@@ -6,8 +6,13 @@ name: CLA draft gate
66# requested reviewers (remembering them in a hidden comment marker), label it
77# `cla-pending`, and explain why in a comment.
88# - 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.
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.
1116# - While a PR stays gated, escalate: remind the contributor 5 days after the
1217# PR was opened, post a final warning after 10 days, and close the PR after
1318# 14 days. Each step waits for the previous comment to be a few days old, so
@@ -40,10 +45,12 @@ jobs:
4045 steps :
4146 - uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
4247 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 }}
48+ # The convertPullRequestToDraft/markPullRequestReadyForReview GraphQL
49+ # mutations require a user token; GITHUB_TOKEN fails with "Resource
50+ # not accessible by integration" (seen on #12036). Use the bot PAT,
51+ # which also lets the ready_for_review/review_requested events from
52+ # restore() trigger the linked_issue_review workflow.
53+ github-token : ${{ secrets.HAYSTACK_BOT_TOKEN }}
4754 script : |
4855 const CLA_CONTEXT = "license/cla";
4956 const LABEL = "cla-pending";
5663 const FIRST_TIMER = new Set(["FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"]);
5764 const { owner, repo } = context.repo;
5865
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+
5981 async function claSigned(sha) {
6082 const { data } = await github.rest.repos.getCombinedStatusForRef({
6183 owner, repo, ref: sha, per_page: 100,
@@ -147,10 +169,14 @@ jobs:
147169 const comment = await findMarkerComment(pr.number);
148170 const meta = comment ? parseMarker(comment.body) : { reviewers: [], team_reviewers: [] };
149171
150- await github.graphql(
151- "mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }",
152- { id: pr.node_id },
153- );
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+ }
154180
155181 // Prefer the individual reviewers we removed; re-requesting the team
156182 // instead would make round-robin pick somebody new.
@@ -251,7 +277,7 @@ jobs:
251277 owner, repo, commit_sha: context.payload.sha,
252278 });
253279 for (const prLite of prs) {
254- if (prLite.state !== "open" || !prLite.draft ) continue;
280+ if (prLite.state !== "open") continue;
255281 if (!prLite.labels.some((l) => l.name === LABEL)) continue;
256282 const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prLite.number });
257283 await restore(pr);
@@ -267,18 +293,30 @@ jobs:
267293 try {
268294 if (pr.user?.type === "Bot") continue;
269295 const labels = pr.labels.map((l) => l.name);
270- if (pr.draft) {
271- if (!labels.includes(LABEL) || labels.includes(EXEMPT_LABEL)) continue;
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)) {
272302 if (await claSigned(pr.head.sha)) {
273303 await restore(pr);
304+ } else if (pr.draft) {
305+ await escalate(pr);
274306 } else {
307+ // Readied without signing: put it back in draft, then escalate.
308+ await gate(pr);
275309 await escalate(pr);
276310 }
277311 continue;
278312 }
313+
314+ // Not yet gated: only gate fresh, external first-timer PRs.
315+ if (pr.draft) continue;
279316 if (!FIRST_TIMER.has(pr.author_association)) continue;
280317 if (Date.now() - new Date(pr.created_at).getTime() < GRACE_MS) continue;
281318 if (await claSigned(pr.head.sha)) continue;
319+ if (await isTeamMember(pr.user.login)) continue;
282320 await gate(pr);
283321 } catch (error) {
284322 core.warning(`PR #${pr.number}: ${error.message}`);
0 commit comments