forked from deepset-ai/haystack
-
Notifications
You must be signed in to change notification settings - Fork 0
324 lines (296 loc) · 15.5 KB
/
Copy pathcla_draft_gate.yml
File metadata and controls
324 lines (296 loc) · 15.5 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
name: 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.
# - 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 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:
- 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" (seen on #12036). 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;
// 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
}
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);
if (!existing) {
const meta = { reviewers, team_reviewers: teamReviewers };
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 if (reviewers.length || teamReviewers.length) {
// Merge any newly requested reviewers into the stored metadata.
const meta = parseMarker(existing.body);
meta.reviewers = [...new Set([...(meta.reviewers ?? []), ...reviewers])];
meta.team_reviewers = [...new Set([...(meta.team_reviewers ?? []), ...teamReviewers])];
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 },
);
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) {
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}`);
}
}