Skip to content

Commit 15b4ff3

Browse files
committed
ci: Auto add ML thread link in github discussion
Signed-off-by: Xuanwo <github@xuanwo.io>
1 parent 68b4129 commit 15b4ff3

File tree

1 file changed

+171
-0
lines changed

1 file changed

+171
-0
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
name: Append mailing-list thread link to new Discussion
2+
3+
on:
4+
discussion:
5+
types: [created]
6+
7+
permissions:
8+
discussions: write
9+
contents: read
10+
11+
jobs:
12+
link-thread:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Append thread URL
16+
uses: actions/github-script@v7
17+
with:
18+
script: |
19+
const { context, github, core } = require('@actions/github');
20+
21+
const owner = context.repo.owner;
22+
const repo = context.repo.repo;
23+
const discussion = context.payload.discussion;
24+
const number = discussion.number;
25+
const title = discussion.title || '';
26+
27+
const baseUrl = 'https://lists.apache.org/api/stats.lua';
28+
const list = 'dev';
29+
const domain = 'opendal.apache.org';
30+
const searchParams = { header_subject: title, d: 'lte=7d' };
31+
32+
function normalize(value) {
33+
return (value || '').trim().toLowerCase();
34+
}
35+
36+
function flatten(nodes) {
37+
const result = [];
38+
const stack = [...(nodes || [])];
39+
while (stack.length) {
40+
const current = stack.pop();
41+
result.push(current);
42+
if (Array.isArray(current.children) && current.children.length) {
43+
stack.push(...current.children);
44+
}
45+
}
46+
return result;
47+
}
48+
49+
async function fetchStats(params) {
50+
const searchParams = new URLSearchParams({ list, domain });
51+
for (const [key, value] of Object.entries(params)) {
52+
if (value) {
53+
searchParams.set(key, value);
54+
}
55+
}
56+
const response = await fetch(`${baseUrl}?${searchParams.toString()}`, {
57+
headers: { accept: 'application/json' },
58+
});
59+
if (!response.ok) {
60+
throw new Error(`lists API ${response.status}`);
61+
}
62+
return response.json();
63+
}
64+
65+
function extractTid(stats, normalizedTitle) {
66+
const threads = flatten(stats.thread_struct);
67+
if (!threads.length) {
68+
return null;
69+
}
70+
71+
const emails = Array.isArray(stats.emails) ? stats.emails : [];
72+
const rootEmails = emails
73+
.filter(email => !email['in-reply-to'])
74+
.sort((a, b) => a.epoch - b.epoch);
75+
76+
for (const email of rootEmails) {
77+
const match = threads.find(thread => thread.tid === email.id);
78+
if (match) {
79+
return match.tid;
80+
}
81+
}
82+
83+
const normalizedSubjects = new Set([normalizedTitle]);
84+
for (const email of rootEmails) {
85+
normalizedSubjects.add(normalize(email.subject));
86+
}
87+
88+
const rootThreads = threads.filter(thread => thread.nest === 1);
89+
for (const subject of normalizedSubjects) {
90+
const match = rootThreads.find(thread => normalize(thread.subject) === subject);
91+
if (match) {
92+
return match.tid;
93+
}
94+
}
95+
96+
if (rootThreads.length === 1) {
97+
return rootThreads[0].tid;
98+
}
99+
100+
for (const subject of normalizedSubjects) {
101+
const match = threads.find(thread => normalize(thread.subject) === subject);
102+
if (match) {
103+
return match.tid;
104+
}
105+
}
106+
107+
return null;
108+
}
109+
110+
async function locateThreadTid() {
111+
const normalizedTitle = normalize(title);
112+
if (!normalizedTitle) {
113+
core.info('Discussion title missing, cannot query mailing list');
114+
return null;
115+
}
116+
try {
117+
const stats = await fetchStats(searchParams);
118+
const tid = extractTid(stats, normalizedTitle);
119+
if (tid) {
120+
core.info('Found thread via header_subject search');
121+
return tid;
122+
}
123+
} catch (error) {
124+
core.info(`Search failed: ${error.message}`);
125+
}
126+
return null;
127+
}
128+
129+
const deadline = Date.now() + 15 * 60 * 1000;
130+
const delays = [5, 10, 20, 30, 45, 60, 90, 120];
131+
let attempt = 0;
132+
let tid = null;
133+
134+
while (Date.now() < deadline && !tid) {
135+
attempt += 1;
136+
tid = await locateThreadTid();
137+
if (tid) {
138+
break;
139+
}
140+
const wait = delays[Math.min(attempt - 1, delays.length - 1)];
141+
core.info(`Thread not found yet, retrying in ${wait}s...`);
142+
await new Promise(resolve => setTimeout(resolve, wait * 1000));
143+
}
144+
145+
if (!tid) {
146+
core.setFailed('Timeout: thread not found yet');
147+
return;
148+
}
149+
150+
const threadUrl = `https://lists.apache.org/thread/${tid}`;
151+
152+
const { data: current } = await github.request(
153+
'GET /repos/{owner}/{repo}/discussions/{number}',
154+
{ owner, repo, number }
155+
);
156+
157+
const body = current.body || '';
158+
if (body.includes(threadUrl)) {
159+
core.info('Thread URL already present');
160+
return;
161+
}
162+
163+
const separator = body.trim().length ? '\n\n' : '';
164+
const newBody = `${body}${separator}---\n**Mailing list thread:** ${threadUrl}\n`;
165+
166+
await github.request(
167+
'PATCH /repos/{owner}/{repo}/discussions/{discussion_number}',
168+
{ owner, repo, discussion_number: number, body: newBody }
169+
);
170+
171+
core.info(`Appended ${threadUrl}`);

0 commit comments

Comments
 (0)