From edadc60b2cbbadee4b4426dda7b4930e3d214b69 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:57:49 -0400 Subject: [PATCH 1/4] feat(pr-pipeline): run the security review on pr-review-cli with key-first funding The security review lane calls ci-workflows pr-review-cli.yml (v0.63.0), lane security: the reviews role's API key first, its subscription token after a handoff. The check name, marker, notice and record format stay; a newer push waits for a running review instead of cancelling it. The review posts as github-actions[bot] with a claude-lane footer, so address-feedback, the feedback dispatcher and the babysit merge gate accept that author only on comments whose last non-blank line is the footer, and babysit's record pattern accepts account=fallback. One reviews role backs both review lanes. Co-Authored-By: Claude Opus 5.5 --- ...curity-review-on-pr-review-cli-5b1e9d47.md | 9 ++ ...curity-review-on-pr-review-cli-a83c06f2.md | 7 ++ .github/lane-funding.json | 16 +--- .../scripts/pr-unblock/address-feedback.mjs | 25 +++++- .../pr-unblock/address-feedback.test.mjs | 34 ++++++++ .../scripts/pr-unblock/feedback-dispatch.mjs | 6 +- .../pr-unblock/feedback-dispatch.test.mjs | 20 +++++ .../workflows/pr-review-security-hosted.yml | 81 ++++++++--------- docs/conventions/pr-pipeline/README.md | 10 ++- .../scripts/babysit_review_evidence.py | 49 ++++++++--- .../tests/test_babysit_review_evidence.py | 86 +++++++++++++++++++ 11 files changed, 269 insertions(+), 74 deletions(-) create mode 100644 .changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md create mode 100644 .changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md diff --git a/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md b/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md new file mode 100644 index 0000000000..c1e7b93d4b --- /dev/null +++ b/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md @@ -0,0 +1,9 @@ +--- +bump: minor +--- + +### Changed + +- The security review lane (`pr-review-security-hosted.yml`) runs on ci-workflows' `pr-review-cli.yml` (v0.63.0), lane `security`: the reviews role's API key first, and its subscription token only after the key hands off or when the repository variable `REVIEWS_KEY_EXHAUSTED` is `true`. The check stays `pr-review-security / security-review`; the review now posts as `github-actions[bot]`, each comment ending in its `claude-lane: claude-security-review` footer. A newer push no longer cancels a running review: it waits for it. +- `address-feedback` and `pr-feedback-dispatch.yml` treat a `github-actions[bot]` comment as the reviewer's when its last non-blank line is a review lane's footer and its review is `github-actions[bot]`'s; `claude[bot]` findings are unchanged. +- One funding role, `reviews`, backs both review lanes and is keyed; the `reviews-security` role is gone. diff --git a/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md new file mode 100644 index 0000000000..b1cd46e13c --- /dev/null +++ b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Changed + +- The merge gate's review evidence accepts the security lane on ci-workflows' `pr-review-cli.yml`: a completion record ending in `account=fallback`, and a `github-actions[bot]` thread or summary comment whose last non-blank line is the lane's `claude-lane:` footer. A reviewed-head marker is never read a second time as a summary comment. diff --git a/.github/lane-funding.json b/.github/lane-funding.json index 0674d01eda..e062b37813 100644 --- a/.github/lane-funding.json +++ b/.github/lane-funding.json @@ -7,21 +7,11 @@ "secretPrefix": "REVIEWS", "tokenSecret": "REVIEWS_CLAUDE_CODE_OAUTH_TOKEN", "keySecret": "REVIEWS_CLAUDE_API_KEY", - "keyed": false, - "category": "review", - "lanes": [ - { "id": "pr-review", "workflow": "pr-review-hosted.yml", "launcher": "claude-code-action", "currentSecrets": ["CLAUDE_CODE_OAUTH_TOKEN"] } - ] - }, - { - "id": "reviews-security", - "secretPrefix": "REVIEWS_SECURITY", - "tokenSecret": "REVIEWS_SECURITY_CLAUDE_CODE_OAUTH_TOKEN", - "keySecret": "REVIEWS_SECURITY_CLAUDE_API_KEY", - "keyed": false, + "keyed": true, "category": "review", "lanes": [ - { "id": "pr-review-security", "workflow": "pr-review-security-hosted.yml", "launcher": "claude-code-action", "currentSecrets": ["CLAUDE_CODE_OAUTH_TOKEN"] } + { "id": "pr-review", "workflow": "pr-review-hosted.yml", "launcher": "claude-code-action", "currentSecrets": ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { "id": "pr-review-security", "workflow": "pr-review-security-hosted.yml", "launcher": "run-claude-cli", "currentSecrets": ["REVIEWS_CLAUDE_API_KEY", "REVIEWS_CLAUDE_CODE_OAUTH_TOKEN"] } ] }, { diff --git a/.github/scripts/pr-unblock/address-feedback.mjs b/.github/scripts/pr-unblock/address-feedback.mjs index 4d9ae28c69..3df762ddfb 100644 --- a/.github/scripts/pr-unblock/address-feedback.mjs +++ b/.github/scripts/pr-unblock/address-feedback.mjs @@ -51,6 +51,20 @@ const FILES_CAP = 3000; const firstLine = (body) => String(body ?? "").split("\n")[0].replace(/\r$/, ""); +// A review lane's own comment as the pr-review-cli reusable posts it: by +// github-actions[bot] (MARKER_AUTHOR_ID), its last non-blank line the lane's +// `claude-lane: ` footer. Any other github-actions[bot] comment is not a +// finding. +const LANE_FOOTER = /^`?claude-lane: (?:claude-review|claude-security-review)`?$/; +export const isLaneComment = (comment) => + comment.authorId === MARKER_AUTHOR_ID && + LANE_FOOTER.test( + String(comment.body ?? "") + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line !== "") ?? "", + ); + // Round markers the lanes App posted; any other author's copy is ignored. export const isRoundMarker = (comment, appId) => comment.user?.id === appId && firstLine(comment.body) === ROUND_MARKER; @@ -258,7 +272,8 @@ export function humanGatedLabelFrom(path) { // Who wrote each comment of a thread, or null when it is not one to work. // `comments` are oldest first, each { authorId, createdAt, body, lastEditedAt, -// editorId }. The first is the reviewer bot's, or a verifier note +// editorId }. The first is the reviewer bot's, a lane comment (isLaneComment, +// which counts as the reviewer bot's throughout), or a verifier note // (github-actions[bot]'s, its first line VERIFY_NOTE_MARKER), and one of these // holds: // - every comment is the reviewer bot's, but a verifier note's own; @@ -278,10 +293,11 @@ export function humanGatedLabelFrom(path) { // escalation when there is one. export function threadAuthors(comments, { reviewerId, appId, trustedHumans = new Set(), requesters = null, fixers = requesters ?? new Set() }) { const verifier = comments.length > 0 && comments[0].authorId === MARKER_AUTHOR_ID && firstLine(comments[0].body) === VERIFY_NOTE_MARKER; - if (comments.length === 0 || (comments[0].authorId !== reviewerId && !verifier)) { + const reviewer = (comment) => comment.authorId === reviewerId || isLaneComment(comment); + if (comments.length === 0 || (!reviewer(comments[0]) && !verifier)) { return null; } - const finder = (comment, i) => (i === 0 && verifier ? { author: "verifier" } : comment.authorId === reviewerId ? { author: "reviewer" } : null); + const finder = (comment, i) => (i === 0 && verifier ? { author: "verifier" } : reviewer(comment) ? { author: "reviewer" } : null); if (comments.every((comment, i) => finder(comment, i) !== null)) { return requesters ? null : comments.map(finder); } @@ -368,7 +384,8 @@ export function keepThreads({ threads, reviewComments, reviews, heads, reviewerI // escalation) asks for the thread, so its review may be on an earlier head. const directed = authors.some((a) => a.direction === true); const review = reviewsById.get(rows[0].pull_request_review_id); - const reviewAuthor = authors[0].author === "verifier" ? MARKER_AUTHOR_ID : reviewerId; + // A verifier note's or a lane comment's review is github-actions[bot]'s. + const reviewAuthor = authors[0].author === "verifier" || rows[0].user?.id === MARKER_AUTHOR_ID ? MARKER_AUTHOR_ID : reviewerId; if (review?.user?.id !== reviewAuthor || (!directed && !heads.has(review.commit_id))) { continue; } diff --git a/.github/scripts/pr-unblock/address-feedback.test.mjs b/.github/scripts/pr-unblock/address-feedback.test.mjs index 26330d6af2..fe76cddadb 100644 --- a/.github/scripts/pr-unblock/address-feedback.test.mjs +++ b/.github/scripts/pr-unblock/address-feedback.test.mjs @@ -420,6 +420,40 @@ test("a github-actions[bot] thread without the note marker, or in another accoun assert.equal((await run(forged)).decision, "skip"); }); +// The pr-review-cli reusable posts each finding as github-actions[bot], the +// body ending `\n\nclaude-lane: ` (ci-workflows +// .github/actions/publish-review/publish.cjs commentBody at v0.63.0). +const LANE_FINDING = "severity: IMPORTANT\nThe input reaches the shell unquoted.\n\nclaude-lane: claude-security-review"; +const laneFinding = (body, reviewer = ACTIONS_BOT) => + fakeApi({ + issueComments: [{ id: 1, user: { id: ACTIONS_BOT }, body: marker("claude-security-review", HEAD) }], + reviewComments: [comment(100, ACTIONS_BOT, { body })], + reviews: [{ id: 10, user: { id: reviewer }, commit_id: HEAD }], + }); + +test("a github-actions[bot] finding ending in the lane footer is kept as the reviewer's", async () => { + const result = await run(laneFinding(LANE_FINDING)); + assert.equal(result.decision, "proceed"); + assert.deepEqual(result.threads[0].comments, [{ id: 100, author: "reviewer", body: LANE_FINDING }]); + // Trailing blank lines do not hide the footer. + assert.equal((await run(laneFinding(`${LANE_FINDING}\n\n`))).decision, "proceed"); +}); + +test("a github-actions[bot] comment whose last line is not a review lane's footer is no finding", async () => { + for (const body of [ + "The input reaches the shell unquoted.", + "claude-lane: claude-security-review\nThe footer is not the last line.", + "The input reaches the shell.\n\nclaude-lane: pr-verify", + ]) { + assert.equal((await run(laneFinding(body))).decision, "skip", body); + } + // The footer on another account's comment, or in a review another account + // submitted, opens nothing. + const human = fakeApi({ reviewComments: [comment(100, HUMAN, { body: LANE_FINDING })] }); + assert.equal((await run(human)).decision, "skip"); + assert.equal((await run(laneFinding(LANE_FINDING, REVIEWER))).decision, "skip"); +}); + test("a later github-actions[bot] comment on a verifier note is no finding", async () => { const result = await run(verifierNote({ replies: [comment(101, ACTIONS_BOT, { body: VERIFY_NOTE })] })); assert.equal(result.decision, "skip"); diff --git a/.github/scripts/pr-unblock/feedback-dispatch.mjs b/.github/scripts/pr-unblock/feedback-dispatch.mjs index dd99c58fa0..4709dff18e 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.mjs @@ -73,10 +73,12 @@ export const DEFAULTS = Object.freeze({ ".github/workflows/pr-review-security-hosted.yml", ]), // The lanes each review workflow names in its reviewed-head marker - // (melodic-software/ci-workflows pr-review.yml and pr-review-security.yml). + // (melodic-software/ci-workflows pr-review.yml and pr-review-cli.yml). markerLanes: Object.freeze(["claude-review", "claude-security-review"]), holdLabels: Object.freeze(["do-not-merge"]), - // claude[bot], the reviewer (.github/standards/trusted-actors/trusted-actors.json). + // claude[bot], the code lane's reviewer (.github/standards/trusted-actors/trusted-actors.json). + // pr-review-cli posts as github-actions[bot]; threadAuthors accepts those + // comments only when the lane footer is their last line. reviewerId: 209825114, // github-actions[bot], which writes the reviewed-head markers. markerAuthorId: 41898282, diff --git a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs index 62077424ce..89b8f90023 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs @@ -229,6 +229,26 @@ test("the security review workflow is an upstream too", async () => { assert.equal((await decide(api)).result.number, 42); }); +// A pr-review-cli finding: github-actions[bot]'s, its body ending in the lane +// footer (ci-workflows publish-review publish.cjs commentBody at v0.63.0). +const laneThread = (body) => ({ + isResolved: false, + comments: { nodes: [{ author: ACTIONS_BOT_NODE, body }], pageInfo: { hasNextPage: false } }, +}); + +test("a github-actions[bot] thread ending in the lane footer dispatches like the reviewer bot's", async () => { + const api = fakeApi({ threads: [laneThread("severity: IMPORTANT\nUnquoted input.\n\nclaude-lane: claude-security-review")] }); + assert.equal((await decide(api)).result.number, 42); +}); + +test("a github-actions[bot] thread without a review lane's footer last dispatches nothing", async () => { + for (const body of ["Unquoted input.", "claude-lane: claude-security-review\nUnquoted input."]) { + const api = fakeApi({ threads: [laneThread(body)] }); + assert.equal((await decide(api)).result, null, body); + assert.deepEqual(api.calls.dispatch, []); + } +}); + for (const [label, options] of [ ["the upstream is another workflow", { run: { path: ".github/workflows/ci.yml" } }], ["the upstream path names a review workflow at another ref", { diff --git a/.github/workflows/pr-review-security-hosted.yml b/.github/workflows/pr-review-security-hosted.yml index c3fcb689db..4f18b5bb0c 100644 --- a/.github/workflows/pr-review-security-hosted.yml +++ b/.github/workflows/pr-review-security-hosted.yml @@ -7,32 +7,31 @@ name: pr-review-security-hosted # this copy can add the resolve-model and kill-switch jobs below. It differs # from the component in those jobs and these comments. # -# Thin caller for the org's reusable security-review lane (advisory verdict, -# fail-closed execution check). The reusable workflow owns the action pin, -# model, and the secret-handling model; per its header, this caller owns the -# triggers, concurrency, and the GITHUB_TOKEN permission grant. +# Thin caller for the org's pr-review-cli reusable, lane `security` (advisory +# verdict, fail-closed execution check). The reusable owns the launcher, the +# credential order (the reviews role's API key, then its subscription token +# after a handoff) and the secret-handling model; per its header, this caller +# owns the triggers, concurrency, and the GITHUB_TOKEN permission grant. # # There is no workflow-level path gating. The reusable skips, with a green -# check, a review whose files in scope are all documentation (its -# `docs-only-paths` default: `docs/**/*.md`, READMEs and changelogs). A -# `.github/claude-security-paths` file is no longer read. +# check, a review whose files in scope are all documentation (the +# `docs-only-paths` passed below). A `.github/claude-security-paths` file is +# no longer read. # # NEVER add workflow-level `paths:`/`paths-ignore:` to this caller: a check # whose workflow never triggers is missing, not green, so a required check # would stay Pending forever, and the babysit merge gate, which waits for # `pr-review-security / security-review`, never arms. # -# The reusable skips fork PRs (they receive no secrets and a read-only token) -# and every `[bot]` actor whose sender id is not in `allowed-bot-sender-ids` -# (below); a required check does NOT prove a fork PR was reviewed (see the -# reusable's header). On a public repository the lane reviews only pull -# requests from branches of this repository: fork and Dependabot pull requests -# get no secrets. +# The reusable skips fork PRs (they receive no secrets and a read-only token), +# Dependabot, and every `[bot]` actor whose sender id is not in +# `allowed-bot-sender-ids` (below); a required check does NOT prove a fork PR +# was reviewed (see the reusable's header). On a public repository the lane +# reviews only pull requests from branches of this repository: fork and +# Dependabot pull requests get no secrets. # -# RUNNER PLACEMENT: hosted shape, the sibling of -# components/claude-lanes/claude-security-review.yml, differing from it only -# in these comments, `runner`, the resolve-model and kill-switch jobs and the workflow `name:`. It is an interim with -# the same removal trigger as the code-review caller. See that file's header. +# RUNNER PLACEMENT: every job of the reusable runs on ubuntu-24.04; it takes +# no runner input. on: pull_request: @@ -41,25 +40,22 @@ on: # pull_request workflow while a PR conflicts with its base), narrowed by # the reusable to the files changed since the last completed review. The check # stays advisory: github-iac ADR 0011 forbids any organization ruleset - # from making an agentic review a merge or deployment prerequisite. With - # the per-PR group below cancelling, a push supersedes the in-flight review. + # from making an agentic review a merge or deployment prerequisite. # - # NO `merge_group:`: the pinned action cannot serve that event; see + # NO `merge_group:`: the reusable runs only on `pull_request`; see # melodic-software/ci-workflows#272 before enabling a merge queue. types: [opened, synchronize, ready_for_review, reopened] permissions: contents: read -# Per-PR supersede group: a newer event for the same PR, a push included, -# cancels the older run, so reviews of superseded heads do not stack. The -# merge guard judges only the newest run of each check, so a cancelled run on -# a superseded head does not block merge. The group is the -# `${{ github.workflow }}`-prefixed canonical form the standards -# `concurrency-policy` component requires. +# Per-PR group: a newer event for the same PR waits for the running review +# instead of cancelling it, because the guardrails merge guard reads a +# cancelled run as red. The group is the `${{ github.workflow }}`-prefixed +# canonical form the standards `concurrency-policy` component requires. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true + cancel-in-progress: false jobs: # The review's model, turns and effort: the review-security route of the @@ -114,7 +110,7 @@ jobs: SETTINGS_JSON: ${{ steps.models.outputs.settings-json }} run: | if [ -n "$SETTINGS_JSON" ]; then - echo "::error::the review-security profile sets subagent-model, which pr-review-security.yml does not support" + echo "::error::the review-security profile sets subagent-model, which pr-review-cli.yml does not support" exit 1 fi @@ -169,15 +165,16 @@ jobs: # A draft is work in progress and does not spend a review; the # ready_for_review trigger above is what delivers one when it is ready. if: ${{ !cancelled() && github.event.pull_request.draft == false && needs.kill-switch.outputs.proceed == 'true' }} + # The model jobs narrow this to contents: read and pull-requests: read; + # only the reusable's status job (the security-review check) writes. permissions: - contents: read # checkout + read the diff - pull-requests: write # post the security review, tracking comment and - # the lane's reviewed-head marker comment - id-token: write # OIDC, mints the Claude GitHub App token (required - # even with an OAuth/API-key credential) - uses: melodic-software/ci-workflows/.github/workflows/pr-review-security.yml@2fe38c031867900824c164e33a140a335ed9ecf0 # v0.62.0 + contents: read + pull-requests: write # the review, its inline comments and the lane's + # reviewed-head marker comment + uses: melodic-software/ci-workflows/.github/workflows/pr-review-cli.yml@8778094736e414233110f3e0d6d9948628ba77b5 # v0.63.0 with: - runner: ubuntu-24.04 + lane: security + publish: comments # Empty unless resolve-model succeeded, so a failed job's partial output # never reaches the review. model-args: ${{ needs.resolve-model.result == 'success' && needs.resolve-model.outputs.claude-args || '' }} @@ -186,8 +183,14 @@ jobs: # reviewed. Where the repository does not set this variable the value is # empty and the reusable reviews no bot. allowed-bot-sender-ids: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} - # Pass only the one named secret (least privilege) rather than - # `secrets: inherit`, which would forward every parent secret to the - # called workflow. + # A review whose files in scope all match is skipped with a green check. + # Agent instruction files never count as documentation. + docs-only-paths: | + docs/**/*.md + **/README.md + **/CHANGELOG.md + # The reviews role's key runs first; its subscription token only after the + # key hands off. Named secrets only, never `secrets: inherit`. secrets: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.REVIEWS_CLAUDE_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.REVIEWS_CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 6cf06cfb5d..48e71b09ca 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -184,6 +184,10 @@ subscription fallback) answers them and hands on a patch and its replies, `publish-feedback` (a script job with no model, `publish-artifact`) posts the replies, naming the pushed commit ([`pr-run-activity.md`](pr-run-activity.md#prepare-review-threads)). +The reviewer bot is `claude[bot]`, or `github-actions[bot]` on a comment whose last non-blank +line is a review lane's `claude-lane: ` footer, as ci-workflows' `pr-review-cli.yml` posts +its findings; the review holding it must be `github-actions[bot]`'s too. + A fix request is a reply by a person on the trusted-actor list who holds write or admin permission on the repository, never edited, or last edited by a person on that list, with a line that starts with `PR-fix lane: please fix this finding`, in any letter case, outside a fenced code block (a quoted @@ -1551,9 +1555,9 @@ waits on an owner decision and a per-week Fable allowance for the lanes. ## Funding -Each Claude lane's runs are paid by one funding role. There are seven: `reviews`, -`reviews-security`, `issue-lane`, `intake`, `pr-unblock`, `pr-verify`, and `automations` for the -lanes outside the pipeline. A role's secrets are `_CLAUDE_API_KEY` and +Each Claude lane's runs are paid by one funding role. There are six: `reviews` for both review +lanes, `issue-lane`, `intake`, `pr-unblock`, `pr-verify`, and `automations` for the lanes outside +the pipeline. A role's secrets are `_CLAUDE_API_KEY` and `_CLAUDE_CODE_OAUTH_TOKEN`, the role upper-snake, so `issue-lane` gives `ISSUE_LANE_`. [`.github/lane-funding.json`](../../../.github/lane-funding.json) is the source of truth: each diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py index 7fa0abf320..fc014248b1 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py @@ -67,7 +67,9 @@ rf"(?:findings=(?P{_COUNT}) inline=(?P{_COUNT}) " rf"scope=(?:(?Pwhole)|incremental since=(?P{_SHA}))" rf"|skip=no-change through=(?P{_SHA})" - rf"|skip=(?P[\w-]+))$" + rf"|skip=(?P[\w-]+))" + # The subscription leg's review (ci-workflows pr-review-cli.yml). + rf"(?: account=fallback)?$" ) SHA_RE = re.compile(rf"^{_SHA}$") HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@") @@ -79,6 +81,28 @@ ) CLAUDE_APP_LOGINS = frozenset({"claude", "claude[bot]"}) LANE_TAG_RE = re.compile(r"^\s*`?claude-lane: ([\w-]+)`?\s*$", re.MULTILINE) +# ci-workflows pr-review-cli.yml posts as github-actions[bot] (REST login; +# GraphQL drops the suffix). Such a comment is the lane's only when its last +# non-blank line is that lane's footer. +ACTIONS_LOGINS = frozenset({"github-actions", "github-actions[bot]"}) + + +def _footer_lane(body: str) -> str | None: + """The lane a body's last non-blank line names as its `claude-lane:` + footer, None when that line is no footer.""" + lines = [line for line in body.splitlines() if line.strip()] + tag = LANE_TAG_RE.fullmatch(lines[-1]) if lines else None + return tag[1] if tag else None + + +def _lane_comment(lane: str, login: object, body: str) -> bool: + """A Claude App comment untagged or tagged for `lane`, or a + github-actions[bot] comment whose footer names `lane`.""" + if login in CLAUDE_APP_LOGINS: + tag = LANE_TAG_RE.search(body) + return tag is None or tag[1] == lane + return login in ACTIONS_LOGINS and _footer_lane(body) == lane + # The lane whose findings hold the merge only at its top tier, CRITICAL # (plugins/review/skills/security-review/SKILL.md, "Reporting"): each inline # comment carries one `severity:` line, and the summary one @@ -442,17 +466,19 @@ def _require_marker(lane: str, head: str, comments: Sequence[Mapping[str, Any]]) def _summary_comments( lane: str, comments: Sequence[Mapping[str, Any]] ) -> list[tuple[datetime | None, str]]: - """Each Claude App issue comment untagged or tagged for `lane`, with its - creation time: the summary comments an older lane producer posted.""" + """Each lane comment of `lane` (`_lane_comment`) but a reviewed-head + marker, which the caller reads already, with its creation time: the + summary comments a lane producer posted.""" found = [] for comment in comments: user = comment.get("user") - if not isinstance(user, Mapping) or user.get("login") not in CLAUDE_APP_LOGINS: + if not isinstance(user, Mapping): continue body = str(comment.get("body") or "") - tag = LANE_TAG_RE.search(body) created = _time(comment.get("created_at")) - if (tag is None or tag[1] == lane) and created is not None: + if MARKER_RE.match(body.split("\n", 1)[0]): + continue + if _lane_comment(lane, user.get("login"), body) and created is not None: found.append((created, body)) return found @@ -718,7 +744,8 @@ def _time(value: Any) -> datetime | None: def _lane_thread(lane: str, thread: Mapping[str, Any]) -> bool: - """A thread the Claude App opened that is untagged or tagged for `lane`.""" + """A thread the Claude App opened, untagged or tagged for `lane`, or + github-actions[bot] opened with `lane`'s footer last.""" comments = thread.get("comments") if not isinstance(comments, list | tuple): raise Unbound("evidence-error") @@ -730,10 +757,6 @@ def _lane_thread(lane: str, thread: Mapping[str, Any]) -> bool: author = first.get("author") if not isinstance(author, Mapping): return False - if ( - author.get("__typename") != "Bot" - or author.get("login") not in CLAUDE_APP_LOGINS - ): + if author.get("__typename") != "Bot": return False - tag = LANE_TAG_RE.search(str(first.get("body") or "")) - return tag is None or tag[1] == lane + return _lane_comment(lane, author.get("login"), str(first.get("body") or "")) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py index 786bc7cf7a..7430fa4764 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py @@ -503,6 +503,30 @@ def test_no_completion_record(self) -> None: result = self.bind([run("claude-review", S)]) self.assertUnbound(result["claude-review"], "no-record") + def test_a_subscription_leg_record_binds(self) -> None: + # ci-workflows pr-review-cli.yml ends the record ` account=fallback` + # when the subscription leg reviewed (publish-review publish.cjs). + runs = [ + whole("claude-review", S), + run( + "claude-security-review", + S, + "findings=0 inline=0 scope=whole account=fallback", + ), + ] + result = self.bind(runs)["claude-security-review"] + self.assertTrue(result.bound) + self.assertEqual(result.open_findings, 0) + + def test_another_account_suffix_is_malformed(self) -> None: + for tail in ( + "findings=0 inline=0 scope=whole account=primary", + "findings=0 inline=0 scope=whole account=fallback extra", + ): + with self.subTest(tail=tail): + result = self.bind([run("claude-review", S, tail)]) + self.assertUnbound(result["claude-review"], "record-malformed") + def test_record_for_another_lane(self) -> None: item = run( "claude-review", @@ -1682,6 +1706,68 @@ def summary(body: str, at: str) -> dict[str, Any]: self.assertEqual(result.open_findings, 2) self.assertEqual(result.blocking_findings, blocking) + # ci-workflows pr-review-cli.yml posts as github-actions[bot] and foots + # each comment `\n\nclaude-lane: ` (publish-review publish.cjs). + ACTIONS = {"__typename": "Bot", "login": "github-actions"} + + def test_a_github_actions_finding_with_the_lane_footer_is_the_lanes(self) -> None: + for severity, blocking in (("IMPORTANT", 0), ("CRITICAL", 1), (None, 1)): + with self.subTest(severity=severity): + line = f"severity: {severity}\n" if severity else "" + body = f"finding\n{line}\nclaude-lane: {self.SEC}" + runs = [whole("claude-review", S), whole(self.SEC, S, n=1, k=1)] + threads = [thread(body, author=self.ACTIONS)] + result = self.bind(runs, threads=threads)[self.SEC] + self.assertEqual(result.open_findings, 1) + self.assertEqual(result.blocking_findings, blocking) + + def test_a_github_actions_thread_without_the_lanes_footer_last_is_not_the_lanes( + self, + ) -> None: + # Not the lane's thread, so the recorded inline finding has none left + # and counts as CRITICAL even though the body reads IMPORTANT. + for body in ( + "finding\nseverity: IMPORTANT", + f"claude-lane: {self.SEC}\nfinding\nseverity: IMPORTANT", + "finding\nseverity: IMPORTANT\n\nclaude-lane: claude-review", + ): + with self.subTest(body=body): + runs = [whole("claude-review", S), whole(self.SEC, S, n=1, k=1)] + threads = [thread(body, author=self.ACTIONS)] + result = self.bind(runs, threads=threads)[self.SEC] + self.assertEqual(result.blocking_findings, 1) + + def test_a_github_actions_summary_comment_counts_only_with_the_lanes_footer( + self, + ) -> None: + runs = [whole("claude-review", S), whole(self.SEC, S, n=2, k=0)] + + def summary(body: str) -> dict[str, Any]: + return { + "id": next(_ids), + "user": {"login": "github-actions[bot]", "type": "Bot"}, + "created_at": "2026-01-01T00:30:00Z", + "body": body, + } + + for body, blocking in ( + (f"summary\n\ncritical-findings: 0\n\nclaude-lane: {self.SEC}", 0), + ("summary\n\ncritical-findings: 0", 2), + (f"claude-lane: {self.SEC}\n\ncritical-findings: 0", 2), + ): + with self.subTest(body=body): + comments = [*both_markers(), summary(body)] + result = self.bind(runs, comments=comments)[self.SEC] + self.assertEqual(result.blocking_findings, blocking) + + def test_a_marker_ending_in_the_lane_footer_is_read_once(self) -> None: + runs = [whole("claude-review", S), whole(self.SEC, S, n=2, k=0)] + comments = self.summary_markers( + f"critical-findings: 0\n\nclaude-lane: {self.SEC}" + ) + result = self.bind(runs, comments=comments)[self.SEC] + self.assertEqual(result.blocking_findings, 0) + def test_a_thread_less_inline_finding_counts_as_critical(self) -> None: runs = [whole("claude-review", S), whole(self.SEC, S, n=1, k=1)] result = self.bind(runs, comments=self.summary_markers("critical-findings: 0"))[ From afc6eff523d1aa27a64796b7f46973a6d6ab1013 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:26:40 -0400 Subject: [PATCH 2/4] fix(pr-pipeline): pin the security lane to pr-review-cli v0.63.1 Co-Authored-By: Claude Opus 5.5 --- .../feat-security-review-on-pr-review-cli-5b1e9d47.md | 2 +- .github/workflows/pr-review-security-hosted.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md b/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md index c1e7b93d4b..5dfa886899 100644 --- a/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md +++ b/.changes/pr-pipeline/feat-security-review-on-pr-review-cli-5b1e9d47.md @@ -4,6 +4,6 @@ bump: minor ### Changed -- The security review lane (`pr-review-security-hosted.yml`) runs on ci-workflows' `pr-review-cli.yml` (v0.63.0), lane `security`: the reviews role's API key first, and its subscription token only after the key hands off or when the repository variable `REVIEWS_KEY_EXHAUSTED` is `true`. The check stays `pr-review-security / security-review`; the review now posts as `github-actions[bot]`, each comment ending in its `claude-lane: claude-security-review` footer. A newer push no longer cancels a running review: it waits for it. +- The security review lane (`pr-review-security-hosted.yml`) runs on ci-workflows' `pr-review-cli.yml` (v0.63.1), lane `security`: the reviews role's API key first, and its subscription token only after the key hands off or when the repository variable `REVIEWS_KEY_EXHAUSTED` is `true`. The check stays `pr-review-security / security-review`; the review now posts as `github-actions[bot]`, each comment ending in its `claude-lane: claude-security-review` footer. A newer push no longer cancels a running review: it waits for it. - `address-feedback` and `pr-feedback-dispatch.yml` treat a `github-actions[bot]` comment as the reviewer's when its last non-blank line is a review lane's footer and its review is `github-actions[bot]`'s; `claude[bot]` findings are unchanged. - One funding role, `reviews`, backs both review lanes and is keyed; the `reviews-security` role is gone. diff --git a/.github/workflows/pr-review-security-hosted.yml b/.github/workflows/pr-review-security-hosted.yml index 4f18b5bb0c..4b4d9461f9 100644 --- a/.github/workflows/pr-review-security-hosted.yml +++ b/.github/workflows/pr-review-security-hosted.yml @@ -171,7 +171,7 @@ jobs: contents: read pull-requests: write # the review, its inline comments and the lane's # reviewed-head marker comment - uses: melodic-software/ci-workflows/.github/workflows/pr-review-cli.yml@8778094736e414233110f3e0d6d9948628ba77b5 # v0.63.0 + uses: melodic-software/ci-workflows/.github/workflows/pr-review-cli.yml@83b739f625b501ad40c65dee7168d836883195e1 # v0.63.1 with: lane: security publish: comments From 813a68482084057da3e336d43c3651e253fde32a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:34:02 -0400 Subject: [PATCH 3/4] fix(source-control): find the security review lane on pr-review-cli review-venue.sh matched the security lane only by pr-review-security.yml, so after the caller moved to pr-review-cli.yml it read the lane as absent. Co-Authored-By: Claude Opus 5.5 --- ...curity-review-on-pr-review-cli-a83c06f2.md | 1 + docs/conventions/pr-pipeline/README.md | 2 +- .../github/pull-request/review-venue.md | 4 +-- .../pull-request/scripts/review-venue.sh | 15 ++++++++--- .../pull-request/scripts/review-venue.test.sh | 27 +++++++++++++++++++ 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md index b1cd46e13c..96db59a786 100644 --- a/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md +++ b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md @@ -5,3 +5,4 @@ bump: minor ### Changed - The merge gate's review evidence accepts the security lane on ci-workflows' `pr-review-cli.yml`: a completion record ending in `account=fallback`, and a `github-actions[bot]` thread or summary comment whose last non-blank line is the lane's `claude-lane:` footer. A reviewed-head marker is never read a second time as a summary comment. +- `review-venue.sh` finds the security lane on ci-workflows' `pr-review-cli.yml` when the caller's path contains `security`. It also reads a `: failure-class=skipped-validation` red as no verdict. diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 3a37fdef0a..805ebae194 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -2007,7 +2007,7 @@ the lanes. | `trust-root` ruleset, merge queues, code-owner review settings | github-iac `OrgRulesets.cs`; ADR 0015 Decision 5; [ADR 0007](https://github.com/melodic-software/github-iac/blob/main/docs/adr/0007-no-required-approving-reviews.md). Its rollback is not yet tracked: [github-iac#677](https://github.com/melodic-software/github-iac/issues/677) | | Test repository | [`melodic-software/pr-pipeline-sandbox`](https://github.com/melodic-software/pr-pipeline-sandbox), whose README states its purpose | | Trusted actors, runner policy, Actions naming vocabulary | `melodic-software/standards` components [`trusted-actors`](https://github.com/melodic-software/standards/tree/main/components/trusted-actors), [`runner-policy`](https://github.com/melodic-software/standards/tree/main/components/runner-policy) and [`github-actions-conventions`](https://github.com/melodic-software/standards/tree/main/components/github-actions-conventions), synced into `.github/standards/` | -| Review and intake reusable workflows | [`melodic-software/ci-workflows`](https://github.com/melodic-software/ci-workflows) `.github/workflows/pr-review.yml`, `pr-review-security.yml` and `intake-triage.yml`, called by this repository's synced `*-hosted.yml` callers and `intake-triage.yml` | +| Review and intake reusable workflows | [`melodic-software/ci-workflows`](https://github.com/melodic-software/ci-workflows) `.github/workflows/pr-review.yml`, `pr-review-cli.yml` (the security lane) and `intake-triage.yml`, called by this repository's synced `*-hosted.yml` callers and `intake-triage.yml` | In this repository, the lane runners and their contract are in [Lanes](#lanes), the config is `docs/conventions/pr-pipeline.yaml`, and the decisions are ADRs 0049 to 0051, 0053 to 0055 and 0058 in `docs/adr/`. diff --git a/plugins/source-control/reference/providers/github/pull-request/review-venue.md b/plugins/source-control/reference/providers/github/pull-request/review-venue.md index 84f28f0b16..79a3bbd9bd 100644 --- a/plugins/source-control/reference/providers/github/pull-request/review-venue.md +++ b/plugins/source-control/reference/providers/github/pull-request/review-venue.md @@ -12,7 +12,7 @@ workflows, fixed in the script: | Lane | Reusable workflow the caller's runs reference | |---|---| | `code-review` | `melodic-software/ci-workflows/.github/workflows/pr-review.yml@` | -| `security-review` | `melodic-software/ci-workflows/.github/workflows/pr-review-security.yml@` | +| `security-review` | `melodic-software/ci-workflows/.github/workflows/pr-review-security.yml@`, or `pr-review-cli.yml@` from a caller whose path contains `security` (the run listing does not show its `lane` input) | The caller's own file name does not matter (`pr-review-hosted.yml` on a public repository, `pr-review.yml` on a private one). The script reads the `referenced_workflows` field of the @@ -65,7 +65,7 @@ Any other class, `rate-limit` included, or a failed run with no failed job, keep After 10 such runs in a row the lane reads `local stale`. Pointer: the annotation is the reusable workflow's last step, `echo "::error title=$LANE failed::failure-class=..."`, in [ci-workflows `pr-review.yml`](https://github.com/melodic-software/ci-workflows/blob/main/.github/workflows/pr-review.yml) -and `pr-review-security.yml`; the reads are at +and `pr-review-security.yml` (`pr-review-cli.yml`'s publish step writes it after the lane id, `: failure-class=...`); the reads are at [REST API: list jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) and [REST API: list check run annotations](https://docs.github.com/en/rest/checks/runs#list-check-run-annotations). As of: 2026-10-21, read from runs 39610040287 and 39610040502. Recheck trigger: the reusable diff --git a/plugins/source-control/skills/pull-request/scripts/review-venue.sh b/plugins/source-control/skills/pull-request/scripts/review-venue.sh index 3cd82a6aa5..2b7553a584 100755 --- a/plugins/source-control/skills/pull-request/scripts/review-venue.sh +++ b/plugins/source-control/skills/pull-request/scripts/review-venue.sh @@ -3,7 +3,8 @@ # pull requests and is healthy, so a local review runs only where CI does not. # # A lane is a workflow whose pull_request runs call the org's reusable review -# workflow: ci-workflows pr-review.yml (code-review) or pr-review-security.yml +# workflow: ci-workflows pr-review.yml (code-review), or pr-review-security.yml +# or, from a caller whose path names security, pr-review-cli.yml # (security-review). It is healthy when its workflow is active and its newest # pull_request run that reached a verdict (completed, and neither skipped nor # cancelled) succeeded and was created within --max-age-days. A failed run whose @@ -68,6 +69,10 @@ REUSABLE=( '^melodic-software/ci-workflows/\.github/workflows/pr-review\.yml@' '^melodic-software/ci-workflows/\.github/workflows/pr-review-security\.yml@' ) +# pr-review-cli.yml serves either lane through an input the run listing does +# not show, so it counts for a lane only from a caller whose path names it. +CLI='^melodic-software/ci-workflows/\.github/workflows/pr-review-cli\.yml@' +CLI_CALLER=('' 'security') WORKFLOW_ID=("" "") WORKFLOW_PATH=("" "") PAGES=3 # Skipped-validation reds passed over per lane before it reads stale. @@ -84,7 +89,7 @@ skipped_validation() { for id in $ids; do [[ "$id" =~ ^[0-9]+$ ]] || die 2 "unexpected job id in run $1" ann=$(gh api "repos/$REPO/check-runs/$id/annotations?per_page=100") || die 2 "cannot read the annotations of job $id" - hit=$(jq 'any(.[]?; .annotation_level == "failure" and (.message | test("^failure-class=skipped-validation(:|$)")))' <<<"$ann") || + hit=$(jq 'any(.[]?; .annotation_level == "failure" and (.message | test("^([a-z-]+: )?failure-class=skipped-validation(:|$)")))' <<<"$ann") || die 2 "cannot parse the annotations of job $id" [[ "$hit" == true ]] || return 1 done @@ -98,8 +103,10 @@ for ((page = 1; page <= PAGES; page++)); do die 2 "cannot list the workflow runs of $REPO" for i in "${!LANES[@]}"; do [[ -n "${WORKFLOW_ID[i]}" ]] && continue - hit=$(jq -r --arg re "${REUSABLE[i]}" ' - [.workflow_runs[]? | select(any(.referenced_workflows[]?; .path | test($re)))][0] + hit=$(jq -r --arg re "${REUSABLE[i]}" --arg cli "$CLI" --arg caller "${CLI_CALLER[i]}" ' + [.workflow_runs[]? | select(any(.referenced_workflows[]?; .path | test($re)) + or ($caller != "" and (.path // "" | test($caller)) + and any(.referenced_workflows[]?; .path | test($cli))))][0] | select(. != null) | "\(.workflow_id)\t\(.path)"' <<<"$runs") || die 2 "cannot parse the workflow runs of $REPO" if [[ -n "$hit" ]]; then diff --git a/plugins/source-control/skills/pull-request/scripts/review-venue.test.sh b/plugins/source-control/skills/pull-request/scripts/review-venue.test.sh index fe02bc2833..a36f9e5cff 100755 --- a/plugins/source-control/skills/pull-request/scripts/review-venue.test.sh +++ b/plugins/source-control/skills/pull-request/scripts/review-venue.test.sh @@ -321,6 +321,33 @@ echo 'not json' >"$TEST_TMPDIR/page-1.json" run >/dev/null 2>&1 assert_exit "an unparsable run listing fails closed" 2 "$?" +# pr-review-cli.yml serves either lane; it counts for security-review only from +# a caller whose path names security, so a code-lane caller of it is not taken. +CLI='melodic-software/ci-workflows/.github/workflows/pr-review-cli.yml@83b739f625b501ad40c65dee7168d836883195e1' +reset +page 1 "$(other_run)" "$(lane_run 11 .github/workflows/pr-review-hosted.yml "$CODE")" \ + "$(lane_run 33 .github/workflows/pr-review-cli-code.yml "$CLI")" \ + "$(lane_run 22 .github/workflows/pr-review-security-hosted.yml "$CLI")" +wf 11 completed:success:2 +wf 22 completed:success:2 +out=$(run) +assert_exit "a security caller of pr-review-cli exits 0" 0 "$?" +assert_contains "a security caller of pr-review-cli covers the security lane" "$out" \ + "security-review ci healthy" +assert_contains "the security line names the pr-review-cli caller" "$out" \ + ".github/workflows/pr-review-security-hosted.yml" + +# pr-review-cli writes the class after the lane id: still a skipped-validation red. +reset +page 1 "${BOTH_PAGE[@]}" +wf 11 completed:success:2 +wf 22 completed:failure:1::skipped-validation completed:success:3 +jq '.[1].message |= "claude-security-review: " + .' "$TEST_TMPDIR/ann-$(((NEXT_RUN - 2) * 10)).json" >"$TEST_TMPDIR/ann.tmp" && + mv "$TEST_TMPDIR/ann.tmp" "$TEST_TMPDIR/ann-$(((NEXT_RUN - 2) * 10)).json" +out=$(run) +assert_contains "a lane-prefixed skipped-validation red reaches no verdict" "$out" \ + "security-review ci healthy" + run --repo 'acme app' >/dev/null 2>&1 assert_exit "a malformed --repo exits 1" 1 "$?" run --max-age-days 0 >/dev/null 2>&1 From 822de2cd90807e758c8e7f66357839c20d2b5876 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:44:33 -0400 Subject: [PATCH 4/4] fix(source-control): read a lane summary only from the REST bot identity Co-Authored-By: Claude Opus 5.5 --- ...curity-review-on-pr-review-cli-a83c06f2.md | 1 + .../scripts/babysit_review_evidence.py | 6 +++++ .../tests/test_babysit_review_evidence.py | 22 ++++++++++++------- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md index 96db59a786..0c2ee2c5c2 100644 --- a/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md +++ b/.changes/source-control/feat-security-review-on-pr-review-cli-a83c06f2.md @@ -6,3 +6,4 @@ bump: minor - The merge gate's review evidence accepts the security lane on ci-workflows' `pr-review-cli.yml`: a completion record ending in `account=fallback`, and a `github-actions[bot]` thread or summary comment whose last non-blank line is the lane's `claude-lane:` footer. A reviewed-head marker is never read a second time as a summary comment. - `review-venue.sh` finds the security lane on ci-workflows' `pr-review-cli.yml` when the caller's path contains `security`. It also reads a `: failure-class=skipped-validation` red as no verdict. +- A `github-actions` summary comment counts for its lane only when it carries the REST bot identity: login `github-actions[bot]` and type `Bot`. diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py index fc014248b1..73065e806b 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py @@ -103,6 +103,7 @@ def _lane_comment(lane: str, login: object, body: str) -> bool: return tag is None or tag[1] == lane return login in ACTIONS_LOGINS and _footer_lane(body) == lane + # The lane whose findings hold the merge only at its top tier, CRITICAL # (plugins/review/skills/security-review/SKILL.md, "Reporting"): each inline # comment carries one `severity:` line, and the summary one @@ -478,6 +479,11 @@ def _summary_comments( created = _time(comment.get("created_at")) if MARKER_RE.match(body.split("\n", 1)[0]): continue + # A REST comment always carries the bot's suffixed login and type. + if user.get("login") in ACTIONS_LOGINS and ( + user.get("login") != MARKER_AUTHOR or user.get("type") != "Bot" + ): + continue if _lane_comment(lane, user.get("login"), body) and created is not None: found.append((created, body)) return found diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py index a03e1bad7d..1698ed293f 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_review_evidence.py @@ -1787,21 +1787,27 @@ def test_a_github_actions_summary_comment_counts_only_with_the_lanes_footer( ) -> None: runs = [whole("claude-review", S), whole(self.SEC, S, n=2, k=0)] - def summary(body: str) -> dict[str, Any]: + def summary(body: str, user: dict[str, str] | None = None) -> dict[str, Any]: return { "id": next(_ids), - "user": {"login": "github-actions[bot]", "type": "Bot"}, + "user": user or {"login": "github-actions[bot]", "type": "Bot"}, "created_at": "2026-01-01T00:30:00Z", "body": body, } - for body, blocking in ( - (f"summary\n\ncritical-findings: 0\n\nclaude-lane: {self.SEC}", 0), - ("summary\n\ncritical-findings: 0", 2), - (f"claude-lane: {self.SEC}\n\ncritical-findings: 0", 2), + footed = f"summary\n\ncritical-findings: 0\n\nclaude-lane: {self.SEC}" + # REST names the bot `github-actions[bot]` with type Bot; a bare login + # or a User account under either login is not the lane's. + for body, user, blocking in ( + (footed, None, 0), + ("summary\n\ncritical-findings: 0", None, 2), + (f"claude-lane: {self.SEC}\n\ncritical-findings: 0", None, 2), + (footed, {"login": "github-actions", "type": "User"}, 2), + (footed, {"login": "github-actions", "type": "Bot"}, 2), + (footed, {"login": "github-actions[bot]", "type": "User"}, 2), ): - with self.subTest(body=body): - comments = [*both_markers(), summary(body)] + with self.subTest(body=body, user=user): + comments = [*both_markers(), summary(body, user)] result = self.bind(runs, comments=comments)[self.SEC] self.assertEqual(result.blocking_findings, blocking)