Skip to content

Commit 9564314

Browse files
committed
✨ feat(pipeline-proposals): add github action bot for tracking status
1 parent 9cca86c commit 9564314

File tree

1 file changed

+267
-0
lines changed

1 file changed

+267
-0
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
name: Pipeline proposal approval automation
2+
3+
on:
4+
issues:
5+
types: [opened, closed, labeled, unlabeled]
6+
issue_comment:
7+
types: [created, edited]
8+
9+
jobs:
10+
pipeline_approval:
11+
# Only run for pipeline proposal issues
12+
if: startsWith(github.event.issue.title, 'New Pipeline')
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Handle pipeline proposal approval logic
17+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea #v7.0.1
18+
with:
19+
github-token: ${{ secrets.nf_core_bot_auth_token }}
20+
script: |
21+
const issueNumber = context.issue.number;
22+
const org = context.repo.owner;
23+
const repo = context.repo.repo;
24+
25+
// Ignore comments on closed issues
26+
if (context.eventName === 'issue_comment' && context.payload.issue.state === 'closed') {
27+
console.log('Comment event on closed issue, ignoring.');
28+
return;
29+
}
30+
31+
// ---------------------------------------------
32+
// Fetch members of the core and maintainer teams
33+
// ---------------------------------------------
34+
async function getTeamMembers(teamSlug) {
35+
try {
36+
const res = await github.request('GET /orgs/{org}/teams/{team_slug}/members', {
37+
org,
38+
team_slug: teamSlug,
39+
per_page: 100
40+
});
41+
console.log(`Fetched ${res.data.length} ${teamSlug} team members.`);
42+
return res.data.map(m => m.login);
43+
} catch (err) {
44+
console.error(`Failed to fetch ${teamSlug} team members:`, err);
45+
throw err;
46+
}
47+
}
48+
49+
const coreTeamMembers = await getTeamMembers('core');
50+
const maintainerTeamMembers = await getTeamMembers('maintainers');
51+
console.log('Core team members:', coreTeamMembers);
52+
console.log('Maintainer team members:', maintainerTeamMembers);
53+
54+
// Helper for list formatting and complete status body
55+
function formatUserList(users) {
56+
return users.length ? users.map(u => `[@${u}](https://github.com/${u})`).join(', ') : '-';
57+
}
58+
59+
function generateStatusBody(status, coreApprovalsSet, maintainerApprovalsSet, rejectionsSet, awaitingCore, awaitingMaintainers) {
60+
const coreApprovers = [...coreApprovalsSet];
61+
const maintainerApprovers = [...maintainerApprovalsSet];
62+
const rejecters = [...rejectionsSet];
63+
let body = `## Pipeline proposal approval status: ${status}\n\n`;
64+
body += `Required approvals: Either 2 core team members OR 1 core team member + 1 maintainer\n\n`;
65+
66+
if (coreApprovers.length > 0 || maintainerApprovers.length > 0 || rejecters.length > 0 || awaitingCore.length > 0 || awaitingMaintainers.length > 0) {
67+
body += `|Review Status|Team members|\n|--|--|\n`;
68+
if (coreApprovers.length > 0) {
69+
body += `| ✅ Approved (Core) | ${formatUserList(coreApprovers)} |\n`;
70+
}
71+
if (maintainerApprovers.length > 0) {
72+
body += `| ✅ Approved (Maintainer) | ${formatUserList(maintainerApprovers)} |\n`;
73+
}
74+
if (rejecters.length > 0) {
75+
body += `| ❌ Rejected | ${formatUserList(rejecters)} |\n`;
76+
}
77+
if (awaitingCore.length > 0) {
78+
body += `| 🕐 Pending (Core) | ${formatUserList(awaitingCore)} |\n`;
79+
}
80+
if (awaitingMaintainers.length > 0) {
81+
body += `| 🕐 Pending (Maintainer) | ${formatUserList(awaitingMaintainers)} |\n`;
82+
}
83+
}
84+
return body;
85+
}
86+
87+
// Helper function to update issue status and labels
88+
async function updateIssueStatus(status, closeReason = null) {
89+
const labels = [];
90+
let state = 'open';
91+
92+
switch (status) {
93+
case '✅ Approved':
94+
labels.push('accepted');
95+
state = 'closed';
96+
break;
97+
case '❌ Rejected':
98+
labels.push('turned-down');
99+
state = 'closed';
100+
break;
101+
case '⏰ Timed Out':
102+
labels.push('timed-out');
103+
state = 'closed';
104+
break;
105+
default:
106+
labels.push('proposed');
107+
}
108+
109+
// Update labels
110+
await github.rest.issues.update({
111+
owner: org,
112+
repo,
113+
issue_number: issueNumber,
114+
labels: labels
115+
});
116+
117+
// Close the issue if needed
118+
if (state === 'closed') {
119+
await github.rest.issues.update({
120+
owner: org,
121+
repo,
122+
issue_number: issueNumber,
123+
state: 'closed',
124+
state_reason: closeReason || (status === '✅ Approved' ? 'completed' : 'not_planned')
125+
});
126+
}
127+
}
128+
129+
// Handle label changes
130+
if (context.eventName === 'issues' && (context.payload.action === 'labeled' || context.payload.action === 'unlabeled')) {
131+
const label = context.payload.label.name;
132+
if (label === 'timed-out') {
133+
console.log('Timed-out label detected, updating status');
134+
const statusBody = generateStatusBody('⏰ Timed Out', new Set(), new Set(), new Set(), [], []);
135+
136+
// Find and update the status comment
137+
const comments = await github.paginate(github.rest.issues.listComments, {
138+
owner: org,
139+
repo,
140+
issue_number: issueNumber,
141+
per_page: 100
142+
});
143+
144+
let statusComment = comments.find(c => c.body.startsWith('## Pipeline proposal approval status:'));
145+
if (statusComment) {
146+
await github.rest.issues.updateComment({
147+
owner: org,
148+
repo,
149+
comment_id: statusComment.id,
150+
body: statusBody
151+
});
152+
} else {
153+
await github.rest.issues.createComment({
154+
owner: org,
155+
repo,
156+
issue_number: issueNumber,
157+
body: statusBody
158+
});
159+
}
160+
return;
161+
}
162+
}
163+
164+
// -------------------------------------------------
165+
// If this workflow was triggered by issue creation
166+
// -------------------------------------------------
167+
if (context.eventName === 'issues' && context.payload.action === 'opened') {
168+
const body = generateStatusBody('🕐 Pending', new Set(), new Set(), new Set(), coreTeamMembers, maintainerTeamMembers);
169+
console.log('Creating initial comment for review status');
170+
171+
await github.rest.issues.createComment({
172+
owner: org,
173+
repo,
174+
issue_number: issueNumber,
175+
body
176+
});
177+
178+
// Set initial status to proposed
179+
await updateIssueStatus('🕐 Pending');
180+
return;
181+
}
182+
183+
// ---------------------------------------------------------------------
184+
// Collect comments and compute votes (shared for comment & closed events)
185+
// ---------------------------------------------------------------------
186+
187+
// Collect all comments on the issue
188+
const comments = await github.paginate(github.rest.issues.listComments, {
189+
owner: org,
190+
repo,
191+
issue_number: issueNumber,
192+
per_page: 100
193+
});
194+
195+
const coreApprovals = new Set();
196+
const maintainerApprovals = new Set();
197+
const rejections = new Set();
198+
199+
for (const comment of comments) {
200+
const commenter = comment.user.login;
201+
const isCoreMember = coreTeamMembers.includes(commenter);
202+
const isMaintainer = maintainerTeamMembers.includes(commenter);
203+
204+
if (!isCoreMember && !isMaintainer) continue; // Only team members count
205+
206+
// Count approvals / rejections based on line starting with /approve or /reject
207+
const lines = comment.body.split(/\r?\n/);
208+
for (const rawLine of lines) {
209+
const line = rawLine.trim();
210+
if (/^\/approve\b/i.test(line)) {
211+
if (isCoreMember) {
212+
coreApprovals.add(commenter);
213+
} else if (isMaintainer) {
214+
maintainerApprovals.add(commenter);
215+
}
216+
} else if (/^\/reject\b/i.test(line)) {
217+
rejections.add(commenter);
218+
}
219+
}
220+
}
221+
222+
console.log(`Core approvals (${coreApprovals.size}):`, [...coreApprovals]);
223+
console.log(`Maintainer approvals (${maintainerApprovals.size}):`, [...maintainerApprovals]);
224+
console.log(`Rejections (${rejections.size}):`, [...rejections]);
225+
226+
const awaitingCore = coreTeamMembers.filter(u => !coreApprovals.has(u) && !rejections.has(u));
227+
const awaitingMaintainers = maintainerTeamMembers.filter(u => !maintainerApprovals.has(u) && !rejections.has(u));
228+
229+
// Determine status
230+
let status = '🕐 Pending';
231+
232+
if (context.eventName === 'issues' && context.payload.action === 'closed' && context.payload.issue.state_reason === 'not_planned' && rejections.size > 0) {
233+
status = '❌ Rejected';
234+
} else if ((coreApprovals.size >= 2) || (coreApprovals.size >= 1 && maintainerApprovals.size >= 1)) {
235+
status = '✅ Approved';
236+
}
237+
238+
const statusBody = generateStatusBody(status, coreApprovals, maintainerApprovals, rejections, awaitingCore, awaitingMaintainers);
239+
console.log('New status body to post:\n', statusBody);
240+
241+
// Try to locate the existing status comment (starts with our header)
242+
let statusComment = comments.find(c => c.body.startsWith('## Pipeline proposal approval status:'));
243+
244+
if (statusComment) {
245+
if (statusComment.body.trim() === statusBody.trim()) {
246+
console.log('Status comment already up to date - no update required.');
247+
} else {
248+
console.log('Updating existing status comment.');
249+
await github.rest.issues.updateComment({
250+
owner: org,
251+
repo,
252+
comment_id: statusComment.id,
253+
body: statusBody
254+
});
255+
}
256+
} else {
257+
// Fallback: create a new status comment if missing (shouldn't normally happen)
258+
await github.rest.issues.createComment({
259+
owner: org,
260+
repo,
261+
issue_number: issueNumber,
262+
body: statusBody
263+
});
264+
}
265+
266+
// Update issue status and labels
267+
await updateIssueStatus(status);

0 commit comments

Comments
 (0)