Skip to content

Commit eb82a30

Browse files
committed
ci: add Explore PR Triage Commenter workflow
Posts a sticky maintainer-facing comment on PRs that touch topic or collection pages. For topic PRs: repo count for the topic. For collection PRs: per-item stars, last push, owner type, and a self-submission flag. Edit-in-place via marker comment, so synchronize/reopen events update the same comment instead of stacking. Reads PR head content via repos.getContent at pr.head.sha rather than checking out the fork (avoids the issues addressed in #5094 by sidestepping checkout entirely).
1 parent 6e614f4 commit eb82a30

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
name: Explore PR Triage Commenter
2+
3+
# Posts a sticky comment on PRs that touch topic or collection pages,
4+
# surfacing the facts maintainers normally look up by hand:
5+
# - topics: repo count for the topic
6+
# - collections: per-item stars, last push, owner type, plus a flag if
7+
# the PR author looks like one of the item owners (self-submission)
8+
#
9+
# Edit-in-place: subsequent runs (synchronize, reopen) update the same
10+
# comment instead of posting a new one. Marker: <!-- explore-triage-comment -->
11+
12+
on:
13+
pull_request_target:
14+
types: [opened, synchronize, reopened]
15+
paths:
16+
- 'topics/**'
17+
- 'collections/**'
18+
19+
permissions:
20+
contents: read
21+
pull-requests: write
22+
23+
jobs:
24+
triage:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/github-script@v9
28+
env:
29+
MARKER: '<!-- explore-triage-comment -->'
30+
with:
31+
script: |
32+
const marker = process.env.MARKER;
33+
const pr = context.payload.pull_request;
34+
const prNumber = pr.number;
35+
const prAuthor = pr.user.login.toLowerCase();
36+
const headSha = pr.head.sha;
37+
const baseOwner = context.repo.owner;
38+
const baseRepo = context.repo.repo;
39+
40+
// List files in the PR (paginated).
41+
const files = await github.paginate(github.rest.pulls.listFiles, {
42+
owner: baseOwner,
43+
repo: baseRepo,
44+
pull_number: prNumber,
45+
per_page: 100,
46+
});
47+
48+
// Detect topic and collection slugs touched.
49+
// Skip removed files; only validate slug shape we'd ever expect on disk.
50+
const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/i;
51+
const topics = new Set();
52+
const collections = new Set();
53+
for (const f of files) {
54+
if (f.status === 'removed') continue;
55+
const m = f.filename.match(/^(topics|collections)\/([^\/]+)\//);
56+
if (!m) continue;
57+
const slug = m[2];
58+
if (!SLUG.test(slug)) continue;
59+
if (m[1] === 'topics') topics.add(slug);
60+
else collections.add(slug);
61+
}
62+
63+
if (topics.size === 0 && collections.size === 0) {
64+
core.info('No topic or collection changes detected; nothing to do.');
65+
return;
66+
}
67+
68+
const sections = [];
69+
70+
// ---- Topic section ----
71+
if (topics.size > 0) {
72+
const lines = ['### Topics', ''];
73+
for (const slug of topics) {
74+
let count = null;
75+
try {
76+
const res = await github.rest.search.repos({
77+
q: `topic:${slug}`,
78+
per_page: 1,
79+
});
80+
count = res.data.total_count;
81+
} catch (err) {
82+
core.warning(`Search failed for topic '${slug}': ${err.message}`);
83+
}
84+
const url = `https://github.com/topics/${encodeURIComponent(slug)}`;
85+
if (count == null) {
86+
lines.push(`- **${slug}** — [topic page](${url}) _(repo count lookup failed)_`);
87+
} else {
88+
lines.push(`- **${slug}** — ${count.toLocaleString()} repositories — [topic page](${url})`);
89+
}
90+
}
91+
sections.push(lines.join('\n'));
92+
}
93+
94+
// ---- Collection section ----
95+
if (collections.size > 0) {
96+
for (const slug of collections) {
97+
const lines = [`### Collection \`${slug}\``, ''];
98+
99+
// Read collection's index.md at the PR head SHA.
100+
// PR commits from forks are mirrored into the base repo's network,
101+
// so we can fetch from the base repo with the head SHA — simpler
102+
// and avoids any cross-repo token concerns.
103+
let content;
104+
try {
105+
const res = await github.rest.repos.getContent({
106+
owner: baseOwner,
107+
repo: baseRepo,
108+
path: `collections/${slug}/index.md`,
109+
ref: headSha,
110+
});
111+
content = Buffer.from(res.data.content, 'base64').toString('utf8');
112+
} catch (err) {
113+
lines.push(`_Could not read \`collections/${slug}/index.md\` at PR head (\`${err.status || 'error'}\`)._`);
114+
sections.push(lines.join('\n'));
115+
continue;
116+
}
117+
118+
const items = parseCollectionItems(content);
119+
if (items.length === 0) {
120+
lines.push('_No `items:` list found in frontmatter._');
121+
sections.push(lines.join('\n'));
122+
continue;
123+
}
124+
125+
lines.push('| Item | Stars | Last push | Owner type | Notes |');
126+
lines.push('| --- | ---: | --- | --- | --- |');
127+
128+
for (const item of items) {
129+
if (!/^[\w.-]+\/[\w.-]+$/.test(item)) {
130+
lines.push(`| \`${item}\` | – | – | – | invalid format |`);
131+
continue;
132+
}
133+
const [owner, repo] = item.split('/');
134+
try {
135+
const r = await github.rest.repos.get({ owner, repo });
136+
const stars = r.data.stargazers_count.toLocaleString();
137+
const pushed = r.data.pushed_at ? r.data.pushed_at.slice(0, 10) : '–';
138+
const ownerType = r.data.owner.type;
139+
const notes = [];
140+
if (owner.toLowerCase() === prAuthor) notes.push('⚠️ possible self-submission');
141+
if (r.data.archived) notes.push('archived');
142+
if (r.data.disabled) notes.push('disabled');
143+
lines.push(`| [\`${item}\`](https://github.com/${item}) | ${stars} | ${pushed} | ${ownerType} | ${notes.join(', ') || '–'} |`);
144+
} catch (err) {
145+
const note = err.status === 404 ? 'not found' : `error (${err.status || '?'})`;
146+
lines.push(`| \`${item}\` | – | – | – | ${note} |`);
147+
}
148+
}
149+
lines.push('');
150+
sections.push(lines.join('\n'));
151+
}
152+
}
153+
154+
const body = [
155+
marker,
156+
'<!-- Maintained by .github/workflows/explore-triage-commenter.yml. Edits will be overwritten. -->',
157+
'',
158+
'## Maintainer triage',
159+
'',
160+
...sections,
161+
].join('\n');
162+
163+
// Edit-in-place via marker.
164+
const comments = await github.paginate(github.rest.issues.listComments, {
165+
owner: baseOwner,
166+
repo: baseRepo,
167+
issue_number: prNumber,
168+
per_page: 100,
169+
});
170+
const existing = comments.find(c => c.body && c.body.startsWith(marker));
171+
172+
if (existing) {
173+
await github.rest.issues.updateComment({
174+
owner: baseOwner,
175+
repo: baseRepo,
176+
comment_id: existing.id,
177+
body,
178+
});
179+
core.info(`Updated comment ${existing.id}`);
180+
} else {
181+
await github.rest.issues.createComment({
182+
owner: baseOwner,
183+
repo: baseRepo,
184+
issue_number: prNumber,
185+
body,
186+
});
187+
core.info('Created new comment');
188+
}
189+
190+
function parseCollectionItems(text) {
191+
// Frontmatter between leading --- lines.
192+
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
193+
if (!fmMatch) return [];
194+
const lines = fmMatch[1].split('\n');
195+
const items = [];
196+
let inItems = false;
197+
for (const line of lines) {
198+
if (/^items:\s*$/.test(line)) { inItems = true; continue; }
199+
// Next top-level key ends the items block.
200+
if (inItems && /^[a-zA-Z_]\w*\s*:/.test(line)) break;
201+
if (inItems) {
202+
const m = line.match(/^\s*-\s*([^\s#]+)/);
203+
if (m) items.push(m[1]);
204+
}
205+
}
206+
return items;
207+
}

0 commit comments

Comments
 (0)