blob: ff777edf88335b3f81bfb0c66e6234672209e32d [file]
# 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@v9
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}`);