Skip to content

Append mailing-list thread link to new Discussion #1

Append mailing-list thread link to new Discussion

Append mailing-list thread link to new Discussion #1

# 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]
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}`);