From 2b8f6a9042af9324b434b291f96cce7dbc9f92d3 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Mon, 26 Oct 2026 16:44:43 +0100 Subject: [PATCH 1/4] feat: add a label when the review is ready for a human --- README.md | 12 +- llp/0008-sources-and-reporters.explainer.md | 2 + src/__tests__/ready-label.test.ts | 197 ++++++++++++++++++++ src/commands/ci.ts | 19 +- src/commands/post-review.ts | 1 + src/commands/review.ts | 1 + src/config/schema.ts | 7 + src/reporters/github.ts | 96 ++++++++++ templates/config.jsonc | 4 + 9 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/ready-label.test.ts diff --git a/README.md b/README.md index dad1c12..fd8e53c 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,8 @@ per-repo `noise.additionalIgnores`. "noise": { "additionalIgnores": ["packages/*/build/**"] }, "review": { "trigger": "all", // which PRs `ecr ci` reviews: "all" "label": "ai-review", // (default, except ai-review:skip) or - "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs) + "skipLabel": "ai-review:skip", // "label" (only labeled PRs) + "readyForHumanLabel": "ready for human" }, // optional, see below "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment "auth": { "providers": { @@ -588,6 +589,15 @@ per-repo `noise.additionalIgnores`. } ``` +`review.readyForHumanLabel` (off by default) names a label that `ecr ci` adds to +the PR when the review decision is "Ready for human review", with or without +comments. A later review that requests changes, or a review that fails, removes +it, but only when the reviewer's account added it last: a label a person added +stays. It works in the single comment mode; per-scope comments have no one +decision for the PR, so that mode sets no label. `/dismiss` does not change the +decision, so it does not change the label. The workflow token needs +`issues: write`. +
diff --git a/llp/0008-sources-and-reporters.explainer.md b/llp/0008-sources-and-reporters.explainer.md index 6eb87a1..59557a8 100644 --- a/llp/0008-sources-and-reporters.explainer.md +++ b/llp/0008-sources-and-reporters.explainer.md @@ -81,6 +81,8 @@ The reporter maintains exactly one PR comment, plus optional inline threads (see **Links fail soft.** `linkContextAsync` can be prebuilt and injected once so a CI fan-out builds one diff-lines + base-SHA context for all scopes instead of one pair of `gh` calls per scope — the reporter-side mirror of `memoizeSource` [observed] ([github.ts:33-37, 200-205](../src/reporters/github.ts)). Both underlying fetches fail soft: a missing diff index degrades in-diff findings to plain-text `file:line`, a missing base SHA degrades out-of-diff findings the same way — never a dead link, never a hard failure of the whole report [observed] ([github.ts:194-235](../src/reporters/github.ts); commits f9fecd5, 9446b42). This follows the repo rule that `ecr ci` must never fail a PR's checks on reviewer error [observed] (AGENTS.md: "`ecr ci` must never fail a PR's checks — reviewer errors degrade gracefully"). +**The ready label follows the decision, and the bot removes only its own.** With `review.readyForHumanLabel` set, `report` and `reportAggregate` call `syncReadyLabel` after the comment is posted. `readyForHuman` is true when every review completed and the worst decision is `approve` or `approve_with_comments`. A ready review adds the label when it is missing. A review that is not ready removes it only when the newest `labeled` event for that label on the PR has the reporter's own login as its actor (`addedLabelLast`), so a label a person added is never removed, and no extra state is stored in the comment. Label failures only log: the comment is already up [observed] ([github.ts](../src/reporters/github.ts) `syncReadyLabel`, `readyForHuman`; tests `src/__tests__/ready-label.test.ts`). Only the single comment labels: in per-scope mode there is no one decision for the PR, and `ecr ci` logs that it sets no label (`runRoutedCi` in [ci.ts](../src/commands/ci.ts)). `/dismiss` re-renders with the stored decision, so it does not change the label. + ## Inline Threads With `inline.enabled`, `syncInline` converges one inline thread per in-diff finding: `planInlineSync` decides which threads to create, patch, stub or delete, and the executor runs the plan [observed] ([github.ts](../src/reporters/github.ts) `planInlineSync`, `syncInline`). diff --git a/src/__tests__/ready-label.test.ts b/src/__tests__/ready-label.test.ts new file mode 100644 index 0000000..5a7e69d --- /dev/null +++ b/src/__tests__/ready-label.test.ts @@ -0,0 +1,197 @@ +import { test, expect, afterEach, beforeEach } from "bun:test"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path, { delimiter } from "node:path"; + +import { resetTrustedToolCache } from "../core/exec.js"; +import { ReviewConfigSchema } from "../config/schema.js"; +import type { CoordinatorOutput } from "../core/schema.js"; +import { GitHubReporter, readyForHuman } from "../reporters/github.js"; + +const TAG = "expo-ai-code-reviewer"; +const LABEL = "ready for human"; + +const review = (over: Partial = {}): CoordinatorOutput => ({ + decision: "approve", + findings: [], + summary: "s", + incomplete: [], + ...over, +}); + +test("readyForHuman: both 'Ready for human review' decisions, every review complete", () => { + expect(readyForHuman([review()])).toBe(true); + expect(readyForHuman([review({ decision: "approve_with_comments" })])).toBe(true); + expect(readyForHuman([review({ decision: "request_changes" })])).toBe(false); + expect(readyForHuman([review({ couldNotComplete: true })])).toBe(false); + // An aggregate is as ready as its worst scope. + expect(readyForHuman([review(), review({ decision: "request_changes" })])).toBe(false); + expect(readyForHuman([review(), review({ couldNotComplete: true })])).toBe(false); + expect(readyForHuman([])).toBe(false); +}); + +test("config: review.readyForHumanLabel is optional and never empty", () => { + expect(ReviewConfigSchema.parse({}).review.readyForHumanLabel).toBeUndefined(); + expect( + ReviewConfigSchema.parse({ review: { readyForHumanLabel: LABEL } }).review.readyForHumanLabel, + ).toBe(LABEL); + expect(ReviewConfigSchema.safeParse({ review: { readyForHumanLabel: "" } }).success).toBe(false); +}); + +// ---- the reporter adds and removes the label through a fake `gh` on PATH ---- + +// A fake `gh` that logs each call. The PR's labels come from labels.txt (one per +// line) and its issue events from events.json. FAKE_GH_LABEL_ERROR makes every +// label call fail. +const FAKE_GH = `#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_GH_DIR/calls.log" +case "$*" in + *"/labels"*) + if [ -n "$FAKE_GH_LABEL_ERROR" ]; then echo "$FAKE_GH_LABEL_ERROR" >&2; exit 1; fi + case "$*" in + *"-X GET "*) if [ -f "$FAKE_GH_DIR/labels.txt" ]; then cat "$FAKE_GH_DIR/labels.txt"; fi ;; + *) printf '[]' ;; + esac ;; + *"/events"*) + if [ -f "$FAKE_GH_DIR/events.json" ]; then cat "$FAKE_GH_DIR/events.json"; else printf '[]'; fi ;; + *"-X GET "*"/comments"*) printf '[]' ;; + *"-X POST "*"/comments --input "*) printf '{"id": 1}' ;; + *) echo "unexpected gh call: $*" >&2; exit 1 ;; +esac +`; + +let dir = ""; +const saved: Record = {}; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "ecr-ready-label-")); + await writeFile(path.join(dir, "gh"), FAKE_GH, "utf8"); + await chmod(path.join(dir, "gh"), 0o755); + await writeFile(path.join(dir, "calls.log"), "", "utf8"); + for (const name of ["PATH", "FAKE_GH_DIR", "FAKE_GH_LABEL_ERROR"]) { + saved[name] = process.env[name]; + } + process.env.PATH = `${dir}${delimiter}${saved.PATH ?? ""}`; + process.env.FAKE_GH_DIR = dir; + delete process.env.FAKE_GH_LABEL_ERROR; + resetTrustedToolCache(); +}); + +afterEach(async () => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + resetTrustedToolCache(); + await rm(dir, { recursive: true, force: true }); +}); + +// Each test uses its own PR number: the reporter caches some reads per PR. +async function post( + prNumber: number, + posted: CoordinatorOutput, + opts: { labels?: string[]; labeledBy?: string; readyForHumanLabel?: string | null } = {}, +): Promise { + if (opts.labels) { + await writeFile(path.join(dir, "labels.txt"), opts.labels.join("\n"), "utf8"); + } + if (opts.labeledBy) { + await writeFile( + path.join(dir, "events.json"), + JSON.stringify([ + { event: "labeled", label: { name: LABEL }, actor: { login: "someone-else" } }, + { event: "unlabeled", label: { name: LABEL }, actor: { login: "someone-else" } }, + { event: "labeled", label: { name: LABEL }, actor: { login: opts.labeledBy } }, + ]), + "utf8", + ); + } + const reporter = new GitHubReporter({ + prNumber, + repo: "o/r", + commentTag: TAG, + ownLogin: "ecr-bot", + linkContext: { repo: "o/r", prNumber }, + ...(opts.readyForHumanLabel === null + ? {} + : { readyForHumanLabel: opts.readyForHumanLabel ?? LABEL }), + }); + await reporter.report(posted); + return (await readFile(path.join(dir, "calls.log"), "utf8")).trim().split("\n"); +} + +const labelCalls = (calls: string[]): string[] => + calls.filter((call) => call.includes("/labels") && !call.includes("-X GET")); + +test("report: a ready review adds the label", async () => { + const calls = await post(901, review({ decision: "approve_with_comments" })); + expect(labelCalls(calls)).toEqual([ + `api -X POST repos/o/r/issues/901/labels -f labels[]=${LABEL}`, + ]); +}); + +test("report: a ready review leaves a label that is already there", async () => { + const calls = await post(902, review(), { labels: [LABEL] }); + expect(labelCalls(calls)).toEqual([]); +}); + +test("report: a review that requests changes removes the label the bot added", async () => { + const calls = await post(903, review({ decision: "request_changes" }), { + labels: [LABEL], + labeledBy: "ecr-bot", + }); + expect(labelCalls(calls)).toEqual([ + "api -X DELETE repos/o/r/issues/903/labels/ready%20for%20human", + ]); +}); + +test("report: a failed review keeps a label that a person added", async () => { + const calls = await post(904, review({ couldNotComplete: true }), { + labels: [LABEL], + labeledBy: "a-maintainer", + }); + expect(labelCalls(calls)).toEqual([]); +}); + +test("report: without the option, no label is read or changed", async () => { + const calls = await post(905, review(), { readyForHumanLabel: null }); + expect(calls.filter((call) => call.includes("/labels") || call.includes("/events"))).toEqual([]); +}); + +test("report: a label failure does not fail the report", async () => { + process.env.FAKE_GH_LABEL_ERROR = "HTTP 403"; + const calls = await post(906, review()); + // The comment was posted before the label call. + expect(calls.some((call) => call.includes("-X POST repos/o/r/issues/906/comments"))).toBe(true); +}); + +test("reportAggregate: the worst scope decides, so one scope that requests changes removes it", async () => { + await writeFile(path.join(dir, "labels.txt"), LABEL, "utf8"); + await writeFile( + path.join(dir, "events.json"), + JSON.stringify([{ event: "labeled", label: { name: LABEL }, actor: { login: "ecr-bot" } }]), + "utf8", + ); + const reporter = new GitHubReporter({ + prNumber: 907, + repo: "o/r", + commentTag: TAG, + ownLogin: "ecr-bot", + linkContext: { repo: "o/r", prNumber: 907 }, + readyForHumanLabel: LABEL, + }); + await reporter.reportAggregate( + [ + { scope: "ios", isDefault: false, review: review() }, + { scope: "web", isDefault: false, review: review({ decision: "request_changes" }) }, + ], + [], + ); + const calls = (await readFile(path.join(dir, "calls.log"), "utf8")).trim().split("\n"); + expect(labelCalls(calls)).toEqual([ + "api -X DELETE repos/o/r/issues/907/labels/ready%20for%20human", + ]); +}); diff --git a/src/commands/ci.ts b/src/commands/ci.ts index 93b02f8..38728a6 100644 --- a/src/commands/ci.ts +++ b/src/commands/ci.ts @@ -565,6 +565,7 @@ async function runLegacyCi( cwd, feedback: config.feedback, inline: config.inline, + readyForHumanLabel: config.review.readyForHumanLabel, headSha, }); @@ -888,7 +889,11 @@ async function runRoutedCi( const stackWalk = resolveStackWalk(rootConfig.stack, noStackAware); const stackConfirm = resolveStackConfirm(rootConfig.stack, noStackAware); - const reporterFor = (tag: string, withLink = false): GitHubReporter => + const reporterFor = ( + tag: string, + withLink = false, + readyForHumanLabel?: string, + ): GitHubReporter => new GitHubReporter({ prNumber, repo, @@ -900,6 +905,7 @@ async function runRoutedCi( // no explicit records match replies itself (annotate mode) at report time. feedback: rootConfig.feedback, inline: rootConfig.inline, + readyForHumanLabel, headSha, }); @@ -909,7 +915,16 @@ async function runRoutedCi( // `fetchAllComments` TTL cache) across the whole run — a fresh reporter per // scope would otherwise re-fetch the paginated comment list and re-resolve // the bot login once per scope for the identical comment. - const singleModeReporter = mode === "single" ? reporterFor(rootTag, true) : undefined; + // Only the single comment has one decision for the whole PR, so only it labels. + const singleModeReporter = + mode === "single" + ? reporterFor(rootTag, true, rootConfig.review.readyForHumanLabel) + : undefined; + if (mode !== "single" && rootConfig.review.readyForHumanLabel) { + process.stderr.write( + "CI reviewer: review.readyForHumanLabel applies to the single comment mode only; no label is set in per-scope mode.\n", + ); + } // Per-scope mode has the same property, one comment tag at a time: a scope's feedback // seam reads the very comment that scope's review is posted to, so both must use the diff --git a/src/commands/post-review.ts b/src/commands/post-review.ts index cde9552..f2a0e92 100644 --- a/src/commands/post-review.ts +++ b/src/commands/post-review.ts @@ -148,6 +148,7 @@ export async function postReviewCommand(argv: string[]): Promise { cwd, feedback: config.feedback, inline: config.inline, + readyForHumanLabel: config.review.readyForHumanLabel, headSha, }); const result = await publishDeferredReview(reporter, artifact, async () => { diff --git a/src/commands/review.ts b/src/commands/review.ts index 0278196..19fc5ae 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -365,6 +365,7 @@ export async function reviewCommand(argv: string[]): Promise { cwd, feedback: config.feedback, inline: config.inline, + readyForHumanLabel: config.review.readyForHumanLabel, headSha, }) : null; diff --git a/src/config/schema.ts b/src/config/schema.ts index 19c3947..4ee0857 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -160,6 +160,12 @@ export const ReviewConfigSchema = z.object({ // Opt a single PR out of review. A label (not a config flag) because labels // are write-gated to maintainers — a PR author can't add one to dodge review. skipLabel: z.string().default("ai-review:skip"), + // A label `ecr ci` adds when the review decision is "Ready for human review", + // with or without comments, and removes when a later review requests changes or + // fails. It only removes the label when it added it. Unset (the default) turns + // labelling off. Single-comment mode only: per-scope comments have no one + // decision for the PR. + readyForHumanLabel: z.string().min(1).optional(), }) .default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }), // Stack-aware requalification: walk the OPEN PRs stacked on top of this one and @@ -508,6 +514,7 @@ export interface LoadedConfig { trigger: "all" | "label"; label: string; skipLabel: string; + readyForHumanLabel?: string; }; /** Root-only stack-aware requalification settings (see ReviewConfigSchema.stack). */ stack: { diff --git a/src/reporters/github.ts b/src/reporters/github.ts index 263921c..d5d8ec1 100644 --- a/src/reporters/github.ts +++ b/src/reporters/github.ts @@ -16,6 +16,7 @@ import { renderAggregateMarkdown, renderInlineCommentBody, renderMarkdown, + worstDecision, } from "../core/render.js"; import type { LinkContext, ReviewState, ScopeReviewResult } from "../core/render.js"; import { matchReplies } from "../core/responses.js"; @@ -71,6 +72,11 @@ export interface GitHubReporterOptions { * tag's inline comments so a comment-mode switch or a disable cleans up. */ inline?: LoadedConfig["inline"]; + /** + * `review.readyForHumanLabel`: the label to add when a posted review is ready for a + * human (see readyForHuman), and to remove when it is not. Unset ⇒ no labels. + */ + readyForHumanLabel?: string; /** * The head commit this run reviews (the PR's immutable head OID, from the source's * metadata). Feedback merging binds a stored verdict to the source it judged, so a @@ -397,6 +403,19 @@ function withInlineUrls(link: LinkContext, inlineUrls: Map): Lin return inlineUrls.size > 0 ? { ...link, inlineUrls } : link; } +/** + * Whether a posted review is ready for a human: every review ran, and the decision + * (the worst one, for an aggregate) is "Ready for human review", with or without + * comments. Exported for tests. + */ +export function readyForHuman(reviews: CoordinatorOutput[]): boolean { + return ( + reviews.length > 0 && + reviews.every((review) => review.couldNotComplete !== true) && + worstDecision(reviews.map((review) => review.decision)) !== "request_changes" + ); +} + // @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged /** * Carry a decision already recorded in the prior comment state onto the freshly @@ -758,6 +777,7 @@ export class GitHubReporter implements Reporter { state?.mentioned, ), ); + await this.syncReadyLabel(readyForHuman([review])); } /** @@ -808,6 +828,7 @@ export class GitHubReporter implements Reporter { pins, ), ); + await this.syncReadyLabel(readyForHuman(results.map((result) => result.review))); } /** @@ -1965,6 +1986,81 @@ export class GitHubReporter implements Reporter { ); } + // @ref LLP 0008#comment-lifecycle [implements] — the ready label follows the decision; only our own label is removed + /** + * Add `readyForHumanLabel` when `ready`, or remove it when not. The label is + * removed only when this reporter's account added it last, so a label a person + * added stays. Fail-soft: the review comment is already posted, and a label + * failure only logs. + */ + private async syncReadyLabel(ready: boolean): Promise { + const label = this.options.readyForHumanLabel; + if (!label) { + return; + } + try { + const gh = await resolveTrustedTool("gh"); + const issue = `repos/${this.options.repo}/issues/${this.options.prNumber}`; + const { stdout } = await run( + gh, + ["api", "-X", "GET", `${issue}/labels`, "-f", "per_page=100", "--jq", ".[].name"], + { cwd: this.options.cwd }, + ); + const present = stdout.split("\n").some((name) => name === label); + if (ready && !present) { + await run(gh, ["api", "-X", "POST", `${issue}/labels`, "-f", `labels[]=${label}`], { + cwd: this.options.cwd, + }); + } else if (!ready && present && (await this.addedLabelLast(label))) { + await run(gh, ["api", "-X", "DELETE", `${issue}/labels/${encodeURIComponent(label)}`], { + cwd: this.options.cwd, + }); + } + } catch (error) { + process.stderr.write(`ready label: update skipped: ${errorMessage(error)}\n`); + } + } + + /** Whether the newest `labeled` event for `label` on this PR is from our account. */ + private async addedLabelLast(label: string): Promise { + const ownLogin = await this.resolveOwnLogin(); + if (!ownLogin) { + return false; + } + const gh = await resolveTrustedTool("gh"); + let actor: string | undefined; + for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) { + const { stdout } = await run( + gh, + [ + "api", + "-X", + "GET", + `repos/${this.options.repo}/issues/${this.options.prNumber}/events`, + "-f", + "per_page=100", + "-f", + `page=${page}`, + ], + { cwd: this.options.cwd }, + ); + const events = JSON.parse(stdout) as Array<{ + event?: string; + label?: { name?: string }; + actor?: { login?: string } | null; + }>; + for (const event of events) { + if (event.event === "labeled" && event.label?.name === label) { + actor = event.actor?.login; + } + } + if (events.length < 100) { + break; + } + } + return actor === ownLogin; + } + private async deleteComment(commentId: number): Promise { this.invalidateComments(); const gh = await resolveTrustedTool("gh"); diff --git a/templates/config.jsonc b/templates/config.jsonc index 163dbd3..4502460 100644 --- a/templates/config.jsonc +++ b/templates/config.jsonc @@ -68,6 +68,10 @@ // "trigger": "label" — review only PRs labeled `label` (or `label:`). // The `skipLabel` is write-gated (only maintainers can add labels), so a PR // author can't opt their own PR out. + // `readyForHumanLabel` (optional, off by default): a label `ecr ci` adds when the + // decision is "Ready for human review" (with or without comments), and removes + // when a later review requests changes or fails. It only removes the label when + // it added it. Single-comment mode only. "review": { "trigger": "all", "label": "ai-review", "skipLabel": "ai-review:skip" }, // A maintainer comment containing this marker skips the CI review. From 1d08fe08ed0f5fc6d9f6a5b0913dbd1889973e27 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Mon, 26 Oct 2026 17:08:05 +0100 Subject: [PATCH 2/4] fix: respect a person's label changes, and remove the label when the review goes away --- README.md | 31 +++- llp/0008-sources-and-reporters.explainer.md | 2 +- src/__tests__/deferred-review.test.ts | 6 + src/__tests__/ready-label.test.ts | 195 +++++++++++++++----- src/commands/ci.ts | 4 +- src/config/schema.ts | 15 ++ src/core/deferred-review.ts | 2 + src/reporters/github.ts | 71 ++++--- templates/config.jsonc | 7 +- 9 files changed, 254 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index fd8e53c..bd09056 100644 --- a/README.md +++ b/README.md @@ -589,14 +589,29 @@ per-repo `noise.additionalIgnores`. } ``` -`review.readyForHumanLabel` (off by default) names a label that `ecr ci` adds to -the PR when the review decision is "Ready for human review", with or without -comments. A later review that requests changes, or a review that fails, removes -it, but only when the reviewer's account added it last: a label a person added -stays. It works in the single comment mode; per-scope comments have no one -decision for the PR, so that mode sets no label. `/dismiss` does not change the -decision, so it does not change the label. The workflow token needs -`issues: write`. +`review.readyForHumanLabel` (off by default) names a label that `ecr ci`, +`ecr post-review` and `ecr review --pr --post` add to the PR when the review +is ready for a human: the decision is "Ready for human review", with or without +comments, and every pass finished (partial coverage gets no label). + +- A later review that requests changes or does not finish, a `/skip-review`, or a + run that deletes the review comment removes the label, but only when the + reviewer's account added it last. A label a person added stays. +- When a person removes the label, the reviewer does not add it again on that PR. +- It works in the single comment mode. Per-scope comments have no one decision + for the PR, so that mode sets no label. +- `/dismiss` does not change the decision, so it does not change the label. +- When the reviewer fails before it can load the config, it leaves the label as + it is. +- The reviewer knows its own labels by login: `gh api user`, or + `github-actions[bot]` for the default `GITHUB_TOKEN`. With a GitHub App token + the login is not known, so the reviewer never removes the label. With a personal + token, the reviewer treats labels that person added as its own. +- The decision comes from a model that reads untrusted PR content, so a prompt + injection can earn the label. Never use it as a merge gate or in branch + protection. +- The label must differ from `review.label`, `review.skipLabel` and every + `
diff --git a/llp/0008-sources-and-reporters.explainer.md b/llp/0008-sources-and-reporters.explainer.md index 59557a8..21c161f 100644 --- a/llp/0008-sources-and-reporters.explainer.md +++ b/llp/0008-sources-and-reporters.explainer.md @@ -81,7 +81,7 @@ The reporter maintains exactly one PR comment, plus optional inline threads (see **Links fail soft.** `linkContextAsync` can be prebuilt and injected once so a CI fan-out builds one diff-lines + base-SHA context for all scopes instead of one pair of `gh` calls per scope — the reporter-side mirror of `memoizeSource` [observed] ([github.ts:33-37, 200-205](../src/reporters/github.ts)). Both underlying fetches fail soft: a missing diff index degrades in-diff findings to plain-text `file:line`, a missing base SHA degrades out-of-diff findings the same way — never a dead link, never a hard failure of the whole report [observed] ([github.ts:194-235](../src/reporters/github.ts); commits f9fecd5, 9446b42). This follows the repo rule that `ecr ci` must never fail a PR's checks on reviewer error [observed] (AGENTS.md: "`ecr ci` must never fail a PR's checks — reviewer errors degrade gracefully"). -**The ready label follows the decision, and the bot removes only its own.** With `review.readyForHumanLabel` set, `report` and `reportAggregate` call `syncReadyLabel` after the comment is posted. `readyForHuman` is true when every review completed and the worst decision is `approve` or `approve_with_comments`. A ready review adds the label when it is missing. A review that is not ready removes it only when the newest `labeled` event for that label on the PR has the reporter's own login as its actor (`addedLabelLast`), so a label a person added is never removed, and no extra state is stored in the comment. Label failures only log: the comment is already up [observed] ([github.ts](../src/reporters/github.ts) `syncReadyLabel`, `readyForHuman`; tests `src/__tests__/ready-label.test.ts`). Only the single comment labels: in per-scope mode there is no one decision for the PR, and `ecr ci` logs that it sets no label (`runRoutedCi` in [ci.ts](../src/commands/ci.ts)). `/dismiss` re-renders with the stored decision, so it does not change the label. +**The ready label follows the decision, and the bot removes only its own.** With `review.readyForHumanLabel` set, `report` and `reportAggregate` call `syncReadyLabel` after the comment is posted, and `postSkipNote` and `clear` call it with "not ready". `readyForHuman` is true when every review completed with full coverage (`incomplete` is empty) and the worst decision is `approve` or `approve_with_comments`. The label's own history on the PR, the newest `labeled` or `unlabeled` event for it (`lastLabelEvent`; the events API lists the oldest first), decides what is ours: a review that is not ready removes the label only when the newest event is our account's `labeled`, and a ready review does not add it again when the newest event is someone else's `unlabeled`, so a person's add or removal always wins. No extra state is stored in the comment. "Our account" is `resolveOwnLogin`: `gh api user`, or `github-actions[bot]` for the default `GITHUB_TOKEN`. A GitHub App token's actor is expected to be `[bot]` [not verified], which that does not match, so such a setup never removes the label, and a personal token treats that person's labels as ours. Label failures only log: the comment is already up [observed] ([github.ts](../src/reporters/github.ts) `syncReadyLabel`, `readyForHuman`, `lastLabelEvent`; tests `src/__tests__/ready-label.test.ts`). Only the single comment labels: in per-scope mode there is no one decision for the PR, `ecr ci` logs that it sets no label, and the root-tag `clear` of a mode switch removes ours (`runRoutedCi` in [ci.ts](../src/commands/ci.ts)). `/dismiss` re-renders with the stored decision, so it does not change the label, and the config check rejects a label that is also `review.label`, `review.skipLabel` or a `