This is an automated email from the ASF dual-hosted git repository. xuanwo pushed a commit to branch mail-list in repository https://gitbox.apache.org/repos/asf/opendal.git
commit 15b4ff3ced6818ad2cd4b41a8dd729d1639036c5 Author: Xuanwo <[email protected]> AuthorDate: Mon Oct 20 02:13:15 2025 +0800 ci: Auto add ML thread link in github discussion Signed-off-by: Xuanwo <[email protected]> --- .github/workflows/discussion-thread-link.yml | 171 +++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/.github/workflows/discussion-thread-link.yml b/.github/workflows/discussion-thread-link.yml new file mode 100644 index 000000000..511a9a984 --- /dev/null +++ b/.github/workflows/discussion-thread-link.yml @@ -0,0 +1,171 @@ +name: Append mailing-list thread link to new Discussion + +on: + discussion: + types: [created] + +permissions: + discussions: write + contents: read + +jobs: + link-thread: + runs-on: ubuntu-latest + steps: + - name: Append thread URL + uses: actions/github-script@v7 + with: + script: | + const { context, github, core } = require('@actions/github'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const discussion = context.payload.discussion; + const number = discussion.number; + const title = discussion.title || ''; + + const baseUrl = 'https://lists.apache.org/api/stats.lua'; + const list = 'dev'; + const domain = 'opendal.apache.org'; + const searchParams = { header_subject: title, d: 'lte=7d' }; + + function normalize(value) { + return (value || '').trim().toLowerCase(); + } + + function flatten(nodes) { + const result = []; + const stack = [...(nodes || [])]; + while (stack.length) { + const current = stack.pop(); + result.push(current); + if (Array.isArray(current.children) && current.children.length) { + stack.push(...current.children); + } + } + return result; + } + + async function fetchStats(params) { + const searchParams = new URLSearchParams({ list, domain }); + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.set(key, value); + } + } + const response = await fetch(`${baseUrl}?${searchParams.toString()}`, { + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`lists API ${response.status}`); + } + return response.json(); + } + + function extractTid(stats, normalizedTitle) { + const threads = flatten(stats.thread_struct); + if (!threads.length) { + return null; + } + + const emails = Array.isArray(stats.emails) ? stats.emails : []; + const rootEmails = emails + .filter(email => !email['in-reply-to']) + .sort((a, b) => a.epoch - b.epoch); + + for (const email of rootEmails) { + const match = threads.find(thread => thread.tid === email.id); + if (match) { + return match.tid; + } + } + + const normalizedSubjects = new Set([normalizedTitle]); + for (const email of rootEmails) { + normalizedSubjects.add(normalize(email.subject)); + } + + const rootThreads = threads.filter(thread => thread.nest === 1); + for (const subject of normalizedSubjects) { + const match = rootThreads.find(thread => normalize(thread.subject) === subject); + if (match) { + return match.tid; + } + } + + if (rootThreads.length === 1) { + return rootThreads[0].tid; + } + + for (const subject of normalizedSubjects) { + const match = threads.find(thread => normalize(thread.subject) === subject); + if (match) { + return match.tid; + } + } + + return null; + } + + async function locateThreadTid() { + const normalizedTitle = normalize(title); + if (!normalizedTitle) { + core.info('Discussion title missing, cannot query mailing list'); + return null; + } + try { + const stats = await fetchStats(searchParams); + const tid = extractTid(stats, normalizedTitle); + if (tid) { + core.info('Found thread via header_subject search'); + return tid; + } + } catch (error) { + core.info(`Search failed: ${error.message}`); + } + return null; + } + + const deadline = Date.now() + 15 * 60 * 1000; + const delays = [5, 10, 20, 30, 45, 60, 90, 120]; + let attempt = 0; + let tid = null; + + while (Date.now() < deadline && !tid) { + attempt += 1; + tid = await locateThreadTid(); + if (tid) { + break; + } + const wait = delays[Math.min(attempt - 1, delays.length - 1)]; + core.info(`Thread not found yet, retrying in ${wait}s...`); + await new Promise(resolve => setTimeout(resolve, wait * 1000)); + } + + if (!tid) { + core.setFailed('Timeout: thread not found yet'); + return; + } + + const threadUrl = `https://lists.apache.org/thread/${tid}`; + + const { data: current } = await github.request( + 'GET /repos/{owner}/{repo}/discussions/{number}', + { owner, repo, number } + ); + + const body = current.body || ''; + if (body.includes(threadUrl)) { + core.info('Thread URL already present'); + return; + } + + const separator = body.trim().length ? '\n\n' : ''; + const newBody = `${body}${separator}---\n**Mailing list thread:** ${threadUrl}\n`; + + await github.request( + 'PATCH /repos/{owner}/{repo}/discussions/{discussion_number}', + { owner, repo, discussion_number: number, body: newBody } + ); + + core.info(`Appended ${threadUrl}`);
