You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/08/05 08:51:23 UTC
(opendal-reqsign) 01/01: ci: use asfml for discussion thread links
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/asfml-discussion-links
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit 243aea68d2dc356b2ae68e059433f44528358385
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Wed Aug 5 16:50:58 2026 +0800
ci: use asfml for discussion thread links
---
.github/workflows/discussion-thread-link.yml | 231 +++++++++++++++++++++++++++
1 file changed, 231 insertions(+)
diff --git a/.github/workflows/discussion-thread-link.yml b/.github/workflows/discussion-thread-link.yml
new file mode 100644
index 0000000..e164877
--- /dev/null
+++ b/.github/workflows/discussion-thread-link.yml
@@ -0,0 +1,231 @@
+# 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-latest
+ 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@opendal.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}`);