From 496f1505ba971c77d1f335572fe82da8480beae2 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 1 Sep 2026 12:42:24 +0800 Subject: [PATCH] ci: link discussions to mailing list threads --- .asf.yaml | 9 + .github/workflows/discussion-thread-link.yml | 232 +++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 .github/workflows/discussion-thread-link.yml diff --git a/.asf.yaml b/.asf.yaml index f8f8ddf0..6308283e 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -37,6 +37,15 @@ github: issues: true projects: false discussions: true + custom_subjects: + new_discussion: "{title}" + edit_discussion: "Re: {title}" + close_discussion: "Re: {title}" + close_discussion_with_comment: "Re: {title}" + reopen_discussion: "Re: {title}" + new_comment_discussion: "Re: {title}" + edit_comment_discussion: "Re: {title}" + delete_comment_discussion: "Re: {title}" pull_requests: allow_auto_merge: true allow_update_branch: true diff --git a/.github/workflows/discussion-thread-link.yml b/.github/workflows/discussion-thread-link.yml new file mode 100644 index 00000000..b3fa4524 --- /dev/null +++ b/.github/workflows/discussion-thread-link.yml @@ -0,0 +1,232 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Append mailing-list thread link to new Discussion + +on: + discussion: + types: [created] + workflow_dispatch: + inputs: + discussion_url: + description: 'Discussion URL to backfill mailing list link' + required: true + +permissions: + discussions: write + contents: read + +jobs: + link-thread: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Append thread URL + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { execFile } = require('node:child_process'); + const { promisify } = require('node:util'); + + const execFileAsync = promisify(execFile); + + let owner = context.repo.owner; + let repo = context.repo.repo; + + const manualUrl = context.payload.inputs && context.payload.inputs.discussion_url + ? context.payload.inputs.discussion_url.trim() + : ''; + + let discussion = context.payload.discussion || null; + let number = discussion ? discussion.number : null; + + let discussionRepoMismatch = false; + + if (manualUrl) { + const match = manualUrl.match(/github\.com\/([^/]+)\/([^/]+)\/discussions\/(\d+)/i); + if (!match) { + core.setFailed(`Invalid discussion URL: ${manualUrl}`); + return; + } + owner = match[1]; + repo = match[2]; + number = Number(match[3]); + if (!number || Number.isNaN(number)) { + core.setFailed(`Invalid discussion number in URL: ${manualUrl}`); + return; + } + discussionRepoMismatch = + owner.toLowerCase() !== context.repo.owner.toLowerCase() || + repo.toLowerCase() !== context.repo.repo.toLowerCase(); + } + + if (!discussion && !manualUrl) { + core.setFailed('Discussion payload missing and no discussion_url input provided'); + return; + } + + async function loadDiscussion() { + const query = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + number + title + body + comments(first: 100) { + nodes { + body + } + } + } + } + } + `; + const result = await github.graphql(query, { owner, repo, number }); + const current = result.repository && result.repository.discussion; + if (!current) { + throw new Error(`Discussion not found: ${owner}/${repo}#${number}`); + } + return current; + } + + const currentDiscussion = await loadDiscussion(); + const title = currentDiscussion.title || discussion?.title || ''; + + function normalizeSubject(value) { + return (value || '') + .trim() + .replace(/^(re:\s*)+/i, '') + .toLowerCase(); + } + + async function runAsfml(args) { + const { stdout } = await execFileAsync('npx', ['--yes', 'asfml', ...args], { + maxBuffer: 1024 * 1024, + }); + return stdout; + } + + async function locateThreadUrl() { + const normalizedTitle = normalizeSubject(title); + if (!normalizedTitle) { + core.info('Discussion title missing, cannot query mailing list'); + return null; + } + + const stdout = await runAsfml([ + 'search', + 'dev@asyncband.apache.org', + title, + '--since', + '7d', + '--limit', + '20', + '--format', + 'json', + ]); + const emails = JSON.parse(stdout); + if (!Array.isArray(emails) || !emails.length) { + return null; + } + + const candidate = + emails.find(email => normalizeSubject(email.subject) === normalizedTitle) || + emails[0]; + const mid = candidate.mid || candidate.id; + if (!mid) { + return null; + } + + try { + const rootStdout = await runAsfml(['read', mid, '--root', '--format', 'json']); + const root = JSON.parse(rootStdout); + const tid = root.mid || root.id || mid; + if (tid) { + core.info('Found thread via asfml search'); + return `https://lists.apache.org/thread/${tid}`; + } + } catch (error) { + core.info(`Unable to resolve root email via asfml: ${error.message}`); + } + + core.info('Found candidate email via asfml search'); + return `https://lists.apache.org/thread/${mid}`; + } + + const deadline = Date.now() + 15 * 60 * 1000; + const delays = [5, 10, 20, 30, 45, 60, 90, 120]; + let attempt = 0; + let threadUrl = null; + + while (Date.now() < deadline && !threadUrl) { + attempt += 1; + try { + threadUrl = await locateThreadUrl(); + } catch (error) { + core.info(`Search failed: ${error.message}`); + } + if (threadUrl) { + 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 (!threadUrl) { + core.setFailed('Timeout: thread not found yet'); + return; + } + + if (discussionRepoMismatch) { + core.warning( + `Discussion repository ${owner}/${repo} does not match workflow repository ` + + `${context.repo.owner}/${context.repo.repo}. Skipping comment.` + ); + core.info(`Computed thread URL: ${threadUrl}`); + return; + } + + const body = currentDiscussion.body || ''; + if (body.includes(threadUrl)) { + core.info('Thread URL already present'); + return; + } + + const comments = currentDiscussion.comments?.nodes || []; + if (comments.some(comment => (comment.body || '').includes(threadUrl))) { + core.info('Thread URL already present in comments'); + return; + } + + const mutation = ` + mutation ($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { + url + } + } + } + `; + const result = await github.graphql(mutation, { + discussionId: currentDiscussion.id, + body: `**Mailing list thread:** ${threadUrl}`, + }); + + core.info(`Commented ${threadUrl}: ${result.addDiscussionComment.comment.url}`);