Skip to content

Append mailing-list thread link to new Discussion #19

Append mailing-list thread link to new Discussion

Append mailing-list thread link to new Discussion #19

# 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@v8
with:
script: |
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;
}
const { data } = await github.request(
'GET /repos/{owner}/{repo}/discussions/{discussion_number}',
{ owner, repo, discussion_number: number }
);
discussion = data;
discussionRepoMismatch =
owner.toLowerCase() !== context.repo.owner.toLowerCase() ||
repo.toLowerCase() !== context.repo.repo.toLowerCase();
}
if (!discussion) {
core.setFailed('Discussion payload missing and no discussion_url input provided');
return;
}
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}`;
if (discussionRepoMismatch) {
core.warning(
`Discussion repository ${owner}/${repo} does not match workflow repository ` +
`${context.repo.owner}/${context.repo.repo}. Skipping PATCH.`
);
core.info(`Computed thread URL: ${threadUrl}`);
return;
}
const { data: current } = await github.request(
'GET /repos/{owner}/{repo}/discussions/{discussion_number}',
{ owner, repo, discussion_number: 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`;
let updated = false;
try {
await github.request(
'PATCH /repos/{owner}/{repo}/discussions/{discussion_number}',
{
owner,
repo,
discussion_number: number,
body: newBody,
headers: { 'X-GitHub-Api-Version': '2022-11-28' },
}
);
updated = true;
core.info('Updated discussion via REST API');
} catch (error) {
const status = error && error.status ? error.status : 'unknown';
core.info(`REST update failed with status ${status}, attempting GraphQL`);
if (status !== 403 && status !== 404) {
throw error;
}
}
if (!updated) {
const mutation = `
mutation ($discussionId: ID!, $body: String!) {
updateDiscussion(input: { discussionId: $discussionId, body: $body }) {
discussion { number }
}
}
`;
await github.graphql(mutation, {
discussionId: discussion.node_id,
body: newBody,
});
core.info('Updated discussion via GraphQL');
}
core.info(`Appended ${threadUrl}`);