mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its GitHub Actions CI shares the `ubuntu-latest` runner pool across all workflows > - #19480 added the `PR merge review notification` workflow. It triggers on `workflow_run`, on every completed `check_run`, and on every commit `status` > - One PR push completes about 60 checks, so it queues about 60 notifier jobs. Each job takes a runner > - The Actions queue now holds about 340 queued runs. About 260 of them are this notifier. Other `ubuntu-latest` jobs wait more than one hour (for example, the `authorize` job of Runner Full-Stack E2E image builds) > - This pull request skips events that cannot complete a green PR state before a runner is allocated, and it collapses the remaining events to the newest one per PR head > - The benefit is that this unjams the Actions queue and keeps the advisory review notification ## Linked Issues or Issue Description Refs #19480 **What happened?** The `PR merge review notification` workflow from #19480 schedules one runner job for each `workflow_run`, `check_run`, and `status` event. One PR push makes about 60+ jobs. The repository Actions queue has about 340 queued runs (about 260 from this notifier at the time of writing). Other `ubuntu-latest` jobs wait more than one hour. **What did you expect?** The notifier requests one advisory merge review when PR verification is complete. It must not use a runner for each intermediate check. **Steps to reproduce** 1. Push to any PR. 2. Run `gh run list --workflow pr-merge-review.yml -L 200 --json event,status`. 3. See tens of queued `check_run`, `status`, and `workflow_run` runs for the same push. ## What Changed - `.github/workflows/pr-merge-review.yml`: the job `if:` now schedules the job only for events that can complete a green PR state: - `workflow_run` from `pull_request` or `pull_request_target` with conclusion `success`, `skipped`, or `neutral`. - `check_run` from a non-Actions app (app id is not `15368`) with conclusion `success`, `skipped`, or `neutral`. GitHub Actions check runs are already covered by the `workflow_run` list. - `status` with state `success`. - GitHub cannot filter `check_run` or `status` at the trigger level. A skipped job makes a run record but does not take a runner. - The job has a concurrency group `pr-merge-review-<PR head SHA>` with `cancel-in-progress: true`. The notifier reads the current PR state, so the newest event subsumes older ones. The group is on the job, not the workflow, so a skipped event cannot cancel a scheduled notifier. - The environment, permissions, trusted default-branch checkout, and secret handling do not change. - `.github/scripts/tests/notify-pr-merge-review.test.mjs`: a new test pins the trigger kinds, the job condition, and the job-level concurrency group. - `doc/PR-MERGE-REVIEW-WEBHOOK.md`: describes the event filter and the concurrency group. **Expected runner jobs per PR push** (typical PR: `PR`, `Storybook Visual`, `commitperclip PR Review` workflows; Greptile, Paperclip Review, Socket (2), Superagent (2) external checks; one Snyk status): | | Before | After | | --- | --- | --- | | Eligible events that take a runner | about 60–70 (every check run + 2 statuses + 3 workflow runs) | at most about 10 (3 workflow runs + 6 external checks + 1 status, only when green) | | Jobs that run to completion | about 60–70 | usually 1–4: events that arrive while a job is pending or running cancel it, so a burst collapses to the newest event. In a backed-up queue, all events for one head collapse to one job | ## Verification - `node --test .github/scripts/tests/notify-pr-merge-review.test.mjs`: 22 tests pass. - The new workflow test fails on the old workflow (red) and passes on the new workflow (green). - `actionlint .github/workflows/pr-merge-review.yml`: no findings. - I confirmed with `gh run list` that `workflow_run.head_sha` for the `pull_request_target` workflow (`commitperclip PR Review`) is the PR head SHA. So one SHA key groups all event kinds for one PR head. - I confirmed that all workflows that make PR checks are in the `workflow_run` list (`CI routing` and `Trusted PR CI` are reusable workflows called by `PR`). - After merge: check that `gh run list --workflow pr-merge-review.yml` shows mostly `skipped` runs for each push, and a small number of runs that execute. ## Risks - A check provider that reports a final green state through a GitHub Actions check run from a workflow that is not in the `workflow_run` list does not trigger the notifier. All current PR workflows are in the list. The doc already tells authors to add new PR workflows to the list. - A failed or cancelled check that later succeeds on re-run still triggers the notifier, because the re-run completes with `success`. - `cancel-in-progress` can cancel a notifier during delivery. The newer job evaluates the same or a more complete state, and the Paperclip idempotency key deduplicates deliveries for the same PR, head, and base. - Each event still makes a workflow run record. Skipped runs do not take a runner. - Low risk otherwise: the notifier is advisory and `continue-on-error`. It does not affect PR verification results. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, GitHub CLI). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
316 lines
19 KiB
JavaScript
316 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { green, deliver, notify, snapshot, trustedVerification } from "../notify-pr-merge-review.mjs";
|
|
|
|
const head = "a".repeat(40);
|
|
const base = "b".repeat(40);
|
|
const check = (name, conclusion = "SUCCESS", extra = {}) => ({
|
|
__typename: "CheckRun", name, status: "COMPLETED", conclusion, workflowName: "PR",
|
|
checkSuite: { databaseId: 8, app: { databaseId: 15368 }, workflowRun: { databaseId: 7 } },
|
|
...extra,
|
|
});
|
|
const candidate = (extra = {}) => ({
|
|
number: 42, state: "OPEN", isDraft: false, headRefOid: head, baseRefOid: base,
|
|
baseRefName: "master", changedFiles: 1, statusCheckRollup: [check("ci / verify"), check("ci / e2e")], ...extra,
|
|
});
|
|
const event = (extra = {}) => ({ repository: { full_name: "paperclipai/paperclip" }, sha: head, ...extra });
|
|
const trustedRun = (extra = {}) => ({
|
|
id: 7, repository: { full_name: "paperclipai/paperclip" }, path: ".github/workflows/pr.yml",
|
|
event: "pull_request", head_sha: head, status: "completed", conclusion: "success", check_suite_id: 8,
|
|
referenced_workflows: [{ path: "paperclipai/paperclip/.github/workflows/pr-trusted.yml@master",
|
|
ref: "refs/heads/master", sha: base }], ...extra,
|
|
});
|
|
const files = [{ filename: "README.md" }];
|
|
const read = (endpoint) => endpoint.includes("/rules/") ? [] : trustedRun();
|
|
const list = (endpoint) => endpoint.includes("/files?") ? files : [{ number: 42 }];
|
|
const deps = (extra = {}) => ({
|
|
read, list, view: () => candidate(),
|
|
send: async () => "delivered", ...extra,
|
|
});
|
|
|
|
test("reads every check page and fails closed on incomplete or changing listings", () => {
|
|
const page = (nodes, hasNextPage, extra = {}) => ({ data: { repository: { pullRequest: candidate({
|
|
statusCheckRollup: { contexts: { totalCount: 2, nodes,
|
|
pageInfo: { hasNextPage, endCursor: "next" } } }, ...extra,
|
|
}) } } });
|
|
let calls = 0;
|
|
const pr = snapshot(42, (args) => {
|
|
if (++calls === 1) return page([check("ci / verify")], true);
|
|
assert.ok(args.includes("endCursor=next"));
|
|
return page([check("ci / e2e")], false);
|
|
});
|
|
assert.equal(green(pr), true);
|
|
const own = check("notify", "", { status: "IN_PROGRESS", workflowName: undefined,
|
|
checkSuite: { workflowRun: { workflow: { name: "PR merge review notification" } } } });
|
|
const withOwn = snapshot(42, () => ({ data: { repository: { pullRequest: candidate({
|
|
statusCheckRollup: { contexts: { totalCount: 3, nodes: [...candidate().statusCheckRollup, own],
|
|
pageInfo: { hasNextPage: false } } },
|
|
}) } } }));
|
|
assert.equal(green(withOwn), false);
|
|
assert.throws(() => snapshot(42, () => page([check("ci / verify")], false)), /Incomplete/);
|
|
calls = 0;
|
|
assert.throws(() => snapshot(42, () => ++calls === 1 ? page([check("ci / verify")], true)
|
|
: page([check("ci / e2e")], false, { headRefOid: "d".repeat(40) })), /Candidate changed/);
|
|
assert.throws(() => snapshot(42, () => ({ errors: [{ message: "unavailable" }] })), /Incomplete/);
|
|
});
|
|
|
|
test("requires real success from both aggregate checks and additional repository rules", () => {
|
|
assert.equal(green(candidate()), true);
|
|
for (const state of ["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "NEUTRAL", "SKIPPED", ""]) {
|
|
assert.equal(green(candidate({ statusCheckRollup: [check("ci / verify", state), check("ci / e2e")] })), false, state);
|
|
}
|
|
assert.equal(green(candidate({ statusCheckRollup: [check("ci / verify")] })), false);
|
|
assert.equal(green(candidate(), ["extra required"]), false);
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, check("extra required")] }), ["extra required"]), true);
|
|
assert.equal(green(candidate({ statusCheckRollup: [] })), false);
|
|
});
|
|
|
|
test("waits for pending checks and statuses, permits skipped optional routing lanes", () => {
|
|
for (const extra of [check("tests", "", { status: "IN_PROGRESS" }), check("tests", "FAILURE"),
|
|
{ __typename: "StatusContext", context: "security", state: "PENDING" },
|
|
{ __typename: "StatusContext", context: "security", state: "ERROR" }]) {
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, extra] })), false);
|
|
}
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
|
|
check("not selected", "SKIPPED"), { __typename: "StatusContext", context: "security", state: "SUCCESS" }] })), true);
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
|
|
check("optional scanner coverage", "NEUTRAL")] })), true);
|
|
});
|
|
|
|
test("workflow names cannot hide pending or failing checks", () => {
|
|
const extras = ["PR merge review notification", "CI profile"].map((workflowName) =>
|
|
check("notify", "", { status: "IN_PROGRESS", workflowName }));
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, ...extras] })), false);
|
|
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
|
|
check("another", "", { status: "QUEUED", workflowName: "Other verification" })] })), false);
|
|
});
|
|
|
|
test("rejects closed, draft, wrong-base, and malformed candidates", () => {
|
|
for (const extra of [{ state: "MERGED" }, { isDraft: true }, { baseRefName: "release" },
|
|
{ headRefOid: "invalid" }, { baseRefOid: "invalid" }]) assert.equal(green(candidate(extra)), false);
|
|
});
|
|
|
|
test("resolves fork PRs from the head commit when workflow payload has no PRs", async () => {
|
|
const seen = [];
|
|
const outcomes = await notify(event({ workflow_run: {
|
|
status: "completed", event: "pull_request", head_sha: head, pull_requests: [],
|
|
} }), deps({ list: (endpoint) => { seen.push(endpoint); return list(endpoint); } }));
|
|
assert.deepEqual(outcomes, ["delivered"]);
|
|
assert.match(seen[0], new RegExp(`/commits/${head}/pulls\\?per_page=100$`));
|
|
});
|
|
|
|
test("reads additional required rules and holds until their check succeeds", async () => {
|
|
const outcomes = await notify(event(), deps({ read: () => [{ type: "required_status_checks",
|
|
parameters: { required_status_checks: [{ context: "new gate" }] } }] }));
|
|
assert.deepEqual(outcomes, []);
|
|
});
|
|
|
|
test("forged successful check names from a PR-controlled workflow cannot notify", async () => {
|
|
const input = deps({ read: (endpoint) => endpoint.includes("/rules/") ? [] : {
|
|
id: 7, path: ".github/workflows/attacker.yml", event: "pull_request",
|
|
head_sha: head, status: "completed", conclusion: "success",
|
|
} });
|
|
assert.deepEqual(await notify(event(), input), []);
|
|
});
|
|
|
|
test("requires genuine suite, caller, revision, and master reusable workflow provenance", () => {
|
|
assert.equal(trustedVerification(candidate(), deps()), true);
|
|
for (const changes of [
|
|
{ path: ".github/workflows/attacker.yml" }, { event: "pull_request_target" },
|
|
{ head_sha: "c".repeat(40) }, { status: "in_progress" }, { conclusion: "failure" },
|
|
{ repository: { full_name: "attacker/repo" } }, { check_suite_id: 99 },
|
|
{ referenced_workflows: [] },
|
|
{ referenced_workflows: [{ path: "paperclipai/paperclip/.github/workflows/pr-trusted.yml@master",
|
|
ref: "refs/pull/42/merge", sha: base }] },
|
|
]) {
|
|
assert.equal(trustedVerification(candidate(), deps({ read: () => trustedRun(changes) })), false);
|
|
}
|
|
const wrongSuite = check("ci / verify", "SUCCESS", {
|
|
checkSuite: { databaseId: 99, app: { databaseId: 1 }, workflowRun: { databaseId: 7 } },
|
|
});
|
|
assert.equal(trustedVerification(candidate({
|
|
statusCheckRollup: [wrongSuite, check("ci / e2e")],
|
|
}), deps()), false);
|
|
});
|
|
|
|
test("modified or renamed caller and truncated file listings cannot notify", async () => {
|
|
for (const fileList of [[], [{ filename: ".github/workflows/pr.yml" }],
|
|
[{ filename: "moved.yml", previous_filename: ".github/workflows/pr.yml" }]]) {
|
|
const dependencies = deps({ list: (endpoint) => endpoint.includes("/files?")
|
|
? fileList : [{ number: 42 }] });
|
|
assert.deepEqual(await notify(event(), dependencies), []);
|
|
}
|
|
assert.equal(trustedVerification(candidate({ changedFiles: undefined }), deps()), false);
|
|
});
|
|
|
|
test("rejects stale head events and a head, base, or check change during refresh", async () => {
|
|
assert.deepEqual(await notify(event({ sha: "c".repeat(40) }), deps()), []);
|
|
for (const extra of [{ headRefOid: "c".repeat(40) }, { baseRefOid: "d".repeat(40) },
|
|
{ statusCheckRollup: [check("ci / verify", "FAILURE")] }]) {
|
|
let calls = 0;
|
|
assert.deepEqual(await notify(event(), deps({ view: () => candidate(++calls === 1 ? {} : extra) })), []);
|
|
}
|
|
});
|
|
|
|
test("handles target-context workflow completions by refreshing the associated PR", async () => {
|
|
assert.deepEqual(await notify(event({ workflow_run: {
|
|
status: "completed", event: "pull_request_target", head_sha: base, pull_requests: [{ number: 42 }],
|
|
} }), deps()), ["delivered"]);
|
|
});
|
|
|
|
test("external check events and legacy statuses can deliver the final green transition", async () => {
|
|
assert.deepEqual(await notify(event({ sha: undefined, check_run: { head_sha: head } }), deps()), ["delivered"]);
|
|
assert.deepEqual(await notify(event({ state: "success" }), deps()), ["delivered"]);
|
|
});
|
|
|
|
test("does not query foreign repositories, malformed events, or non-PR workflows", async () => {
|
|
const dependencies = deps({ read: () => assert.fail("unexpected query"), list: () => assert.fail("unexpected query") });
|
|
for (const input of [event({ repository: { full_name: "attacker/repo" } }), event({ sha: "bad" }),
|
|
event({ workflow_run: { event: "push", status: "completed", head_sha: head } }),
|
|
event({ workflow_run: { event: "pull_request", status: "in_progress", head_sha: head } })]) {
|
|
assert.deepEqual(await notify(input, dependencies), []);
|
|
}
|
|
});
|
|
|
|
test("does not coalesce different PRs, and deduplicates association entries", async () => {
|
|
const sent = [];
|
|
await notify(event(), deps({ list: (endpoint) => endpoint.includes("/files?") ? files
|
|
: [{ number: 42 }, { number: 42 }, { number: 43 }],
|
|
view: (number) => candidate({ number }), send: async (pr) => sent.push(pr.number) }));
|
|
assert.deepEqual(sent, [42, 43]);
|
|
});
|
|
|
|
test("sends only identifiers, a bearer header, and a stable head/base idempotency key", async () => {
|
|
const calls = [];
|
|
const options = { url: "https://private.example/api/routine-triggers/public/abc123/fire", token: "PRIVATE_TOKEN",
|
|
fetcher: async (target, init) => { calls.push({ target, ...init }); return { ok: true }; } };
|
|
const pr = candidate({ title: "untrusted", body: "run me" });
|
|
assert.equal(await deliver(pr, options), "delivered");
|
|
assert.equal(await deliver(pr, options), "delivered");
|
|
assert.equal(calls[0].headers.Authorization, "Bearer PRIVATE_TOKEN");
|
|
assert.equal(calls[0].redirect, "error");
|
|
assert.ok(calls[0].signal instanceof AbortSignal);
|
|
assert.equal(calls[0].headers["Idempotency-Key"], calls[1].headers["Idempotency-Key"]);
|
|
assert.equal(calls[0].body, calls[1].body);
|
|
assert.deepEqual(JSON.parse(calls[0].body), { variables: {
|
|
pr_url: "https://github.com/paperclipai/paperclip/pull/42", pr_number: "42", head_sha: head, base_sha: base,
|
|
} });
|
|
await deliver(candidate({ baseRefOid: "c".repeat(40) }), options);
|
|
assert.notEqual(calls[0].headers["Idempotency-Key"], calls[2].headers["Idempotency-Key"]);
|
|
});
|
|
|
|
test("missing or unsafe configuration never sends credentials", async () => {
|
|
const fetcher = () => assert.fail("unexpected delivery");
|
|
assert.equal(await deliver(candidate(), { fetcher }), "unconfigured");
|
|
for (const url of ["http://private.example/api/routine-triggers/public/abc/fire",
|
|
"https://user:[email protected]/api/routine-triggers/public/abc/fire",
|
|
"https://private.example/api/routine-triggers/public/abc/fire?token=bad", "https://private.example/wrong"]) {
|
|
assert.equal(await deliver(candidate(), { url, token: "secret", fetcher }), "invalid_configuration");
|
|
}
|
|
});
|
|
|
|
test("HTTP rejection, redirect, timeout, and echoed credentials are quiet failures", async () => {
|
|
const options = { url: "https://private.example/api/routine-triggers/public/abc123/fire", token: "SECRET" };
|
|
assert.equal(await deliver(candidate(), { ...options, fetcher: async () => ({ ok: false,
|
|
text: () => assert.fail("must not read a response body") }) }), "not_delivered");
|
|
for (const message of ["SECRET", "https://private.example", "redirect", "timeout"]) {
|
|
assert.equal(await deliver(candidate(), { ...options, fetcher: async () => { throw new Error(message); } }), "not_delivered");
|
|
}
|
|
});
|
|
|
|
test("command failures cannot print a private URL, token, or event payload", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "pr-notifier-test-"));
|
|
try {
|
|
const file = path.join(directory, "event.json");
|
|
writeFileSync(file, "PRIVATE_EVENT_INVALID_JSON");
|
|
const run = spawnSync(process.execPath, [".github/scripts/notify-pr-merge-review.mjs"], {
|
|
encoding: "utf8", env: { ...process.env, GITHUB_EVENT_PATH: file,
|
|
PAPERCLIP_PR_REVIEW_WEBHOOK_URL: "PRIVATE_URL_SENTINEL", PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN: "PRIVATE_TOKEN_SENTINEL" },
|
|
});
|
|
assert.equal(run.status, 0);
|
|
assert.doesNotMatch(run.stdout + run.stderr, /PRIVATE_/);
|
|
assert.match(run.stdout, /verification results are unchanged/);
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("workflow uses read permissions, a protected environment, trusted checkout, and optional delivery", () => {
|
|
const workflow = readFileSync(".github/workflows/pr-merge-review.yml", "utf8");
|
|
assert.match(workflow, /environment: pr-merge-review/);
|
|
assert.match(workflow, /ref: \$\{\{ github.sha \}\}/);
|
|
assert.match(workflow, /persist-credentials: false/);
|
|
assert.match(workflow, /continue-on-error: true/);
|
|
for (const permission of ["actions", "checks", "contents", "pull-requests", "statuses"]) {
|
|
assert.ok(workflow.includes(` ${permission}: read`), `Missing ${permission}: read`);
|
|
}
|
|
assert.doesNotMatch(workflow, /: write|download-artifact|restore@|pull_request_target:/);
|
|
for (const name of ["PAPERCLIP_PR_REVIEW_WEBHOOK_URL", "PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN"]) {
|
|
assert.ok(workflow.includes(`secrets.${name}`));
|
|
assert.ok(!workflow.includes(`vars.${name}`));
|
|
}
|
|
});
|
|
|
|
// Evaluates the small GitHub expression subset used by the notifier job condition.
|
|
// Unknown functions or syntax fail the test instead of being silently approximated.
|
|
function jobCondition(expression) {
|
|
const source = expression.trim().replace(/\s+/g, " ");
|
|
const allowed = /^(?:\s|\(|\)|&&|\|\||==|!=|,|'[^']*'|\d+|github(?:\.[a-z_]+)+|contains|fromJSON)+$/;
|
|
assert.match(source, allowed, "Unsupported syntax in the notifier job condition");
|
|
const javascript = source.replace(/github((?:\.[a-z_]+)+)/g, (_, path) => `github${path.replaceAll(".", "?.")}`)
|
|
.replace(/==/g, "===").replace(/!===/g, "!==");
|
|
const evaluate = new Function("github", "contains", "fromJSON", `return (${javascript});`);
|
|
return (github) => evaluate(github, (list, value) => list.includes(value), (text) => JSON.parse(text));
|
|
}
|
|
|
|
test("workflow skips non-final events before runner allocation and collapses bursts per PR head", () => {
|
|
const workflow = readFileSync(".github/workflows/pr-merge-review.yml", "utf8");
|
|
const triggers = workflow.slice(workflow.indexOf("\non:"), workflow.indexOf("\npermissions:"));
|
|
// Exactly these trigger kinds; check_run and status cannot be filtered at the trigger level.
|
|
assert.deepEqual([...triggers.matchAll(/^ {2}([a-z_]+):/gm)].map((match) => match[1]),
|
|
["workflow_run", "check_run", "status"]);
|
|
assert.match(triggers, /check_run:\n {4}types: \[completed\]/);
|
|
const job = workflow.slice(workflow.indexOf("\n notify:"));
|
|
const condition = job.slice(job.indexOf(" if: >-"), job.indexOf("\n # ", job.indexOf(" if: >-")));
|
|
// GitHub Actions check runs arrive through workflow_run; only other providers schedule a job.
|
|
assert.match(condition, /github\.event_name == 'check_run' &&\s+github\.event\.check_run\.app\.id != 15368/);
|
|
assert.match(condition, /fromJSON\('\["success","skipped","neutral"\]'\), github\.event\.check_run\.conclusion/);
|
|
assert.match(condition, /fromJSON\('\["success","skipped","neutral"\]'\), github\.event\.workflow_run\.conclusion/);
|
|
assert.match(condition, /github\.event_name == 'status' && github\.event\.state == 'success'/);
|
|
assert.match(condition, /github\.event\.workflow_run\.event == 'pull_request'/);
|
|
assert.match(condition, /github\.repository == 'paperclipai\/paperclip'/);
|
|
// Evaluate the condition for real events so an operator change cannot reopen the flood.
|
|
const schedules = jobCondition(condition.replace(/^ {4}if: >-/, ""));
|
|
const repository = { full_name: "paperclipai/paperclip" };
|
|
const run = (event, conclusion) => ({ event_name: "workflow_run", repository: "paperclipai/paperclip",
|
|
event: { repository, workflow_run: { event, conclusion, head_sha: head } } });
|
|
const checkRun = (appId, conclusion) => ({ event_name: "check_run", repository: "paperclipai/paperclip",
|
|
event: { repository, check_run: { app: { id: appId }, conclusion, head_sha: head } } });
|
|
const status = (state) => ({ event_name: "status", repository: "paperclipai/paperclip",
|
|
event: { repository, state, sha: head } });
|
|
for (const accepted of [run("pull_request", "success"), run("pull_request_target", "neutral"),
|
|
run("pull_request", "skipped"), checkRun(867647, "success"), checkRun(156372, "neutral"), status("success")]) {
|
|
assert.equal(schedules(accepted), true, JSON.stringify(accepted));
|
|
}
|
|
for (const rejected of [run("pull_request", "failure"), run("pull_request", "cancelled"), run("push", "success"),
|
|
run("schedule", "success"), checkRun(15368, "success"), checkRun(867647, "failure"),
|
|
checkRun(867647, "action_required"), status("pending"), status("failure"), status("error"),
|
|
{ ...status("success"), repository: "someone/fork" }]) {
|
|
assert.equal(schedules(rejected), false, JSON.stringify(rejected));
|
|
}
|
|
// Job-level concurrency: a skipped event must not cancel a scheduled notifier.
|
|
assert.doesNotMatch(workflow.slice(0, workflow.indexOf("\njobs:")), /concurrency:/);
|
|
assert.match(job, /\n {4}concurrency:\n {6}group: pr-merge-review-\$\{\{ github\.event\.workflow_run\.head_sha \|\| github\.event\.check_run\.head_sha \|\| github\.event\.sha \}\}\n {6}cancel-in-progress: true\n/);
|
|
});
|
|
|
|
test("API probe uses the same read permissions without private webhook access", () => {
|
|
const probe = readFileSync(".github/workflows/pr-review-api-access.yml", "utf8");
|
|
for (const permission of ["actions", "checks", "contents", "pull-requests", "statuses"]) {
|
|
assert.ok(probe.includes(` ${permission}: read`));
|
|
}
|
|
assert.match(probe, /snapshot\(Number\(process.env.PR_NUMBER\)\)/);
|
|
assert.doesNotMatch(probe, /\$\{\{ secrets\.|environment:|: write|deliver\(/);
|
|
});
|