Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions .github/workflows/cla_draft_gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
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. The
# scheduled sweep doubles as a backstop in case a status event is missed.
# - 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:
# GITHUB_TOKEN can normally convert PRs to draft and back. If those
# GraphQL mutations ever fail with "Resource not accessible by
# integration", add a classic PAT with `repo` scope as CLA_GATE_PAT.
github-token: ${{ secrets.CLA_GATE_PAT || github.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;

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: [] };

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" || !prLite.draft) 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 (pr.draft) {
if (!labels.includes(LABEL) || labels.includes(EXEMPT_LABEL)) continue;
if (await claSigned(pr.head.sha)) {
await restore(pr);
} else {
await escalate(pr);
}
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;
await gate(pr);
} catch (error) {
core.warning(`PR #${pr.number}: ${error.message}`);
}
}
Loading
Loading