Skip to content

Commit 1d7214c

Browse files
julian-rischclaudedavidsbatista
authored
ci: add community PR gates (CLA draft gate, linked issue sync, flood guard) (#3638)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent 8e4fd0c commit 1d7214c

3 files changed

Lines changed: 608 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
name: Core / CLA draft gate
2+
3+
# Reduce reviewer load from first-time contributor PRs without a signed CLA:
4+
# - Scheduled sweep: if a first-time contributor's PR is older than 1 hour and
5+
# the `license/cla` status is not green, convert the PR to draft, remove the
6+
# requested reviewers (remembering them in a hidden comment marker), label it
7+
# `cla-pending`, and explain why in a comment.
8+
# - When the CLA is signed (the `license/cla` commit status turns green), mark
9+
# the PR ready for review again and re-request the same reviewers. This also
10+
# covers the case where the contributor marks the PR ready for review
11+
# themselves after signing. The scheduled sweep doubles as a backstop in case
12+
# a status event is missed.
13+
# - Team members are never gated. author_association is unreliable for this
14+
# (private org members appear as CONTRIBUTOR/NONE), so effective repository
15+
# permission is used instead.
16+
# - While a PR stays gated, escalate: remind the contributor 5 days after the
17+
# PR was opened, post a final warning after 10 days, and close the PR after
18+
# 14 days. Each step waits for the previous comment to be a few days old, so
19+
# PRs that are already old when first gated still get the full sequence
20+
# instead of being closed right away.
21+
# Maintainers can opt a PR out by adding the `skip-cla-reminder` label.
22+
23+
on:
24+
status:
25+
schedule:
26+
- cron: "17,47 * * * *"
27+
workflow_dispatch:
28+
29+
permissions:
30+
contents: read
31+
pull-requests: write
32+
issues: write
33+
34+
concurrency:
35+
group: cla-draft-gate
36+
cancel-in-progress: false
37+
38+
jobs:
39+
gate:
40+
runs-on: ubuntu-slim
41+
# For status events, only react to the CLA check turning green.
42+
if: >
43+
github.event_name != 'status' ||
44+
(github.event.context == 'license/cla' && github.event.state == 'success')
45+
steps:
46+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
47+
with:
48+
# The convertPullRequestToDraft/markPullRequestReadyForReview GraphQL
49+
# mutations require a user token; GITHUB_TOKEN fails with "Resource
50+
# not accessible by integration". Use the bot PAT, which also lets the
51+
# ready_for_review/review_requested events from restore() trigger the
52+
# linked_issue_review workflow.
53+
github-token: ${{ secrets.HAYSTACK_BOT_TOKEN }}
54+
script: |
55+
const CLA_CONTEXT = "license/cla";
56+
const LABEL = "cla-pending";
57+
const EXEMPT_LABEL = "skip-cla-reminder";
58+
const GRACE_MS = 60 * 60 * 1000; // 1 hour
59+
const DAY_MS = 24 * 60 * 60 * 1000;
60+
const MARKER = "<!-- cla-draft-gate ";
61+
const REMINDER_MARKER = "<!-- cla-reminder-5d -->";
62+
const WARNING_MARKER = "<!-- cla-warning-10d -->";
63+
const FIRST_TIMER = new Set(["FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"]);
64+
const { owner, repo } = context.repo;
65+
66+
// Team members must never be gated. author_association is unreliable
67+
// for this: PRIVATE org members show up as CONTRIBUTOR/NONE in webhook
68+
// and API payloads, so check the effective repository permission
69+
// instead (write/admin => part of the team).
70+
async function isTeamMember(login) {
71+
try {
72+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
73+
owner, repo, username: login,
74+
});
75+
return ["admin", "write"].includes(data.permission);
76+
} catch {
77+
return false;
78+
}
79+
}
80+
81+
async function claSigned(sha) {
82+
const { data } = await github.rest.repos.getCombinedStatusForRef({
83+
owner, repo, ref: sha, per_page: 100,
84+
});
85+
return data.statuses.find((s) => s.context === CLA_CONTEXT)?.state === "success";
86+
}
87+
88+
async function findMarkerComment(prNumber) {
89+
const comments = await github.paginate(github.rest.issues.listComments, {
90+
owner, repo, issue_number: prNumber, per_page: 100,
91+
});
92+
return comments.find((c) => c.body?.includes(MARKER));
93+
}
94+
95+
function parseMarker(body) {
96+
try {
97+
const start = body.indexOf(MARKER) + MARKER.length;
98+
return JSON.parse(body.slice(start, body.indexOf(" -->", start)));
99+
} catch {
100+
return { reviewers: [], team_reviewers: [] };
101+
}
102+
}
103+
104+
async function ensureLabel() {
105+
await github.rest.issues
106+
.createLabel({
107+
owner, repo, name: LABEL, color: "d93f0b",
108+
description: "PR is in draft until the contributor signs the CLA",
109+
})
110+
.catch(() => {}); // already exists
111+
}
112+
113+
async function gate(pr) {
114+
const labels = pr.labels.map((l) => l.name);
115+
if (labels.includes(EXEMPT_LABEL)) return;
116+
117+
const reviewers = (pr.requested_reviewers ?? []).map((u) => u.login);
118+
const teamReviewers = (pr.requested_teams ?? []).map((t) => t.slug);
119+
120+
const existing = await findMarkerComment(pr.number);
121+
if (!existing) {
122+
const meta = { reviewers, team_reviewers: teamReviewers };
123+
const body = [
124+
`${MARKER}${JSON.stringify(meta)} -->`,
125+
`Hi @${pr.user.login}, thanks a lot for your contribution! :pray:`,
126+
"",
127+
"We noticed that the **Contributor License Agreement (CLA)** check " +
128+
`(\`${CLA_CONTEXT}\`) hasn't passed yet, so we've temporarily moved this ` +
129+
"PR to **draft** and paused the review assignment.",
130+
"",
131+
"To get your PR reviewed, please sign the CLA via the link in the " +
132+
`\`${CLA_CONTEXT}\` check below (or in the CLA bot comment). As soon as ` +
133+
"the check turns green, this PR will automatically be marked ready " +
134+
"for review again and a reviewer will be re-assigned.",
135+
].join("\n");
136+
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
137+
} else if (reviewers.length || teamReviewers.length) {
138+
// Merge any newly requested reviewers into the stored metadata.
139+
const meta = parseMarker(existing.body);
140+
meta.reviewers = [...new Set([...(meta.reviewers ?? []), ...reviewers])];
141+
meta.team_reviewers = [...new Set([...(meta.team_reviewers ?? []), ...teamReviewers])];
142+
const rest = existing.body.slice(existing.body.indexOf(" -->") + 4);
143+
await github.rest.issues.updateComment({
144+
owner, repo, comment_id: existing.id,
145+
body: `${MARKER}${JSON.stringify(meta)} -->${rest}`,
146+
});
147+
}
148+
149+
if (reviewers.length || teamReviewers.length) {
150+
await github.rest.pulls.removeRequestedReviewers({
151+
owner, repo, pull_number: pr.number,
152+
reviewers, team_reviewers: teamReviewers,
153+
});
154+
}
155+
if (!labels.includes(LABEL)) {
156+
await ensureLabel();
157+
await github.rest.issues.addLabels({
158+
owner, repo, issue_number: pr.number, labels: [LABEL],
159+
});
160+
}
161+
await github.graphql(
162+
"mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }",
163+
{ id: pr.node_id },
164+
);
165+
core.info(`Gated PR #${pr.number} (CLA not signed)`);
166+
}
167+
168+
async function restore(pr) {
169+
const comment = await findMarkerComment(pr.number);
170+
const meta = comment ? parseMarker(comment.body) : { reviewers: [], team_reviewers: [] };
171+
172+
// The contributor may have marked the PR ready for review themselves
173+
// after signing; only run the mutation when it's still a draft.
174+
if (pr.draft) {
175+
await github.graphql(
176+
"mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }",
177+
{ id: pr.node_id },
178+
);
179+
}
180+
181+
// Prefer the individual reviewers we removed; re-requesting the team
182+
// instead would make round-robin pick somebody new.
183+
const reviewers = (meta.reviewers ?? []).filter((r) => r !== pr.user.login);
184+
const teamReviewers = reviewers.length ? [] : (meta.team_reviewers ?? []);
185+
if (reviewers.length || teamReviewers.length) {
186+
await github.rest.pulls.requestReviewers({
187+
owner, repo, pull_number: pr.number,
188+
reviewers, team_reviewers: teamReviewers,
189+
});
190+
}
191+
192+
await github.rest.issues
193+
.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL })
194+
.catch(() => {});
195+
await github.rest.issues.createComment({
196+
owner, repo, issue_number: pr.number,
197+
body:
198+
`Thanks for signing the CLA, @${pr.user.login}! :tada: ` +
199+
"This PR is now ready for review again and the reviewer has been re-assigned.",
200+
});
201+
core.info(`Restored PR #${pr.number} (CLA signed)`);
202+
}
203+
204+
// Escalation for PRs that stay gated: reminder after 5 days, final
205+
// warning after 10 days, auto-close after 14 days. Steps are also
206+
// anchored to the previous comment's age so contributors always get
207+
// the full sequence, even on PRs that were old when first gated.
208+
async function escalate(pr) {
209+
const ageMs = Date.now() - new Date(pr.created_at).getTime();
210+
if (ageMs < 5 * DAY_MS) return;
211+
212+
const comments = await github.paginate(github.rest.issues.listComments, {
213+
owner, repo, issue_number: pr.number, per_page: 100,
214+
});
215+
const reminder = comments.find((c) => c.body?.includes(REMINDER_MARKER));
216+
const warning = comments.find((c) => c.body?.includes(WARNING_MARKER));
217+
const ageOf = (comment) => Date.now() - new Date(comment.created_at).getTime();
218+
219+
if (!reminder) {
220+
await github.rest.issues.createComment({
221+
owner, repo, issue_number: pr.number,
222+
body: [
223+
REMINDER_MARKER,
224+
`Hi @${pr.user.login}, just a friendly reminder: this PR is still in ` +
225+
"draft because the **Contributor License Agreement (CLA)** hasn't " +
226+
"been signed yet. We'd love to review your contribution! Please " +
227+
`sign the CLA via the link in the \`${CLA_CONTEXT}\` check, and this ` +
228+
"PR will automatically be marked ready for review.",
229+
].join("\n"),
230+
});
231+
core.info(`Posted 5-day CLA reminder on PR #${pr.number}`);
232+
return;
233+
}
234+
235+
if (!warning) {
236+
if (ageMs >= 10 * DAY_MS && ageOf(reminder) >= 5 * DAY_MS) {
237+
await github.rest.issues.createComment({
238+
owner, repo, issue_number: pr.number,
239+
body: [
240+
WARNING_MARKER,
241+
`Hi @${pr.user.login}, this PR is still waiting for the ` +
242+
"**Contributor License Agreement (CLA)** to be signed. Please " +
243+
"note that if the CLA isn't signed within the **next 4 days**, " +
244+
"this PR will be automatically closed as stale. Signing only " +
245+
`takes a minute via the link in the \`${CLA_CONTEXT}\` check, and ` +
246+
"the PR will then automatically be marked ready for review.",
247+
].join("\n"),
248+
});
249+
core.info(`Posted 10-day CLA warning on PR #${pr.number}`);
250+
}
251+
return;
252+
}
253+
254+
if (ageMs >= 14 * DAY_MS && ageOf(warning) >= 4 * DAY_MS) {
255+
await github.rest.issues.createComment({
256+
owner, repo, issue_number: pr.number,
257+
body:
258+
`Hi @${pr.user.login}, we're closing this PR because the ` +
259+
"**Contributor License Agreement (CLA)** wasn't signed within two " +
260+
"weeks. Thanks a lot for your interest in contributing to Haystack! " +
261+
"If you'd still like to see this change merged, please sign the CLA " +
262+
"and reopen this PR — it will then automatically be marked ready " +
263+
"for review.",
264+
});
265+
await github.rest.pulls.update({
266+
owner, repo, pull_number: pr.number, state: "closed",
267+
});
268+
core.info(`Closed PR #${pr.number} (CLA not signed after 14 days)`);
269+
}
270+
}
271+
272+
// --- Event dispatch -------------------------------------------------
273+
274+
if (context.eventName === "status") {
275+
// CLA turned green on some commit: restore any gated PRs for it.
276+
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
277+
owner, repo, commit_sha: context.payload.sha,
278+
});
279+
for (const prLite of prs) {
280+
if (prLite.state !== "open") continue;
281+
if (!prLite.labels.some((l) => l.name === LABEL)) continue;
282+
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prLite.number });
283+
await restore(pr);
284+
}
285+
return;
286+
}
287+
288+
// Scheduled sweep (also backstop for missed status events).
289+
const prs = await github.paginate(github.rest.pulls.list, {
290+
owner, repo, state: "open", per_page: 100,
291+
});
292+
for (const pr of prs) {
293+
try {
294+
if (pr.user?.type === "Bot") continue;
295+
const labels = pr.labels.map((l) => l.name);
296+
if (labels.includes(EXEMPT_LABEL)) continue;
297+
298+
// Already gated: drive the PR to the right state. Handle the
299+
// non-draft case too, since the contributor can mark a gated PR
300+
// ready for review themselves.
301+
if (labels.includes(LABEL)) {
302+
if (await claSigned(pr.head.sha)) {
303+
await restore(pr);
304+
} else if (pr.draft) {
305+
await escalate(pr);
306+
} else {
307+
// Readied without signing: put it back in draft, then escalate.
308+
await gate(pr);
309+
await escalate(pr);
310+
}
311+
continue;
312+
}
313+
314+
// Not yet gated: only gate fresh, external first-timer PRs.
315+
if (pr.draft) continue;
316+
if (!FIRST_TIMER.has(pr.author_association)) continue;
317+
if (Date.now() - new Date(pr.created_at).getTime() < GRACE_MS) continue;
318+
if (await claSigned(pr.head.sha)) continue;
319+
if (await isTeamMember(pr.user.login)) continue;
320+
await gate(pr);
321+
} catch (error) {
322+
core.warning(`PR #${pr.number}: ${error.message}`);
323+
}
324+
}

0 commit comments

Comments
 (0)