Skip to content

Commit 200224b

Browse files
committed
Merge remote-tracking branch 'origin/main' into simplify-document
2 parents 84c93cf + 7626194 commit 200224b

17 files changed

Lines changed: 928 additions & 45 deletions

File tree

.clusterfuzzlite/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Pinned by digest for supply-chain integrity. Bump periodically (the OSS-Fuzz
77
# base-builder is updated frequently with toolchain fixes); resolve a fresh digest with:
88
# docker buildx imagetools inspect gcr.io/oss-fuzz-base/base-builder-python:latest --format '{{.Manifest.Digest}}'
9-
FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:5eb0feae4968e9a30de9e346f3e59fadbecb3ab3e0e251474e164b56d616e93f
9+
FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:78f6de5bc839ea803693987032289ca034d24924be7dd4928f4a187aedb0e875
1010

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

0 commit comments

Comments
 (0)