diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 58b6779465..1f119f17ed 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,9 @@ ## Related issue - + ## How did you check it? diff --git a/.github/contribution-policy.json b/.github/contribution-policy.json new file mode 100644 index 0000000000..604ca3fef8 --- /dev/null +++ b/.github/contribution-policy.json @@ -0,0 +1,26 @@ +{ + "vouchedContributors": [ + { + "id": 44305048, + "login": "andrejsshell", + "reason": "Core team (usekaneo/developers)." + }, + { + "id": 127273550, + "login": "randoneering", + "reason": "Core team (usekaneo/developers)." + }, + { + "id": 176929823, + "login": "tinsever", + "reason": "Core team (usekaneo/developers)." + } + ], + "exemptBots": [ + { + "id": 49699333, + "login": "dependabot[bot]", + "reason": "Repository-managed dependency updates." + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f704007ed4..3a16b0f4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: - name: Test release security controls run: | docker pull nginx:1.29.5-alpine - node --test scripts/security/*.test.mjs scripts/ci/*.test.mjs + node --test scripts/security/*.test.mjs scripts/ci/*.test.mjs scripts/contribution-eligibility/*.test.mjs - name: Run unit tests env: diff --git a/.github/workflows/contribution-eligibility.yml b/.github/workflows/contribution-eligibility.yml new file mode 100644 index 0000000000..bffe15d277 --- /dev/null +++ b/.github/workflows/contribution-eligibility.yml @@ -0,0 +1,64 @@ +name: Contribution eligibility + +on: + # zizmor: ignore[dangerous-triggers] Only executes policy code from main; + # never checks out or executes pull-request content. + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review, closed] + issues: + types: [labeled, unlabeled, closed, reopened, deleted, transferred] + label: + types: [edited, deleted] + # zizmor: ignore[dangerous-triggers] Reads GitHub metadata and trusted main + # code only; does not consume artifacts or code from the triggering run. + workflow_run: + workflows: [CI] + types: [completed] + push: + branches: [main] + paths: + - .github/contribution-policy.json + - .github/workflows/contribution-eligibility.yml + - scripts/contribution-eligibility/** + # Manual sidebar links and permission changes have no matching Actions event. + schedule: + - cron: '17 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + issues: read + checks: write + +jobs: + reconcile: + # Dependabot-triggered runs may receive a read-only token. CI completion + # rechecks its PR using a token issued in the trusted workflow context. + if: >- + (github.event_name != 'pull_request_target' || github.actor != 'dependabot[bot]') && + (github.event_name != 'workflow_run' || github.event.workflow_run.actor.login == 'dependabot[bot]') + # Skipped CI-completion events must not replace queued policy rechecks. + concurrency: + group: contribution-eligibility + cancel-in-progress: false + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - name: Check out current trusted policy + uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1 + with: + # Always read the latest policy, including when an older run is queued. + ref: main + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + package-manager-cache: false + + - name: Check open pull requests + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/contribution-eligibility/check.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f4b9d80a5..c33a65f051 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Thanks for wanting to contribute to Kaneo! Whether you're fixing bugs, adding fe - [Getting Started](#getting-started) - [Making Your First Contribution](#making-your-first-contribution) - [Finding Something to Work On](#finding-something-to-work-on) + - [Contribution Eligibility](#contribution-eligibility) - [The Process](#the-process) - [Development Guidelines](#development-guidelines) - [Code Style](#code-style) @@ -36,9 +37,82 @@ Follow the [local development setup guide](ENVIRONMENT_SETUP.md) for prerequisit ### Finding Something to Work On -- **Browse [open issues](https://github.com/usekaneo/kaneo/issues)** - look for "good first issue" labels +- **Browse [issues ready for contribution](https://github.com/usekaneo/kaneo/issues?q=is%3Aissue%20is%3Aopen%20label%3Aready-for-contribution)** - the "good first issue" ones are a nice place to start - **Check our [Discord](https://discord.gg/rU4tSyhXXU)** - we often discuss features and bugs there -- **Found a bug?** Feel free to fix it and open a PR +- **Found a bug?** Open an issue first and wait for a maintainer to give the go-ahead before sending a PR (unless you're [exempt](#contribution-eligibility)) + +### Contribution Eligibility + +Please talk to us before you start writing code. Find or open an issue and +agree on the scope with a maintainer. Unless someone has vouched for you, your +PR needs to link an open issue in this repo with the **`ready-for-contribution`** +label. Opening an issue yourself isn't approval; a maintainer adds the label +once the work is agreed. + +Link the issue in your PR description with `Fixes #123` or `Closes #123`. +GitHub only picks those up for PRs against the default branch, so for other +branches a maintainer can link the issue from the PR's Development sidebar. +Just mentioning it (`See #123`) isn't enough. + +You can skip the issue if you're a repo admin, have the **maintain** role, have +been vouched for, or are an approved bot. A voucher is tied to one GitHub +account, can't be handed to someone else, and only skips the issue step. +Everyone still goes through code review, CI, and the [AI policy](AI_POLICY.md). +Past contributions or org membership don't earn a voucher automatically. + +The **Contribution eligibility** check on your PR tells you where you stand. If +it fails, link an approved issue or ask a maintainer to vouch for you. + +The check reruns when you edit the PR description or push commits, when an +issue gains or loses the label, and when the voucher list changes. It can't +react to sidebar links or permission changes, so an hourly scheduled run picks +those up (GitHub sometimes runs these late). Maintainers can also trigger the +workflow by hand. Dependabot PRs are checked after their CI run finishes, +because bot-triggered workflows may only get read-only tokens. + +Once the check is required, a failing result blocks merging. It won't stop +anyone from opening a PR, and it never closes one. + +#### Managing Vouchers + +Vouchers live in +[`.github/contribution-policy.json`](.github/contribution-policy.json). To vouch +for someone, add them to `vouchedContributors` in a PR to `main`. To revoke, +remove them. Review these changes the same way you'd review code. + +```json +{ + "id": 123456789, + "login": "example-contributor", + "reason": "Consistently submits focused changes and follows through on review." +} +``` + +Get the numeric ID with `gh api users/USERNAME --jq '{id,login}'`. Only the ID +matters for eligibility; `login` and `reason` are there so people can tell who +it is and why. That way a renamed account keeps its voucher, and whoever grabs +an old username doesn't inherit it. + +Bots go in `exemptBots`. For now that's just Dependabot. + +To turn on enforcement once this workflow is on `main`: + +1. Create the `ready-for-contribution` label and only put it on work you've agreed to. +2. Run the **Contribution eligibility** workflow once so it checks PRs that are already open. +3. In the branch rules for `main`, require the **Contribution eligibility** + status check with GitHub Actions as the source. Do the same for any other + protected branches people contribute to. Don't pick the workflow's + `reconcile` job: the script publishes its own check on each PR's head + commit, and that's the one to require. + +The automation only reads code and policy from `main`, relies on GitHub's own +issue links, and never runs anything from the PR. Once it lists the open PRs +and publishes pending checks, a broken policy or an eligibility lookup error +fails the affected checks. If GitHub cannot list PRs or accept check updates, +the workflow fails but earlier check results can remain unchanged, including +successful ones. Maintainers must inspect failed workflow runs and rerun them +after GitHub recovers before relying on those results. Revoking a voucher, +removing the label, or closing the issue all trigger a recheck of open PRs. ### The Process @@ -63,7 +137,8 @@ git commit -m "docs: update deployment guide" git push origin your-branch-name ``` -Then open a pull request on GitHub with a clear description of what you changed and why. +Then open a pull request on GitHub that explains what you changed and why. Link +the approved issue too, unless you're exempt. ## Development Guidelines @@ -207,7 +282,7 @@ Please read and follow our [AI Contribution Policy](AI_POLICY.md), which adopts ## Types of Contributions We Love -- **Bug fixes** - Found something broken? Fix it! +- **Bug fixes** - Found something broken? Open an issue so we can agree on the fix - **New features** - Have an idea? Let's discuss it first - **Documentation** - Help others understand how to use Kaneo - **Performance improvements** - Make things faster diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 91e57d6508..5b809be5f9 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -31,6 +31,15 @@ upgrade-rendering regression checks. Configure the new job checks as required in GitHub if they should block merging; editing workflows alone does not change branch protection. +**Contribution eligibility** is a separate workflow. It posts a status on the +head commit of every open PR. Maintainers, vouched accounts, and approved bots +pass without an issue; everyone else has to link an open +`ready-for-contribution` issue from this repo. The workflow always takes its +code and policy from `main`, never from the PR. Voucher management and the +branch-rule setup are covered in +[the contribution guide](../../CONTRIBUTING.md#contribution-eligibility). Run +its tests with `node --test scripts/contribution-eligibility/*.test.mjs`. + ## Local runtime checks Use disposable services only. The HTTP helpers refuse non-loopback origins. diff --git a/scripts/contribution-eligibility/check-runs.mjs b/scripts/contribution-eligibility/check-runs.mjs new file mode 100644 index 0000000000..9eb815a0de --- /dev/null +++ b/scripts/contribution-eligibility/check-runs.mjs @@ -0,0 +1,31 @@ +import { statusContext } from "./policy.mjs"; + +export function checkPublisher(github) { + const ids = new Map(); + return async (sha, result) => { + const body = { + name: statusContext, + status: result.state === "pending" ? "in_progress" : "completed", + ...(result.state === "pending" + ? {} + : { conclusion: result.state === "success" ? "success" : "failure" }), + details_url: `https://github.com/${github.repository}/blob/main/CONTRIBUTING.md#contribution-eligibility`, + output: { + title: result.description, + summary: `${result.description}\n\n[Contribution rules](https://github.com/${github.repository}/blob/main/CONTRIBUTING.md#contribution-eligibility).`, + }, + }; + const id = ids.get(sha); + const check = await github.request( + `repos/${github.repository}/check-runs${id ? `/${id}` : ""}`, + { + method: id ? "PATCH" : "POST", + body: id ? body : { ...body, head_sha: sha }, + }, + ); + if (!Number.isSafeInteger(check.id) || check.id <= 0) { + throw new Error("GitHub did not return a check run ID."); + } + ids.set(sha, check.id); + }; +} diff --git a/scripts/contribution-eligibility/check.mjs b/scripts/contribution-eligibility/check.mjs new file mode 100644 index 0000000000..8f8920116a --- /dev/null +++ b/scripts/contribution-eligibility/check.mjs @@ -0,0 +1,122 @@ +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { GitHub } from "./github.mjs"; +import { checkPublisher } from "./check-runs.mjs"; +import { assessIssues, authorExemption, validatePolicy } from "./policy.mjs"; + +async function checkPullRequest(github, pull, policy, policyError) { + if (policyError) throw policyError; + let exemption = authorExemption(pull.user, null, policy); + if (!exemption && pull.user.type !== "Bot") { + const permission = await github.request( + `repos/${github.repository}/collaborators/${encodeURIComponent(pull.user.login)}/permission`, + { allowNotFound: true }, + ); + exemption = authorExemption(pull.user, permission, policy); + } + let result; + if (exemption) { + result = { state: "success", description: exemption }; + } else { + result = assessIssues(await github.linkedIssues(pull.number)); + } + const current = await github.request( + `repos/${github.repository}/pulls/${pull.number}`, + ); + if (current.state !== "open") return null; + if ( + current.head.sha !== pull.head.sha || + current.body !== pull.body || + current.base.ref !== pull.base.ref + ) { + return { + state: "pending", + description: "Pull request changed; waiting for a fresh check.", + }; + } + return result; +} + +export async function reconcile(github, loadPolicy) { + let policy; + let policyError; + try { + policy = validatePolicy(await loadPolicy()); + } catch (error) { + policyError = error; + } + const pulls = await github.openPullRequests(); + const heads = new Map(); + const publish = checkPublisher(github); + for (const pull of pulls) { + if (!/^[a-f0-9]{40}$/.test(pull.head?.sha ?? "")) { + throw new Error("Missing pull request head commit."); + } + if (!heads.has(pull.head.sha)) { + // Clear earlier successes before reading mutable approval and trust data. + await publish(pull.head.sha, { + state: "pending", + description: "Checking contribution eligibility.", + }); + heads.set(pull.head.sha, []); + } + } + const results = []; + const errors = []; + // Every event refreshes all open PRs: GitHub concurrency may replace a queued + // run, so an event scoped to one PR could otherwise leave another check stale. + for (const pull of pulls) { + let result; + try { + result = await checkPullRequest(github, pull, policy, policyError); + } catch (error) { + errors.push(error); + result = { + state: "error", + description: + "Eligibility could not be checked. A maintainer must rerun the workflow.", + }; + } + if (result) { + const entry = { number: pull.number, ...result }; + results.push(entry); + heads.get(pull.head.sha).push(entry); + } + } + // Checks belong to commits, not PRs. A vouched author's PR must never make + // a different, ineligible PR sharing the same commit eligible to merge. + const priority = ["error", "failure", "pending", "success"]; + for (const [sha, entries] of heads) { + const worst = entries.toSorted( + (a, b) => priority.indexOf(a.state) - priority.indexOf(b.state), + )[0]; + if (worst) { + await publish(sha, { + state: worst.state, + description: `PR #${worst.number}: ${worst.description}`, + }); + } + } + if (policyError) errors.push(policyError); + if (errors.length) + throw new AggregateError(errors, "Contribution eligibility checks failed."); + return results; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const github = new GitHub(process.env); + const results = await reconcile(github, async () => + JSON.parse( + await readFile( + new URL("../../.github/contribution-policy.json", import.meta.url), + "utf8", + ), + ), + ); + for (const result of results) { + console.log(`PR #${result.number}: ${result.state}. ${result.description}`); + } +} diff --git a/scripts/contribution-eligibility/check.test.mjs b/scripts/contribution-eligibility/check.test.mjs new file mode 100644 index 0000000000..5ad2795e2d --- /dev/null +++ b/scripts/contribution-eligibility/check.test.mjs @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { reconcile } from "./check.mjs"; +import { GitHub } from "./github.mjs"; +import { statusContext } from "./policy.mjs"; + +const sha = "a".repeat(40); +const policy = { vouchedContributors: [], exemptBots: [] }; +const pull = { + number: 1, + state: "open", + user: { id: 123, login: "newcomer", type: "User" }, + head: { sha }, + base: { ref: "main" }, + body: "Fixes #12", +}; +const issue = { + number: 12, + state: "open", + labels: [{ name: "ready-for-contribution" }], +}; +const link = { + number: 12, + repository: { nameWithOwner: "test/repo" }, + state: "OPEN", + labels: { + nodes: issue.labels, + pageInfo: { hasNextPage: false, endCursor: null }, + }, +}; + +function fixture(options = {}) { + const requests = []; + const statuses = []; + const checks = new Map(); + const pulls = options.pulls ?? [pull]; + const fetcher = async (url, init) => { + const parsed = new URL(url); + const path = parsed.pathname; + const body = init.body ? JSON.parse(init.body) : undefined; + requests.push({ path, body }); + let data; + let status = 200; + if (path.includes("/check-runs")) { + if (options.publicationStatus) { + return Response.json({}, { status: options.publicationStatus }); + } + const id = + init.method === "POST" + ? checks.size + 1 + : Number(path.split("/").at(-1)); + const head = body.head_sha ?? checks.get(id).sha; + const state = body.status === "in_progress" ? "pending" : body.conclusion; + const entry = { + id, + sha: head, + context: body.name, + state, + description: body.output.title, + target_url: body.details_url, + }; + statuses.push(entry); + checks.set(id, entry); + data = { id }; + } else if (path === "/repos/test/repo/pulls") { + status = options.listStatus ?? 200; + data = options.pullPages + ? options.pullPages[Number(parsed.searchParams.get("page")) - 1] + : pulls; + } else if (path.startsWith("/repos/test/repo/pulls/")) { + const current = pulls.find( + (item) => item.number === Number(path.split("/").at(-1)), + ); + data = { ...current, ...options.current }; + } else if (path.includes("/collaborators/")) { + status = options.permissionStatus ?? (options.permission ? 200 : 404); + data = options.permission ?? {}; + } else if (path === "/graphql") { + if (body.query.includes("issue(number:")) { + return Response.json({ + data: { repository: { issue: { labels: options.labelPage } } }, + }); + } + const pageIndex = body.variables.after ? Number(body.variables.after) : 0; + const page = options.linkPages?.[pageIndex] ?? { + nodes: options.links ?? [ + { + ...link, + state: (options.issue ?? issue).state.toUpperCase(), + labels: { + nodes: (options.issue ?? issue).labels, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + data = options.graphqlErrors + ? { + errors: [{ message: "synthetic failure" }], + data: { repository: null }, + } + : { + data: { + repository: { pullRequest: { closingIssuesReferences: page } }, + }, + }; + } else { + throw new Error(`Unexpected test request: ${path}`); + } + return Response.json(data, { status }); + }; + const github = new GitHub( + { + GITHUB_REPOSITORY: "test/repo", + GH_TOKEN: "synthetic-token", + GITHUB_API_URL: "https://api.github.test", + GITHUB_GRAPHQL_URL: "https://api.github.test/graphql", + }, + fetcher, + ); + return { github, requests, statuses }; +} + +test("approved issue publishes pending then success on the fork's exact head", async () => { + const { github, statuses } = fixture(); + const results = await reconcile(github, async () => policy); + assert.equal(results[0].state, "success"); + assert.deepEqual( + statuses.map((status) => [status.sha, status.context, status.state]), + [ + [sha, statusContext, "pending"], + [sha, statusContext, "success"], + ], + ); + assert.match( + statuses.at(-1).target_url, + /CONTRIBUTING\.md#contribution-eligibility$/, + ); +}); + +test("mere mentions, foreign issues, deleted issues and revoked approval fail", async () => { + for (const options of [ + { links: [] }, + { links: [{ ...link, repository: { nameWithOwner: "other/repo" } }] }, + { links: [null] }, + { issue: { ...issue, labels: [] } }, + { issue: { ...issue, state: "closed" } }, + ]) { + const { github, statuses } = fixture(options); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "failure"); + assert.match(statuses.at(-1).description, /ready-for-contribution/); + } +}); + +test("granting and revoking a voucher changes the result without a new commit", async () => { + const { github, statuses, requests } = fixture({ links: [] }); + const vouched = { + ...policy, + vouchedContributors: [{ id: 123, login: "old-name", reason: "Trusted." }], + }; + await reconcile(github, async () => vouched); + assert.equal(statuses.at(-1).state, "success"); + assert.equal( + requests.some((request) => request.path.includes("/collaborators/")), + false, + ); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "failure"); +}); + +test("repository maintainers and explicit automation can omit issues", async () => { + const maintainer = fixture({ + permission: { permission: "write", role_name: "maintain" }, + links: [], + }); + await reconcile(maintainer.github, async () => policy); + assert.equal(maintainer.statuses.at(-1).state, "success"); + assert.equal( + maintainer.requests.some((request) => request.path === "/graphql"), + false, + ); + const bot = fixture({ + pulls: [{ ...pull, user: { ...pull.user, type: "Bot" } }], + links: [], + }); + await reconcile(bot.github, async () => ({ + ...policy, + exemptBots: [{ id: 123, login: "automation[bot]", reason: "Approved." }], + })); + assert.equal(bot.statuses.at(-1).state, "success"); + assert.equal( + bot.requests.some((request) => request.path.includes("/collaborators/")), + false, + ); + await reconcile(bot.github, async () => policy); + assert.equal(bot.statuses.at(-1).state, "failure"); +}); + +test("eligibility lookup and policy errors publish failing checks", async () => { + for (const options of [ + { permissionStatus: 403 }, + { permissionStatus: 500 }, + { graphqlErrors: true }, + ]) { + const { github, statuses } = fixture(options); + await assert.rejects( + reconcile(github, async () => policy), + AggregateError, + ); + assert.deepEqual( + statuses.map((status) => status.state), + ["pending", "failure"], + ); + } + for (const loader of [ + async () => ({}), + async () => { + throw new SyntaxError("Invalid JSON"); + }, + ]) { + const { github, statuses } = fixture(); + await assert.rejects(reconcile(github, loader), AggregateError); + assert.equal(statuses.at(-1).state, "failure"); + } +}); + +test("discovery and publication outages preserve prior success until a rerun", async () => { + for (const failure of ["listStatus", "publicationStatus"]) { + const options = {}; + const { github, statuses } = fixture(options); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "success"); + const published = statuses.length; + + options.issue = { ...issue, labels: [] }; + options[failure] = 503; + await assert.rejects( + reconcile(github, async () => policy), + /503/, + ); + assert.equal(statuses.length, published); + assert.equal(statuses.at(-1).state, "success"); + + delete options[failure]; + await reconcile(github, async () => policy); + assert.deepEqual( + statuses.slice(published).map((status) => status.state), + ["pending", "failure"], + ); + } +}); + +test("a changed head, body or target cannot receive a stale success", async () => { + for (const current of [ + { head: { sha: "b".repeat(40) } }, + { body: "Removed the issue link." }, + { base: { ref: "other" } }, + ]) { + const { github, statuses } = fixture({ current }); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "pending"); + assert.equal( + statuses.some((status) => status.state === "success"), + false, + ); + assert.equal( + statuses.every((status) => status.sha === sha), + true, + ); + } + const closed = fixture({ current: { state: "closed" } }); + assert.deepEqual(await reconcile(closed.github, async () => policy), []); + assert.equal( + closed.statuses.some((status) => status.state === "success"), + false, + ); +}); + +test("a vouched PR never overrides an ineligible PR on the same commit", async () => { + for (const reverse of [false, true]) { + const newcomer = { ...pull, number: 2, user: { ...pull.user, id: 456 } }; + const { github, statuses } = fixture({ + pulls: reverse ? [newcomer, pull] : [pull, newcomer], + links: [], + }); + await reconcile(github, async () => ({ + ...policy, + vouchedContributors: [{ id: 123, login: "trusted", reason: "Trusted." }], + })); + assert.equal(statuses.at(-1).state, "failure"); + assert.equal( + statuses.some((status) => status.state === "success"), + false, + ); + assert.match(statuses.at(-1).description, /PR #2/); + } +}); + +test("closing an ineligible PR clears its failure from a shared commit on recheck", async () => { + const pulls = [pull, { ...pull, number: 2, user: { ...pull.user, id: 456 } }]; + const { github, statuses } = fixture({ pulls, links: [] }); + const vouched = { + ...policy, + vouchedContributors: [{ id: 123, login: "trusted", reason: "Trusted." }], + }; + await reconcile(github, async () => vouched); + assert.equal(statuses.at(-1).state, "failure"); + assert.match(statuses.at(-1).description, /PR #2/); + + pulls.pop(); + await reconcile(github, async () => vouched); + assert.equal(statuses.at(-1).sha, sha); + assert.equal(statuses.at(-1).state, "success"); + assert.match(statuses.at(-1).description, /PR #1/); +}); + +test("a failure on one PR still updates other PRs before failing the workflow", async () => { + const { github, statuses } = fixture({ + pulls: [pull, { ...pull, number: 2, head: { sha: "b".repeat(40) } }], + permissionStatus: 500, + }); + await assert.rejects( + reconcile(github, async () => policy), + AggregateError, + ); + assert.equal( + statuses.filter((status) => status.state === "failure").length, + 2, + ); +}); + +test("linked issues paginate and accept case-insensitive repository names", async () => { + const { github, requests } = fixture({ + linkPages: [ + { + nodes: [{ ...link, repository: { nameWithOwner: "foreign/repo" } }], + pageInfo: { hasNextPage: true, endCursor: "1" }, + }, + { + nodes: [{ ...link, repository: { nameWithOwner: "TEST/REPO" } }, link], + pageInfo: { hasNextPage: false, endCursor: "last" }, + }, + ], + }); + assert.deepEqual(await github.linkedIssues(1), [issue]); + assert.deepEqual( + requests.map((request) => request.body.variables.after), + [null, "1"], + ); +}); + +test("a thousand closing references use ten batched issue requests", async () => { + const linkPages = Array.from({ length: 10 }, (_, page) => ({ + nodes: Array.from({ length: 100 }, (_, index) => ({ + ...link, + number: page * 100 + index + 1, + labels: { + ...link.labels, + nodes: page === 9 && index === 99 ? issue.labels : [], + }, + })), + pageInfo: { hasNextPage: page < 9, endCursor: String(page + 1) }, + })); + const { github, requests, statuses } = fixture({ linkPages }); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "success"); + assert.match(statuses.at(-1).description, /issue #1000/); + assert.equal(requests.filter(({ path }) => path === "/graphql").length, 10); + assert.equal( + requests.some(({ path }) => path.includes("/issues/")), + false, + ); +}); + +test("approval labels beyond the first page are included", async () => { + const { github, requests, statuses } = fixture({ + links: [ + { + ...link, + labels: { + nodes: Array.from({ length: 100 }, (_, index) => ({ + name: `label-${index}`, + })), + pageInfo: { hasNextPage: true, endCursor: "labels-next" }, + }, + }, + ], + labelPage: { + nodes: issue.labels, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await reconcile(github, async () => policy); + assert.equal(statuses.at(-1).state, "success"); + const labelRequest = requests.find(({ body }) => + body?.query?.includes("issue(number:"), + ); + assert.equal(labelRequest.body.variables.number, 12); + assert.equal(labelRequest.body.variables.after, "labels-next"); +}); + +test("missing or invalid label pages fail the affected eligibility check", async () => { + for (const cursor of [null, "labels-next"]) { + const { github, statuses } = fixture({ + links: [ + { + ...link, + labels: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: cursor }, + }, + }, + ], + }); + await assert.rejects( + reconcile(github, async () => policy), + AggregateError, + ); + assert.equal(statuses.at(-1).state, "failure"); + } +}); + +test("open PRs paginate beyond the first hundred", async () => { + const first = Array.from({ length: 100 }, (_, index) => ({ + number: index + 1, + })); + const { github } = fixture({ pullPages: [first, [{ number: 101 }]] }); + assert.equal((await github.openPullRequests()).length, 101); +}); + +test("invalid pagination and missing configuration reject", async () => { + const { github } = fixture({ + linkPages: [ + { nodes: [], pageInfo: { hasNextPage: true, endCursor: null } }, + ], + }); + await assert.rejects(github.linkedIssues(1), /pagination cursor/); + assert.throws( + () => + new GitHub({ GITHUB_REPOSITORY: "../invalid", GH_TOKEN: "synthetic" }), + ); + assert.throws(() => new GitHub({ GITHUB_REPOSITORY: "test/repo" })); +}); diff --git a/scripts/contribution-eligibility/github.mjs b/scripts/contribution-eligibility/github.mjs new file mode 100644 index 0000000000..7c4b19b818 --- /dev/null +++ b/scripts/contribution-eligibility/github.mjs @@ -0,0 +1,143 @@ +export class GitHub { + constructor(env, fetcher = fetch) { + if ( + !/^[\w-]+\/[\w.-]+$/.test(env.GITHUB_REPOSITORY ?? "") || + [".", ".."].includes(env.GITHUB_REPOSITORY.split("/")[1]) + ) { + throw new Error("Invalid GitHub repository."); + } + if (!env.GH_TOKEN) throw new Error("Missing GitHub token."); + this.repository = env.GITHUB_REPOSITORY; + this.apiURL = env.GITHUB_API_URL || "https://api.github.com"; + this.graphqlURL = + env.GITHUB_GRAPHQL_URL || "https://api.github.com/graphql"; + this.token = env.GH_TOKEN; + this.fetcher = fetcher; + } + + async request(path, { method = "GET", body, allowNotFound = false } = {}) { + const url = path === "graphql" ? this.graphqlURL : `${this.apiURL}/${path}`; + const response = await this.fetcher(url, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (allowNotFound && response.status === 404) return null; + if (!response.ok) { + throw new Error(`GitHub request failed (${response.status}).`); + } + const result = await response.json(); + if (result.errors?.length) + throw new Error("GitHub GraphQL request failed."); + return result; + } + + async openPullRequests() { + const pulls = []; + for (let page = 1; ; page++) { + const batch = await this.request( + `repos/${this.repository}/pulls?state=open&per_page=100&page=${page}`, + ); + pulls.push(...batch); + if (batch.length < 100) return pulls; + } + } + + async linkedIssues(number) { + const [owner, name] = this.repository.split("/"); + const issues = new Map(); + let after = null; + do { + const result = await this.request("graphql", { + method: "POST", + body: { + query: `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100, after: $after) { + nodes { + number + state + repository { nameWithOwner } + labels(first: 100) { + nodes { name } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + variables: { owner, name, number, after }, + }, + }); + const connection = + result.data?.repository?.pullRequest?.closingIssuesReferences; + if (!connection) throw new Error("GitHub did not return linked issues."); + for (const issue of connection.nodes) { + if ( + issue && + issue.repository.nameWithOwner.toLowerCase() === + this.repository.toLowerCase() + ) { + issues.set(issue.number, { + number: issue.number, + state: issue.state.toLowerCase(), + labels: await this.issueLabels(issue.number, issue.labels), + }); + } + } + if (!connection.pageInfo.hasNextPage) return [...issues.values()]; + if ( + !connection.pageInfo.endCursor || + connection.pageInfo.endCursor === after + ) { + throw new Error("GitHub returned an invalid issue pagination cursor."); + } + after = connection.pageInfo.endCursor; + } while (after); + } + + async issueLabels(number, firstPage) { + const [owner, name] = this.repository.split("/"); + let connection = firstPage; + const labels = [...connection.nodes]; + let after = null; + while (connection.pageInfo.hasNextPage) { + if ( + !connection.pageInfo.endCursor || + connection.pageInfo.endCursor === after + ) { + throw new Error("GitHub returned an invalid label pagination cursor."); + } + after = connection.pageInfo.endCursor; + const result = await this.request("graphql", { + method: "POST", + body: { + query: `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + labels(first: 100, after: $after) { + nodes { name } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + variables: { owner, name, number, after }, + }, + }); + connection = result.data?.repository?.issue?.labels; + if (!connection) throw new Error("GitHub did not return issue labels."); + labels.push(...connection.nodes); + } + return labels; + } +} diff --git a/scripts/contribution-eligibility/policy.mjs b/scripts/contribution-eligibility/policy.mjs new file mode 100644 index 0000000000..c3f578b706 --- /dev/null +++ b/scripts/contribution-eligibility/policy.mjs @@ -0,0 +1,69 @@ +export const approvalLabel = "ready-for-contribution"; +export const statusContext = "Contribution eligibility"; + +export function validatePolicy(policy) { + if (!policy || typeof policy !== "object") { + throw new Error("Missing contribution policy."); + } + const ids = new Set(); + for (const key of ["vouchedContributors", "exemptBots"]) { + if (!Array.isArray(policy[key])) { + throw new Error(`Contribution policy must define ${key}.`); + } + for (const account of policy[key]) { + if ( + !Number.isSafeInteger(account.id) || + account.id <= 0 || + typeof account.login !== "string" || + !account.login.trim() || + typeof account.reason !== "string" || + !account.reason.trim() || + ids.has(account.id) + ) { + throw new Error(`Invalid or duplicate account in ${key}.`); + } + ids.add(account.id); + } + } + return policy; +} + +export function authorExemption(author, permission, policy) { + if (!Number.isSafeInteger(author?.id) || author.id <= 0) { + throw new Error("Missing pull request author identity."); + } + if (author.type === "Bot") { + return policy.exemptBots.some((account) => account.id === author.id) + ? "Approved automation." + : null; + } + if (policy.vouchedContributors.some((account) => account.id === author.id)) { + return "Vouched contributor; an issue is optional."; + } + if ( + permission?.permission === "admin" || + permission?.permission === "maintain" || + permission?.role_name === "maintain" + ) { + return "Repository maintainer; an issue is optional."; + } + return null; +} + +export function assessIssues(issues) { + const approved = issues.find( + (issue) => + !issue.pull_request && + issue.state === "open" && + issue.labels.some((label) => label.name === approvalLabel), + ); + return approved + ? { + state: "success", + description: `Linked to approved issue #${approved.number}.`, + } + : { + state: "failure", + description: `Link an open issue labeled ${approvalLabel}, or ask a maintainer to vouch for you.`, + }; +} diff --git a/scripts/contribution-eligibility/policy.test.mjs b/scripts/contribution-eligibility/policy.test.mjs new file mode 100644 index 0000000000..430551d8ec --- /dev/null +++ b/scripts/contribution-eligibility/policy.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { assessIssues, authorExemption, validatePolicy } from "./policy.mjs"; + +const account = { + id: 123, + login: "trusted", + reason: "Reviewed contributions.", +}; +const policy = { + vouchedContributors: [account], + exemptBots: [{ ...account, id: 456 }], +}; +const author = { id: 123, login: "trusted", type: "User" }; + +test("checked-in policy is valid and explicitly exempts only Dependabot initially", async () => { + const current = validatePolicy( + JSON.parse( + await readFile( + new URL("../../.github/contribution-policy.json", import.meta.url), + "utf8", + ), + ), + ); + assert.deepEqual( + current.exemptBots.map((entry) => entry.id), + [49699333], + ); +}); + +test("vouchers follow account IDs through renames, never reused usernames", () => { + assert.ok(authorExemption({ ...author, login: "renamed" }, null, policy)); + assert.equal(authorExemption({ ...author, id: 999 }, null, policy), null); + assert.equal( + authorExemption(author, null, { ...policy, vouchedContributors: [] }), + null, + ); +}); + +test("only explicit bots are exempt; human and bot vouchers cannot be exchanged", () => { + assert.ok(authorExemption({ ...author, id: 456, type: "Bot" }, null, policy)); + assert.equal( + authorExemption( + { ...author, type: "Bot" }, + { permission: "admin" }, + policy, + ), + null, + ); + assert.equal(authorExemption({ ...author, id: 456 }, null, policy), null); +}); + +test("maintainer permissions are checked rather than contributor association", () => { + const newcomer = { ...author, id: 999, author_association: "MEMBER" }; + for (const permission of [ + { permission: "admin", role_name: "admin" }, + { permission: "write", role_name: "maintain" }, + { permission: "maintain" }, + ]) + assert.ok(authorExemption(newcomer, permission, policy)); + for (const permission of [ + null, + { permission: "write", role_name: "write" }, + { permission: "triage" }, + { permission: "read" }, + ]) { + assert.equal(authorExemption(newcomer, permission, policy), null); + } +}); + +test("policy rejects missing lists, ambiguous identities and undocumented grants", () => { + for (const invalid of [ + null, + {}, + { exemptBots: [] }, + { ...policy, vouchedContributors: null }, + ]) { + assert.throws(() => validatePolicy(invalid)); + } + for (const invalid of [ + { id: "123" }, + { id: 0 }, + { id: -1 }, + { id: 1.5 }, + { id: Number.MAX_SAFE_INTEGER + 1 }, + { login: "" }, + { reason: " " }, + ]) { + assert.throws(() => + validatePolicy({ + ...policy, + vouchedContributors: [{ ...account, ...invalid }], + }), + ); + } + assert.throws(() => validatePolicy({ ...policy, exemptBots: [account] })); + assert.throws(() => authorExemption({}, null, policy)); +}); + +test("an existing open approved issue is required; one valid link suffices", () => { + const approved = { + number: 12, + state: "open", + labels: [{ name: "ready-for-contribution" }], + }; + assert.equal(assessIssues([approved]).state, "success"); + for (const issues of [ + [], + [{ ...approved, state: "closed" }], + [{ ...approved, labels: [] }], + [{ ...approved, labels: [{ name: "good first issue" }] }], + [{ ...approved, pull_request: { url: "https://example.invalid" } }], + ]) + assert.equal(assessIssues(issues).state, "failure"); + assert.equal( + assessIssues([{ ...approved, state: "closed" }, approved]).state, + "success", + ); +});