-
Notifications
You must be signed in to change notification settings - Fork 318
475 lines (439 loc) · 23.1 KB
/
Copy pathCI_cla_draft_gate.yml
File metadata and controls
475 lines (439 loc) · 23.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
name: Core / CLA draft gate
# Reduce reviewer load from first-time contributor PRs without a signed CLA:
# - Scheduled sweep: if a first-time contributor's PR is older than 1 hour and
# the `license/cla` status is not green, convert the PR to draft, remove the
# requested reviewers (remembering them in a hidden comment marker), label it
# `cla-pending`, and explain why in a comment. The PR's linked issues are
# handed back too: the reviewers we removed are unassigned and the issue is
# moved out of the review column of the Open Source project board, undoing
# what linked_issue_review.yml did when the PR was opened. Otherwise a parked
# PR keeps its issue looking taken and in review.
# - When the CLA is signed (the `license/cla` commit status turns green), mark
# the PR ready for review again and re-request the same reviewers. This also
# covers the case where the contributor marks the PR ready for review
# themselves after signing. The resulting ready_for_review/review_requested
# events make linked_issue_review.yml re-claim the linked issues, which only
# works because gating unassigned them (it skips issues that already have an
# assignee). The scheduled sweep doubles as a backstop in case a status event
# is missed.
# - Team members are never gated. author_association is unreliable for this
# (private org members appear as CONTRIBUTOR/NONE), so effective repository
# permission is used instead.
# - While a PR stays gated, escalate: remind the contributor 5 days after the
# PR was opened, post a final warning after 10 days, and close the PR after
# 14 days. Each step waits for the previous comment to be a few days old, so
# PRs that are already old when first gated still get the full sequence
# instead of being closed right away.
# Maintainers can opt a PR out by adding the `skip-cla-reminder` label.
on:
status:
schedule:
- cron: "17,47 * * * *"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: cla-draft-gate
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-slim
# For status events, only react to the CLA check turning green.
if: >
github.event_name != 'status' ||
(github.event.context == 'license/cla' && github.event.state == 'success')
steps:
- id: gate
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
# The convertPullRequestToDraft/markPullRequestReadyForReview GraphQL
# mutations require a user token; GITHUB_TOKEN fails with "Resource
# not accessible by integration". Use the bot PAT, which also lets the
# ready_for_review/review_requested events from restore() trigger the
# linked_issue_review workflow.
github-token: ${{ secrets.HAYSTACK_BOT_TOKEN }}
script: |
const CLA_CONTEXT = "license/cla";
const LABEL = "cla-pending";
const EXEMPT_LABEL = "skip-cla-reminder";
const GRACE_MS = 60 * 60 * 1000; // 1 hour
const DAY_MS = 24 * 60 * 60 * 1000;
const MARKER = "<!-- cla-draft-gate ";
const REMINDER_MARKER = "<!-- cla-reminder-5d -->";
const WARNING_MARKER = "<!-- cla-warning-10d -->";
const FIRST_TIMER = new Set(["FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"]);
const { owner, repo } = context.repo;
// Node ids of the linked issues released by this run, handed to the
// next step (which needs a different token to touch the project).
const parkedIssueIds = new Set();
// Team members must never be gated. author_association is unreliable
// for this: PRIVATE org members show up as CONTRIBUTOR/NONE in webhook
// and API payloads, so check the effective repository permission
// instead (write/admin => part of the team).
async function isTeamMember(login) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username: login,
});
return ["admin", "write"].includes(data.permission);
} catch {
return false;
}
}
async function claSigned(sha) {
const { data } = await github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: sha, per_page: 100,
});
return data.statuses.find((s) => s.context === CLA_CONTEXT)?.state === "success";
}
async function findMarkerComment(prNumber) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
return comments.find((c) => c.body?.includes(MARKER));
}
function parseMarker(body) {
try {
const start = body.indexOf(MARKER) + MARKER.length;
return JSON.parse(body.slice(start, body.indexOf(" -->", start)));
} catch {
return { reviewers: [], team_reviewers: [] };
}
}
async function ensureLabel() {
await github.rest.issues
.createLabel({
owner, repo, name: LABEL, color: "d93f0b",
description: "PR is in draft until the contributor signs the CLA",
})
.catch(() => {}); // already exists
}
// linked_issue_review.yml assigns the requested reviewers to the PR's
// linked issues and moves them to the review column, to signal that the
// issue is taken. A gated PR isn't being reviewed, so hand the issue
// back: unassign exactly the reviewers we removed (anybody else stays,
// e.g. a maintainer who deliberately assigned themselves) and remember
// the issue for the board step.
async function releaseLinkedIssues(pr, meta) {
const assignees = meta.reviewers ?? [];
// linked_issue_review only claims an issue when it has individual
// reviewers to assign, so with nobody to unassign there is nothing to
// hand back -- and no reason to move the board item either.
if (!assignees.length) return;
const result = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 10) {
nodes { id number repository { nameWithOwner } }
}
}
}
}`,
{ owner, repo, number: pr.number },
);
const issues = result.repository.pullRequest.closingIssuesReferences.nodes.filter(
(issue) => issue.repository.nameWithOwner === `${owner}/${repo}`,
);
for (const issue of issues) {
// Logins that aren't assigned are ignored, so this is idempotent.
await github.rest.issues.removeAssignees({
owner, repo, issue_number: issue.number, assignees,
});
parkedIssueIds.add(issue.id);
core.info(`Released linked issue #${issue.number} of PR #${pr.number}`);
}
}
async function gate(pr) {
const labels = pr.labels.map((l) => l.name);
if (labels.includes(EXEMPT_LABEL)) return;
const reviewers = (pr.requested_reviewers ?? []).map((u) => u.login);
const teamReviewers = (pr.requested_teams ?? []).map((t) => t.slug);
const existing = await findMarkerComment(pr.number);
let meta = { reviewers, team_reviewers: teamReviewers };
if (!existing) {
const body = [
`${MARKER}${JSON.stringify(meta)} -->`,
`Hi @${pr.user.login}, thanks a lot for your contribution! :pray:`,
"",
"We noticed that the **Contributor License Agreement (CLA)** check " +
`(\`${CLA_CONTEXT}\`) hasn't passed yet, so we've temporarily moved this ` +
"PR to **draft** and paused the review assignment.",
"",
"To get your PR reviewed, please sign the CLA via the link in the " +
`\`${CLA_CONTEXT}\` check below (or in the CLA bot comment). As soon as ` +
"the check turns green, this PR will automatically be marked ready " +
"for review again and a reviewer will be re-assigned.",
].join("\n");
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
} else {
// Merge any newly requested reviewers into the stored metadata.
const stored = parseMarker(existing.body);
meta = {
reviewers: [...new Set([...(stored.reviewers ?? []), ...reviewers])],
team_reviewers: [...new Set([...(stored.team_reviewers ?? []), ...teamReviewers])],
};
if (reviewers.length || teamReviewers.length) {
const rest = existing.body.slice(existing.body.indexOf(" -->") + 4);
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id,
body: `${MARKER}${JSON.stringify(meta)} -->${rest}`,
});
}
}
if (reviewers.length || teamReviewers.length) {
await github.rest.pulls.removeRequestedReviewers({
owner, repo, pull_number: pr.number,
reviewers, team_reviewers: teamReviewers,
});
}
if (!labels.includes(LABEL)) {
await ensureLabel();
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [LABEL],
});
}
await github.graphql(
"mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }",
{ id: pr.node_id },
);
// Uses the merged metadata rather than pr.requested_reviewers: on the
// re-gate path (contributor readies the PR themselves and CODEOWNERS
// re-requests review) the two differ, and we want to release both.
await releaseLinkedIssues(pr, meta);
core.info(`Gated PR #${pr.number} (CLA not signed)`);
}
async function restore(pr) {
const comment = await findMarkerComment(pr.number);
const meta = comment ? parseMarker(comment.body) : { reviewers: [], team_reviewers: [] };
// The contributor may have marked the PR ready for review themselves
// after signing; only run the mutation when it's still a draft.
if (pr.draft) {
await github.graphql(
"mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }",
{ id: pr.node_id },
);
}
// Prefer the individual reviewers we removed; re-requesting the team
// instead would make round-robin pick somebody new.
const reviewers = (meta.reviewers ?? []).filter((r) => r !== pr.user.login);
const teamReviewers = reviewers.length ? [] : (meta.team_reviewers ?? []);
if (reviewers.length || teamReviewers.length) {
await github.rest.pulls.requestReviewers({
owner, repo, pull_number: pr.number,
reviewers, team_reviewers: teamReviewers,
});
}
await github.rest.issues
.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL })
.catch(() => {});
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body:
`Thanks for signing the CLA, @${pr.user.login}! :tada: ` +
"This PR is now ready for review again and the reviewer has been re-assigned.",
});
core.info(`Restored PR #${pr.number} (CLA signed)`);
}
// Escalation for PRs that stay gated: reminder after 5 days, final
// warning after 10 days, auto-close after 14 days. Steps are also
// anchored to the previous comment's age so contributors always get
// the full sequence, even on PRs that were old when first gated.
async function escalate(pr) {
const ageMs = Date.now() - new Date(pr.created_at).getTime();
if (ageMs < 5 * DAY_MS) return;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const reminder = comments.find((c) => c.body?.includes(REMINDER_MARKER));
const warning = comments.find((c) => c.body?.includes(WARNING_MARKER));
const ageOf = (comment) => Date.now() - new Date(comment.created_at).getTime();
if (!reminder) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: [
REMINDER_MARKER,
`Hi @${pr.user.login}, just a friendly reminder: this PR is still in ` +
"draft because the **Contributor License Agreement (CLA)** hasn't " +
"been signed yet. We'd love to review your contribution! Please " +
`sign the CLA via the link in the \`${CLA_CONTEXT}\` check, and this ` +
"PR will automatically be marked ready for review.",
].join("\n"),
});
core.info(`Posted 5-day CLA reminder on PR #${pr.number}`);
return;
}
if (!warning) {
if (ageMs >= 10 * DAY_MS && ageOf(reminder) >= 5 * DAY_MS) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: [
WARNING_MARKER,
`Hi @${pr.user.login}, this PR is still waiting for the ` +
"**Contributor License Agreement (CLA)** to be signed. Please " +
"note that if the CLA isn't signed within the **next 4 days**, " +
"this PR will be automatically closed as stale. Signing only " +
`takes a minute via the link in the \`${CLA_CONTEXT}\` check, and ` +
"the PR will then automatically be marked ready for review.",
].join("\n"),
});
core.info(`Posted 10-day CLA warning on PR #${pr.number}`);
}
return;
}
if (ageMs >= 14 * DAY_MS && ageOf(warning) >= 4 * DAY_MS) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body:
`Hi @${pr.user.login}, we're closing this PR because the ` +
"**Contributor License Agreement (CLA)** wasn't signed within two " +
"weeks. Thanks a lot for your interest in contributing to Haystack! " +
"If you'd still like to see this change merged, please sign the CLA " +
"and reopen this PR — it will then automatically be marked ready " +
"for review.",
});
await github.rest.pulls.update({
owner, repo, pull_number: pr.number, state: "closed",
});
core.info(`Closed PR #${pr.number} (CLA not signed after 14 days)`);
}
}
// --- Event dispatch -------------------------------------------------
if (context.eventName === "status") {
// CLA turned green on some commit: restore any gated PRs for it.
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, repo, commit_sha: context.payload.sha,
});
for (const prLite of prs) {
if (prLite.state !== "open") continue;
if (!prLite.labels.some((l) => l.name === LABEL)) continue;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prLite.number });
await restore(pr);
}
return;
}
// Scheduled sweep (also backstop for missed status events).
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
for (const pr of prs) {
try {
if (pr.user?.type === "Bot") continue;
const labels = pr.labels.map((l) => l.name);
if (labels.includes(EXEMPT_LABEL)) continue;
// Already gated: drive the PR to the right state. Handle the
// non-draft case too, since the contributor can mark a gated PR
// ready for review themselves.
if (labels.includes(LABEL)) {
if (await claSigned(pr.head.sha)) {
await restore(pr);
} else if (pr.draft) {
// Also re-run the release: it heals PRs that were gated before
// this ran on any linked issues, and issues that got re-claimed
// while the PR was briefly readied.
const comment = await findMarkerComment(pr.number);
await releaseLinkedIssues(pr, comment ? parseMarker(comment.body) : {});
await escalate(pr);
} else {
// Readied without signing: put it back in draft, then escalate.
await gate(pr);
await escalate(pr);
}
continue;
}
// Not yet gated: only gate fresh, external first-timer PRs.
if (pr.draft) continue;
if (!FIRST_TIMER.has(pr.author_association)) continue;
if (Date.now() - new Date(pr.created_at).getTime() < GRACE_MS) continue;
if (await claSigned(pr.head.sha)) continue;
if (await isTeamMember(pr.user.login)) continue;
await gate(pr);
} catch (error) {
core.warning(`PR #${pr.number}: ${error.message}`);
}
}
core.setOutput("backlog_issue_ids", JSON.stringify([...parkedIssueIds]));
- name: Move released issues back to the backlog
if: steps.gate.outputs.backlog_issue_ids && steps.gate.outputs.backlog_issue_ids != '[]'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ISSUE_NODE_IDS: ${{ steps.gate.outputs.backlog_issue_ids }}
PROJECT_ORG: deepset-ai
PROJECT_NUMBER: "5"
# Names of the Status options, matched case-insensitively and falling
# back to a substring match (the review option is ":eyes: In review").
# This only undoes the move linked_issue_review.yml made, so an issue is
# left alone unless it currently sits in the review column.
REVIEW_STATUS_NAME: In review
BACKLOG_STATUS_NAME: Backlog
with:
# The default GITHUB_TOKEN cannot access org-level projects.
github-token: ${{ secrets.GH_PROJECT_PAT }}
script: |
const issueIds = JSON.parse(process.env.ISSUE_NODE_IDS);
const result = await github.graphql(
`query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
id
field(name: "Status") {
... on ProjectV2SingleSelectField { id options { id name } }
}
}
}
}`,
{ org: process.env.PROJECT_ORG, number: Number(process.env.PROJECT_NUMBER) },
);
const project = result.organization.projectV2;
const field = project.field;
const optionFor = (name) =>
field.options.find((o) => o.name.toLowerCase() === name.toLowerCase()) ??
field.options.find((o) => o.name.toLowerCase().includes(name.toLowerCase()));
const backlog = optionFor(process.env.BACKLOG_STATUS_NAME);
const review = optionFor(process.env.REVIEW_STATUS_NAME);
if (!backlog || !review) {
core.warning(
`No Status option matching "${process.env.BACKLOG_STATUS_NAME}" and ` +
`"${process.env.REVIEW_STATUS_NAME}" in project ${process.env.PROJECT_NUMBER}. ` +
`Available: ${field.options.map((o) => o.name).join(", ")}`,
);
return;
}
for (const issueId of issueIds) {
const node = await github.graphql(
`query($id: ID!) {
node(id: $id) {
... on Issue {
projectItems(first: 50) {
nodes {
id
project { id }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { optionId }
}
}
}
}
}
}`,
{ id: issueId },
);
const item = node.node.projectItems.nodes.find((n) => n.project.id === project.id);
// Nothing to release if the issue was never added to the board, and
// don't override a column somebody moved the issue to deliberately.
if (!item || item.fieldValueByName?.optionId !== review.id) continue;
await github.graphql(
`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
value: { singleSelectOptionId: $optionId }
}
) { projectV2Item { id } }
}`,
{ projectId: project.id, itemId: item.id, fieldId: field.id, optionId: backlog.id },
);
core.info(`Moved issue ${issueId} back to "${backlog.name}"`);
}