Skip to content

Commit c26e2cb

Browse files
authored
ci: Auto add ML thread link in github discussion (#6709)
* ci: Auto add ML thread link in github discussion Signed-off-by: Xuanwo <github@xuanwo.io> * Fix licenses Signed-off-by: Xuanwo <github@xuanwo.io> --------- Signed-off-by: Xuanwo <github@xuanwo.io>
1 parent 68b4129 commit c26e2cb

File tree

1 file changed

+188
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)