mirror of
https://github.com/expo/code-review-cli.git
synced 2026-11-15 03:38:09 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e08db4568 | ||
|
|
6785412cfe |
No files matched your search
@@ -70,9 +70,9 @@ excludes) — see [the mixed setup](#other-providers) below. Prefer
|
||||
# Review working-tree changes; prints here, posts nothing
|
||||
ecr review
|
||||
# Review a GitHub PR by number (preview only)
|
||||
ecr review --pr 4057
|
||||
ecr review --pr 123
|
||||
# …and post it as the PR comment
|
||||
ecr review --pr 4057 --post
|
||||
ecr review --pr 123 --post
|
||||
```
|
||||
|
||||
Options (most to least common):
|
||||
@@ -141,9 +141,9 @@ your-monorepo/
|
||||
routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
|
||||
config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
|
||||
shared.md coordinator.md agents/
|
||||
server/
|
||||
www/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # www team
|
||||
website/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # website team
|
||||
apps/
|
||||
api/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
|
||||
web/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
|
||||
.github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
|
||||
```
|
||||
|
||||
@@ -160,21 +160,21 @@ your-monorepo/
|
||||
"comment": "single", // "single" = one aggregated comment (default) | "per-scope"
|
||||
// Ordered; the LAST matching scope wins per changed file (CODEOWNERS discipline).
|
||||
"scopes": [
|
||||
{ "name": "default", "paths": ["**/*"], "config": "." },
|
||||
{ "name": "server-www", "paths": ["server/www/**"], "config": "server/www" },
|
||||
{ "name": "server-website", "paths": ["server/website/**"], "config": "server/website" }
|
||||
{ "name": "default", "paths": ["**/*"], "config": "." },
|
||||
{ "name": "apps-api", "paths": ["apps/api/**"], "config": "apps/api" },
|
||||
{ "name": "apps-web", "paths": ["apps/web/**"], "config": "apps/web" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// server/www/.expo-code-review/config.jsonc (the www team owns this)
|
||||
// apps/api/.expo-code-review/config.jsonc (the api team owns this)
|
||||
{
|
||||
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
||||
"model": "openai/gpt-5.5",
|
||||
"policy": { "includeSuggestions": false },
|
||||
"noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
|
||||
// shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
|
||||
"noise": { "additionalIgnores": ["apps/api/**/__generated__/**"] }
|
||||
// shared.md, coordinator.md, agents/*.md live beside this file — the api team's roster.
|
||||
}
|
||||
```
|
||||
|
||||
@@ -248,7 +248,7 @@ your-monorepo/
|
||||
security warning and will be removed on a minor boundary.
|
||||
|
||||
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
|
||||
(the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
|
||||
(the single authoritative router) and `/apps/api/.expo-code-review/ @your-api-team`
|
||||
(each team owns only its own scope dir). Rerouting globs is gated behind infra review.
|
||||
|
||||
---
|
||||
@@ -544,7 +544,7 @@ highest-value part — "repeat offenders": findings whose title recurred across
|
||||
PRs and drew a reply every single time.
|
||||
|
||||
```bash
|
||||
ecr feedback --repo expo/eas-cli --limit 100 --since 2026-06-01
|
||||
ecr feedback --repo your-org/your-repo --limit 100 --since 2026-06-01
|
||||
ecr feedback --as my-review-bot # if CI posts under a PAT/app identity
|
||||
ecr feedback --json # for scripting
|
||||
```
|
||||
|
||||
@@ -279,9 +279,21 @@ model could be argued out of. `feedbackApplied` is the single function that
|
||||
decides whether a reply clears a finding, and it returns `false` unconditionally
|
||||
for `finding.severity === "critical"` and for any finding whose category is in
|
||||
`HARD_FLOOR_CATEGORIES` (`secrets`, `security`) — a constant the configured
|
||||
`protectedCategories` can only ADD to, never shrink, since both checks run
|
||||
independently [observed] ([adjudicate.ts](../src/core/adjudicate.ts)
|
||||
`HARD_FLOOR_CATEGORIES`, `feedbackApplied`). Above those floors, dismissal is
|
||||
`protectedCategories` can only ADD to, never shrink, since that check runs
|
||||
independently of the floor [observed] ([adjudicate.ts](../src/core/adjudicate.ts)
|
||||
`HARD_FLOOR_CATEGORIES`, `hardFloored`, `feedbackApplied`). The floor is also the
|
||||
reason such a reply is never JUDGED: `hardFloored` is one function read by both
|
||||
`feedbackApplied` and `adjudicateFeedback`'s `toJudge` filter, so the two can never
|
||||
drift into a model call whose verdict the floor would then discard — a call that also
|
||||
consumed one of the `maxAdjudications` slots a clearable finding's reply could have
|
||||
used. The skip is deliberately NOT counted in `skipped`: that figure surfaces as "N
|
||||
left unjudged over the cap", and raising the cap would buy back no coverage here,
|
||||
so counting it would report a gap that does not exist. The config-tunable exclusions
|
||||
(`protectedCategories`, `dismiss`) are deliberately left IN the judged set — they can
|
||||
change, and `applied` is recomputed under the current config on every run, so a stored
|
||||
verdict there becomes useful the moment a repo widens its policy
|
||||
[observed] ([adjudicate.ts](../src/core/adjudicate.ts) `adjudicateFeedback`, the
|
||||
`!hardFloored(item.finding)` term of `toJudge`). Above those floors, dismissal is
|
||||
gated by `dismiss` alone: `"never"` (the shipped default) short-circuits to
|
||||
`false` before any floor is even checked, so nothing clears a finding until a
|
||||
repo explicitly opts in. `mode` is deliberately a separate axis — it selects
|
||||
@@ -310,9 +322,9 @@ clears" rather than open [observed] ([responses.ts](../src/core/responses.ts)
|
||||
|
||||
Re-deriving it costs a `gh pr view`, and `mode: "annotate"` is the shipped
|
||||
default, so that call would otherwise land on EVERY report of every adopting
|
||||
repo — multiplied per scope in routed CI, which builds a fresh reporter per
|
||||
scope plus one for the seam. Two things bound it, and neither weakens the fail-
|
||||
closed rule. The flag gates exactly one branch, so it is resolved only when that
|
||||
repo — multiplied per scope in routed CI, which builds one reporter per scope
|
||||
comment plus one for the aggregate comment. Two things bound it, and neither weakens
|
||||
the fail-closed rule. The flag gates exactly one branch, so it is resolved only when that
|
||||
branch is reachable: `feedbackNeedsPrAuthor` returns true for
|
||||
`dismiss: "adjudicated"` and nothing else, and a skipped lookup reads the same
|
||||
as a failed one — `author: false`, clears nothing
|
||||
@@ -338,6 +350,17 @@ in-flight entry, while the entry is still what concurrent scopes join
|
||||
[observed] ([github.ts](../src/reporters/github.ts) `sharedPrAuthor`, the
|
||||
`forget` helper).
|
||||
|
||||
The other half of that cost is per instance and stays per instance: the paginated
|
||||
comment list (a 30s TTL cache) and the resolved bot login. So the rule in `ci.ts` is
|
||||
**one reporter per comment, not one per call site** — a scope's feedback seam reads the
|
||||
very comment that scope's review is posted to, so a second instance for the same tag
|
||||
re-fetches the identical list and re-resolves the login. `comment:'single'` mode shares
|
||||
`singleModeReporter` across every scope's seam and the aggregate post; per-scope mode
|
||||
memoizes one reporter per scope name and uses it for both that scope's seam and its
|
||||
`report()` [observed] ([ci.ts](../src/commands/ci.ts) `singleModeReporter`,
|
||||
`memoizeByScope`). The link context the report needs is inert for the seam
|
||||
(`matchAdjudicationItems` renders nothing), so one link-carrying reporter serves both.
|
||||
|
||||
`adjudicateFeedback` re-derives `applied` for **every** record on every call —
|
||||
including ones a model never touched this run (already-decided verdicts from a
|
||||
prior run are skipped to avoid re-spending the budget on the same words,
|
||||
|
||||
@@ -185,9 +185,17 @@ test("feedbackApplied: an unclearedByHuman record never clears (a human restored
|
||||
|
||||
// ---- adjudicateFeedback: recompute without model calls ----
|
||||
|
||||
// A record every toJudge gate lets through (the PR author, cites the id, no prior
|
||||
// verdict, no `/undismiss` pin) paired with real reply text. Each test below then varies
|
||||
// exactly ONE thing, so the gate it names is the only reason nothing is judged — a
|
||||
// fixture excluded by a second gate would stay green with the named gate deleted.
|
||||
const eligibleRecord = (over: Partial<FeedbackRecord> = {}): FeedbackRecord =>
|
||||
record({ author: true, citedId: true, ...over });
|
||||
const REBUTTAL = "false positive, sanitizePath runs below";
|
||||
|
||||
test("adjudicateFeedback: mode:'off' makes no model calls and applies nothing", async () => {
|
||||
const items: AdjudicationItem[] = [
|
||||
{ finding: finding(), record: record({ maintainer: true }), replyText: "trust me" },
|
||||
{ finding: finding(), record: eligibleRecord(), replyText: REBUTTAL },
|
||||
];
|
||||
const out = await adjudicateFeedback(noHandle, items, config({ mode: "off" }));
|
||||
expect(out.adjudicated).toBe(0);
|
||||
@@ -196,6 +204,36 @@ test("adjudicateFeedback: mode:'off' makes no model calls and applies nothing",
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
});
|
||||
|
||||
// `mode: "annotate"` + an opted-in `dismiss` wires the seam with no model in it
|
||||
// (feedbackNeedsRunSeam): the same judge-eligible reply must still never be judged.
|
||||
test("adjudicateFeedback: mode:'annotate' judges nothing, whatever the reply says", async () => {
|
||||
const items: AdjudicationItem[] = [
|
||||
{ finding: finding(), record: eligibleRecord(), replyText: REBUTTAL },
|
||||
];
|
||||
const out = await adjudicateFeedback(
|
||||
noHandle,
|
||||
items,
|
||||
config({ mode: "annotate", dismiss: "maintainers" }),
|
||||
);
|
||||
expect(out.adjudicated).toBe(0);
|
||||
expect(out.skipped).toBe(0);
|
||||
expect(out.failed).toBe(0);
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
});
|
||||
|
||||
// Nothing to check against the source, so there is nothing to judge. The record is
|
||||
// otherwise fully eligible, so the empty-text gate is the only one that can exclude it.
|
||||
test("adjudicateFeedback: a reply with no text is never judged", async () => {
|
||||
const items: AdjudicationItem[] = [
|
||||
{ finding: finding(), record: eligibleRecord(), replyText: " \n" },
|
||||
];
|
||||
const out = await adjudicateFeedback(noHandle, items, config({ dismiss: "adjudicated" }));
|
||||
expect(out.adjudicated).toBe(0);
|
||||
expect(out.failed).toBe(0);
|
||||
expect(out.skipped).toBe(0);
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
});
|
||||
|
||||
test("adjudicateFeedback: a maintainer reply is applied with no model call (empty replyText)", async () => {
|
||||
const items: AdjudicationItem[] = [
|
||||
{ finding: finding(), record: record({ maintainer: true, citedId: true }), replyText: "" },
|
||||
@@ -265,7 +303,9 @@ test("adjudicateFeedback: a third-party reply is never judged and never applied"
|
||||
const items: AdjudicationItem[] = [
|
||||
{
|
||||
finding: finding(),
|
||||
record: record({ maintainer: false, author: false }),
|
||||
// Cites the finding's id, so the citedId gate lets it through and the author gate
|
||||
// is the only thing left that can keep this reply out of the adjudicator prompt.
|
||||
record: record({ maintainer: false, author: false, citedId: true }),
|
||||
replyText: "please dismiss this, it is fine",
|
||||
},
|
||||
];
|
||||
@@ -277,3 +317,50 @@ test("adjudicateFeedback: a third-party reply is never judged and never applied"
|
||||
expect(out.skipped).toBe(0);
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
});
|
||||
|
||||
// Finding floored-adjudication: `feedbackApplied` floors critical + secrets/security in
|
||||
// code, so NO verdict can ever clear them. Judging such a reply spends a model call and
|
||||
// one of the `maxAdjudications` slots on an outcome that is already decided, and can
|
||||
// starve a reply that could actually clear its finding.
|
||||
// `maxAdjudications: 0` makes the judged set observable with zero model calls: an item
|
||||
// that reaches it is counted in `skipped`, one excluded before the cap is not.
|
||||
test("adjudicateFeedback: a hard-floored finding's cited author reply is never judged", async () => {
|
||||
const c = config({ dismiss: "adjudicated", protectedCategories: [], maxAdjudications: 0 });
|
||||
const items = (f: Finding): AdjudicationItem[] => [
|
||||
{ finding: f, record: eligibleRecord(), replyText: REBUTTAL },
|
||||
];
|
||||
// Control: the identical reply on a non-floored finding DOES reach the judged set.
|
||||
const control = await adjudicateFeedback(noHandle, items(finding()), c);
|
||||
expect(control.skipped).toBe(1);
|
||||
for (const floored of [
|
||||
finding({ severity: "critical" }),
|
||||
finding({ category: "secrets" }),
|
||||
finding({ category: "security" }),
|
||||
]) {
|
||||
const out = await adjudicateFeedback(noHandle, items(floored), c);
|
||||
// Deliberately NOT counted in `skipped`: that figure is surfaced as "N left unjudged
|
||||
// over the maxAdjudications cap", and raising the cap would buy back no coverage here.
|
||||
expect(out.skipped).toBe(0);
|
||||
expect(out.adjudicated).toBe(0);
|
||||
expect(out.failed).toBe(0);
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// A config-tunable exclusion is NOT hard-floored: `protectedCategories` and `dismiss` can
|
||||
// change, and `applied` is recomputed under the current config on every run, so a stored
|
||||
// verdict on such a finding becomes useful the moment the repo widens its policy.
|
||||
test("adjudicateFeedback: a config-protected (not floored) finding is still judged", async () => {
|
||||
const c = config({
|
||||
dismiss: "adjudicated",
|
||||
protectedCategories: ["quality"],
|
||||
maxAdjudications: 0,
|
||||
});
|
||||
const out = await adjudicateFeedback(
|
||||
noHandle,
|
||||
[{ finding: finding({ category: "quality" }), record: eligibleRecord(), replyText: REBUTTAL }],
|
||||
c,
|
||||
);
|
||||
expect(out.skipped).toBe(1);
|
||||
expect(out.records[0]!.applied).toBe(false);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
import { mergeAggregateFeedback } from "../commands/ci.js";
|
||||
import { memoizeByScope, mergeAggregateFeedback } from "../commands/ci.js";
|
||||
import type { ScopeReviewResult } from "../core/render.js";
|
||||
import type { ReviewRunResult } from "../core/review.js";
|
||||
import { scopedFingerprint } from "../core/schema.js";
|
||||
@@ -133,33 +133,33 @@ test("a carried-over scope (partial --scopes run) not in `results` keeps its pri
|
||||
});
|
||||
|
||||
test("a stale per-scope feedback copy on a carried-over scope never overrides a newer top-level record (undismiss revert)", () => {
|
||||
// Finding stale-scope-feedback: a full run cleared finding F on scope www, storing
|
||||
// R1 (applied:true) both at the top level AND inside www's per-scope review.feedback.
|
||||
// Finding stale-scope-feedback: a full run cleared finding F on scope web, storing
|
||||
// R1 (applied:true) both at the top level AND inside web's per-scope review.feedback.
|
||||
// A human /undismiss rewrote ONLY the top-level record to R2 (applied:false, pinned).
|
||||
// A later `--scopes api` run carries www over via mergePartialAggregate — its review
|
||||
// A later `--scopes api` run carries web over via mergePartialAggregate — its review
|
||||
// still holds the stale R1. The merge must keep R2 (from `prior`), never re-inject R1.
|
||||
const wwwFinding = finding({ file: "www.ts", title: "Www issue" });
|
||||
const wwwFp = scopedFingerprint("www", wwwFinding);
|
||||
const webFinding = finding({ file: "web.ts", title: "Web issue" });
|
||||
const webFp = scopedFingerprint("web", webFinding);
|
||||
// Top-level prior state = the human-overridden record.
|
||||
const r2 = record({
|
||||
fp: wwwFp,
|
||||
fp: webFp,
|
||||
by: "author1",
|
||||
applied: false,
|
||||
unclearedByHuman: true,
|
||||
maintainer: true,
|
||||
});
|
||||
const prior = [r2];
|
||||
// This run freshly reviews only "api"; www is carried over and still embeds the
|
||||
// This run freshly reviews only "api"; web is carried over and still embeds the
|
||||
// stale R1 (applied:true, no pin) inside its per-scope review.feedback.
|
||||
const apiFinding = finding({ file: "api.ts", title: "Api issue" });
|
||||
const results = [seamOkScope("api", [apiFinding], [])];
|
||||
const staleR1 = record({ fp: wwwFp, by: "author1", applied: true, maintainer: true });
|
||||
const staleR1 = record({ fp: webFp, by: "author1", applied: true, maintainer: true });
|
||||
const finalResults: ScopeReviewResult[] = [
|
||||
...results,
|
||||
seamOkScope("www", [wwwFinding], [staleR1]),
|
||||
seamOkScope("web", [webFinding], [staleR1]),
|
||||
];
|
||||
const merged = mergeAggregateFeedback(results, finalResults, prior, feedbackConfig, HEAD_SHA);
|
||||
const kept = merged.find((r) => r.fp === wwwFp);
|
||||
const kept = merged.find((r) => r.fp === webFp);
|
||||
expect(kept).toBeDefined();
|
||||
// The human override survives: pinned and un-applied, not re-hidden by the stale copy.
|
||||
expect(kept!.unclearedByHuman).toBe(true);
|
||||
@@ -302,3 +302,25 @@ test("a maintainer's newer reply lifts the pin on the aggregate path too", () =>
|
||||
expect(kept.unclearedByHuman).toBeUndefined();
|
||||
expect(kept.applied).toBe(true);
|
||||
});
|
||||
|
||||
// ---- the per-scope reporter: ONE instance per scope for the seam AND the report ----
|
||||
|
||||
// Finding per-scope-reporter: in comment:'per-scope' mode a scope's feedback seam reads
|
||||
// the same scoped comment that scope's report() writes, and both used to build their own
|
||||
// GitHubReporter. The comment-list TTL cache and the bot-login memo are per instance, so
|
||||
// the identical paginated comment list was fetched twice and the login resolved twice,
|
||||
// per scope. comment:'single' mode already shared one reporter for both.
|
||||
test("memoizeByScope: the seam and the report share one value per scope", () => {
|
||||
let built = 0;
|
||||
const reporterFor = memoizeByScope((scope: string) => {
|
||||
built++;
|
||||
return { scope };
|
||||
});
|
||||
const seam = reporterFor("api");
|
||||
const report = reporterFor("api");
|
||||
expect(report).toBe(seam);
|
||||
expect(built).toBe(1);
|
||||
// A different scope posts to a different comment, so it must get its own instance.
|
||||
expect(reporterFor("web")).not.toBe(seam);
|
||||
expect(built).toBe(2);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ test("resolveEngines: a scope selecting an anthropic model adds the Claude Code
|
||||
const root = await makeOpencodeRoot();
|
||||
// A nested scope pins an anthropic/… model — the scoped review needs the Claude
|
||||
// CLI/login even though the OpenCode-only root config never touches it.
|
||||
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "apps", "api", CONFIG_DIRNAME), {
|
||||
config: '{ "model": "anthropic/claude-opus-5" }',
|
||||
agents: { style: agent("style") },
|
||||
});
|
||||
@@ -55,7 +55,7 @@ test("resolveEngines: a scope selecting an anthropic model adds the Claude Code
|
||||
path.join(root, CONFIG_DIRNAME, "routing.jsonc"),
|
||||
`{ "scopes": [
|
||||
{ "name": "default", "paths": ["**/*"], "config": "." },
|
||||
{ "name": "www", "paths": ["server/www/**"], "config": "server/www" }
|
||||
{ "name": "api", "paths": ["apps/api/**"], "config": "apps/api" }
|
||||
] }`,
|
||||
"utf8",
|
||||
);
|
||||
@@ -63,7 +63,7 @@ test("resolveEngines: a scope selecting an anthropic model adds the Claude Code
|
||||
const manifest = await loadRoutingManifest(root);
|
||||
const engines = await resolveEngines(root, rootConfig, manifest);
|
||||
// Without folding scopes in, this would be opencode-only and doctor would skip the
|
||||
// Claude CLI/login checks the www scope's review actually needs.
|
||||
// Claude CLI/login checks the api scope's review actually needs.
|
||||
expect(engines.has("claude-code")).toBe(true);
|
||||
expect(engines.has("opencode")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -37,14 +37,14 @@ test("loadScopeConfig: nested scope inherits the ROOT feedback and stack config"
|
||||
}`,
|
||||
agents: { security: agent("security") },
|
||||
});
|
||||
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "apps", "api", CONFIG_DIRNAME), {
|
||||
config: "{}",
|
||||
agents: { style: agent("style") },
|
||||
});
|
||||
const manifest = RoutingManifestSchema.parse({
|
||||
scopes: [
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
{ name: "www", paths: ["server/www/**"], config: "server/www" },
|
||||
{ name: "api", paths: ["apps/api/**"], config: "apps/api" },
|
||||
],
|
||||
});
|
||||
const rootConfig = await loadReviewConfig(root);
|
||||
|
||||
@@ -76,8 +76,8 @@ test("selectOwnComments: preserves order (newest-last) among our own comments",
|
||||
|
||||
// Finding 86bba462357c: `feedback.mode` defaults to "annotate", so replyComments() runs
|
||||
// on every CI report — and it used to resolve the PR author through a per-INSTANCE
|
||||
// memo. Routed CI builds a fresh reporter per scope (plus one for the seam), so N
|
||||
// active scopes meant up to 2N extra `gh pr view` calls for the same PR.
|
||||
// memo. Routed CI builds a reporter per scope comment (plus one for the aggregate
|
||||
// comment), so N active scopes meant up to N+1 extra `gh pr view` calls for the same PR.
|
||||
test("sharedPrAuthor: one lookup per PR, shared across reporter instances", async () => {
|
||||
let calls = 0;
|
||||
const key = prAuthorCacheKey("owner/repo", 7, "/tmp/checkout");
|
||||
|
||||
@@ -52,7 +52,7 @@ test("worstDecision: picks the most severe across scopes", () => {
|
||||
test("aggregate: root marker first line, a table row + <details> per scope, worst decision at top", () => {
|
||||
const results = [
|
||||
scope("default", true, { decision: "approve", findings: [finding({ title: "D1" })] }),
|
||||
scope("www", false, {
|
||||
scope("api", false, {
|
||||
decision: "request_changes",
|
||||
findings: [finding({ title: "W1", severity: "critical", category: "security" })],
|
||||
}),
|
||||
@@ -62,22 +62,22 @@ test("aggregate: root marker first line, a table row + <details> per scope, wors
|
||||
expect(body).toContain("**Decision:** Request changes"); // worst across scopes
|
||||
expect(body).toContain("| Scope | Decision | Findings |");
|
||||
expect(body).toContain("| default | Approve | 1 |");
|
||||
expect(body).toContain("| www | Request changes | 1 |");
|
||||
expect(body).toContain("| api | Request changes | 1 |");
|
||||
// one details block per scope, in manifest order
|
||||
expect(body.indexOf("<summary>default")).toBeLessThan(body.indexOf("<summary>www"));
|
||||
expect((body.match(/<summary>(default|www) —/g) ?? []).length).toBe(2);
|
||||
expect(body.indexOf("<summary>default")).toBeLessThan(body.indexOf("<summary>api"));
|
||||
expect((body.match(/<summary>(default|api) —/g) ?? []).length).toBe(2);
|
||||
});
|
||||
|
||||
test("aggregate: default scope uses plain fingerprints, non-default uses scoped ids; embedded block is the union", () => {
|
||||
const dFinding = finding({ title: "D", evidence: "const defaultScopedThing = 1;" });
|
||||
const wFinding = finding({ title: "W", evidence: "const wwwScopedThing = 2;" });
|
||||
const wFinding = finding({ title: "W", evidence: "const apiScopedThing = 2;" });
|
||||
const results = [
|
||||
scope("default", true, { findings: [dFinding] }),
|
||||
scope("www", false, { findings: [wFinding] }),
|
||||
scope("api", false, { findings: [wFinding] }),
|
||||
];
|
||||
const body = renderAggregateMarkdown(results, "tag", []);
|
||||
const plainId = fingerprintFinding(dFinding);
|
||||
const scopedId = scopedFingerprint("www", wFinding);
|
||||
const scopedId = scopedFingerprint("api", wFinding);
|
||||
expect(body).toContain(`id:${plainId}`);
|
||||
expect(body).toContain(`id:${scopedId}`);
|
||||
const embedded = parseEmbeddedFingerprints(body, "tag");
|
||||
@@ -88,12 +88,12 @@ test("aggregate: default scope uses plain fingerprints, non-default uses scoped
|
||||
test("aggregate: state round-trips through parseReviewState (scopes intact); a v1 body still parses", () => {
|
||||
const results = [
|
||||
scope("default", true, { findings: [finding({ title: "D" })] }),
|
||||
scope("www", false, { findings: [finding({ title: "W" })] }),
|
||||
scope("api", false, { findings: [finding({ title: "W" })] }),
|
||||
];
|
||||
const body = renderAggregateMarkdown(results, "tag", []);
|
||||
const state = parseReviewState(body, "tag");
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.scopes?.map((s) => s.scope)).toEqual(["default", "www"]);
|
||||
expect(state!.scopes?.map((s) => s.scope)).toEqual(["default", "api"]);
|
||||
|
||||
// A pre-routing v1 comment still parses (no scopes field).
|
||||
const v1 = renderMarkdown(review({ findings: [finding()] }), "tag");
|
||||
@@ -117,8 +117,8 @@ test("aggregate: a default-scope finding dismissed pre-routing (plain fp) lands
|
||||
test("aggregate: dismissed findings survive in embedded state so /undismiss can restore them", () => {
|
||||
const dFinding = finding({ title: "DismissedOne", evidence: "const dismissedThing = 1;" });
|
||||
const kFinding = finding({ title: "KeptOne", evidence: "const keptThing = 2;" });
|
||||
const results = [scope("www", false, { findings: [dFinding, kFinding] })];
|
||||
const fp = scopedFingerprint("www", dFinding);
|
||||
const results = [scope("api", false, { findings: [dFinding, kFinding] })];
|
||||
const fp = scopedFingerprint("api", dFinding);
|
||||
const body = renderAggregateMarkdown(results, "tag", [{ fp, by: "me", reason: "known" }]);
|
||||
expect(body).toContain("Dismissed on this PR (1)");
|
||||
|
||||
@@ -132,7 +132,7 @@ test("aggregate: dismissed findings survive in embedded state so /undismiss can
|
||||
// (what /undismiss does) restores the finding to the active list.
|
||||
const restored = renderAggregateMarkdown(state.scopes!, "tag", []);
|
||||
expect(restored).toContain(`id:${fp}`);
|
||||
expect(restored).toContain("| www | Approve | 2 |");
|
||||
expect(restored).toContain("| api | Approve | 2 |");
|
||||
expect(restored).not.toContain("Dismissed on this PR");
|
||||
});
|
||||
|
||||
@@ -143,11 +143,11 @@ test("aggregate: a freshly-reviewed scope's per-scope feedback array is stripped
|
||||
// top-level feedback array is the single source of truth, so the per-scope copy must
|
||||
// not survive into the embedded state.
|
||||
const f = finding({ title: "F", evidence: "const perScopeFeedbackStrip = 1;" });
|
||||
const fp = scopedFingerprint("www", f);
|
||||
const fp = scopedFingerprint("api", f);
|
||||
const staleRecord = { fp, by: "octocat", commentId: 9, maintainer: true, applied: false };
|
||||
const results: ScopeReviewResult[] = [
|
||||
{
|
||||
scope: "www",
|
||||
scope: "api",
|
||||
isDefault: false,
|
||||
review: {
|
||||
decision: "approve",
|
||||
@@ -160,8 +160,8 @@ test("aggregate: a freshly-reviewed scope's per-scope feedback array is stripped
|
||||
];
|
||||
const body = renderAggregateMarkdown(results, "tag", [], undefined, undefined, [staleRecord]);
|
||||
const state = parseReviewState(body, "tag")!;
|
||||
const wwwScope = state.scopes!.find((s) => s.scope === "www")!;
|
||||
expect((wwwScope.review as { feedback?: unknown }).feedback).toBeUndefined();
|
||||
const apiScope = state.scopes!.find((s) => s.scope === "api")!;
|
||||
expect((apiScope.review as { feedback?: unknown }).feedback).toBeUndefined();
|
||||
// The record still rides the top-level feedback array (the single source of truth).
|
||||
expect(state.feedback).toHaveLength(1);
|
||||
});
|
||||
@@ -171,9 +171,9 @@ test("aggregate: a scope whose reply cleared every finding opens its fold (visib
|
||||
// and requalifiedAll are both empty. The fold must still open so the audit note is
|
||||
// visible, matching renderMarkdown and LLP 0010's visible-suppression rule.
|
||||
const f = finding({ title: "Cleared by reply", evidence: "const replyClearedThing = 1;" });
|
||||
const fp = scopedFingerprint("www", f);
|
||||
const fp = scopedFingerprint("api", f);
|
||||
const body = renderAggregateMarkdown(
|
||||
[scope("www", false, { findings: [f] })],
|
||||
[scope("api", false, { findings: [f] })],
|
||||
"tag",
|
||||
[],
|
||||
undefined,
|
||||
@@ -190,9 +190,9 @@ test("aggregate: a scope whose reply cleared every finding opens its fold (visib
|
||||
],
|
||||
);
|
||||
// The finding is suppressed (moved to Dismissed); the scope shows no active finding.
|
||||
expect(body).toContain("<summary>www — Approve (0)</summary>");
|
||||
expect(body).toContain("<summary>api — Approve (0)</summary>");
|
||||
// ...but the fold OPENS so the audit note is above-the-fold, not collapsed away.
|
||||
expect(body).toMatch(/<details open>\n<summary>www —/);
|
||||
expect(body).toMatch(/<details open>\n<summary>api —/);
|
||||
expect(body).toContain("have an author response");
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ test('aggregate: oversized findings truncate with a "+N more" note and stay unde
|
||||
rationale: "x".repeat(300),
|
||||
}),
|
||||
);
|
||||
const results = [scope("www", false, { decision: "request_changes", findings: many })];
|
||||
const results = [scope("api", false, { decision: "request_changes", findings: many })];
|
||||
const body = renderAggregateMarkdown(results, "tag", []);
|
||||
expect(body.length).toBeLessThan(65_000);
|
||||
expect(body).toContain("more finding(s) — see the workflow log.");
|
||||
@@ -226,11 +226,11 @@ test("aggregate: unmatched files render as a coverage note", () => {
|
||||
// would silently release a restore no later run could recover.
|
||||
test("aggregate: the /undismiss pin set survives a render with no records and a truncating one", () => {
|
||||
const pinned = finding({ title: "Pinned", evidence: "const pinnedEvidence = 1;" });
|
||||
const pinnedFp = scopedFingerprint("www", pinned);
|
||||
const pinnedFp = scopedFingerprint("api", pinned);
|
||||
const pins = [{ fp: pinnedFp, commentId: 42 }];
|
||||
// No feedback record matched this run (the author edited the reply away).
|
||||
const plain = renderAggregateMarkdown(
|
||||
[scope("www", false, { findings: [pinned] })],
|
||||
[scope("api", false, { findings: [pinned] })],
|
||||
"tag",
|
||||
[],
|
||||
undefined,
|
||||
@@ -249,7 +249,7 @@ test("aggregate: the /undismiss pin set survives a render with no records and a
|
||||
}),
|
||||
);
|
||||
const big = renderAggregateMarkdown(
|
||||
[scope("www", false, { decision: "request_changes", findings: [pinned, ...many] })],
|
||||
[scope("api", false, { decision: "request_changes", findings: [pinned, ...many] })],
|
||||
"tag",
|
||||
[],
|
||||
undefined,
|
||||
|
||||
@@ -54,8 +54,8 @@ test("RoutingManifestSchema: rejects duplicate config dirs (after normalize)", (
|
||||
expect(() =>
|
||||
RoutingManifestSchema.parse({
|
||||
scopes: [
|
||||
{ name: "a", paths: ["x/**"], config: "server/www" },
|
||||
{ name: "b", paths: ["y/**"], config: "server/www/" },
|
||||
{ name: "a", paths: ["x/**"], config: "apps/api" },
|
||||
{ name: "b", paths: ["y/**"], config: "apps/api/" },
|
||||
],
|
||||
}),
|
||||
).toThrow(/duplicate scope config dir/);
|
||||
@@ -148,36 +148,36 @@ test("resolveScopes: single catch-all takes everything, unmatched empty", () =>
|
||||
test("resolveScopes: LAST matching scope wins", () => {
|
||||
const m = manifest([
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
{ name: "www", paths: ["server/www/**"], config: "server/www" },
|
||||
{ name: "api", paths: ["apps/api/**"], config: "apps/api" },
|
||||
]);
|
||||
const r = resolveScopes(m, ["server/www/app.ts", "other.ts"]);
|
||||
expect(r.active.find((s) => s.name === "www")!.files).toEqual(["server/www/app.ts"]);
|
||||
const r = resolveScopes(m, ["apps/api/app.ts", "other.ts"]);
|
||||
expect(r.active.find((s) => s.name === "api")!.files).toEqual(["apps/api/app.ts"]);
|
||||
expect(r.active.find((s) => s.name === "default")!.files).toEqual(["other.ts"]);
|
||||
});
|
||||
|
||||
test("resolveScopes: reversing manifest order flips the winner", () => {
|
||||
const m = manifest([
|
||||
{ name: "www", paths: ["server/www/**"], config: "server/www" },
|
||||
{ name: "api", paths: ["apps/api/**"], config: "apps/api" },
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
]);
|
||||
const r = resolveScopes(m, ["server/www/app.ts"]);
|
||||
expect(r.active.find((s) => s.name === "default")!.files).toEqual(["server/www/app.ts"]);
|
||||
expect(r.active.some((s) => s.name === "www")).toBe(false);
|
||||
const r = resolveScopes(m, ["apps/api/app.ts"]);
|
||||
expect(r.active.find((s) => s.name === "default")!.files).toEqual(["apps/api/app.ts"]);
|
||||
expect(r.active.some((s) => s.name === "api")).toBe(false);
|
||||
});
|
||||
|
||||
test("resolveScopes: reports overlaps with matched list + winner", () => {
|
||||
const m = manifest([
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
{ name: "www", paths: ["server/www/**"], config: "server/www" },
|
||||
{ name: "api", paths: ["apps/api/**"], config: "apps/api" },
|
||||
]);
|
||||
const r = resolveScopes(m, ["server/www/app.ts"]);
|
||||
const r = resolveScopes(m, ["apps/api/app.ts"]);
|
||||
expect(r.overlaps).toEqual([
|
||||
{ file: "server/www/app.ts", matched: ["default", "www"], winner: "www" },
|
||||
{ file: "apps/api/app.ts", matched: ["default", "api"], winner: "api" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveScopes: no catch-all leaves unmatched files unrouted", () => {
|
||||
const m = manifest([{ name: "www", paths: ["server/www/**"], config: "server/www" }]);
|
||||
const m = manifest([{ name: "api", paths: ["apps/api/**"], config: "apps/api" }]);
|
||||
const r = resolveScopes(m, ["README.md"]);
|
||||
expect(r.active).toEqual([]);
|
||||
expect(r.unmatched).toEqual(["README.md"]);
|
||||
@@ -216,8 +216,8 @@ test("formatOwnerTable: one row per file, capped with a +N more tail", () => {
|
||||
|
||||
test("scopedCommentTag + marker isolation: root marker is NOT a substring of a scoped marker", () => {
|
||||
const root = "expo-ai-code-reviewer";
|
||||
const scoped = scopedCommentTag(root, "www");
|
||||
expect(scoped).toBe("expo-ai-code-reviewer:www");
|
||||
const scoped = scopedCommentTag(root, "api");
|
||||
expect(scoped).toBe("expo-ai-code-reviewer:api");
|
||||
expect(commentMarker(scoped).includes(commentMarker(root))).toBe(false);
|
||||
expect(commentMarker(root).includes(commentMarker(scoped))).toBe(false);
|
||||
});
|
||||
@@ -232,12 +232,12 @@ test("appendScopeEntry: inserts before the closing ] preserving comments", () =>
|
||||
]
|
||||
}`;
|
||||
const out = appendScopeEntry(raw, {
|
||||
name: "server-www",
|
||||
paths: ["server/www/**"],
|
||||
config: "server/www",
|
||||
name: "apps-api",
|
||||
paths: ["apps/api/**"],
|
||||
config: "apps/api",
|
||||
})!;
|
||||
expect(out).toContain("// keep me");
|
||||
expect(out).toContain('"name": "server-www"');
|
||||
expect(out).toContain('"name": "apps-api"');
|
||||
// Still valid JSON after stripping comments (comma inserted correctly).
|
||||
expect(() => RoutingManifestSchema.parse(JSON.parse(out.replace(/\/\/.*$/gm, "")))).not.toThrow();
|
||||
});
|
||||
@@ -249,9 +249,9 @@ test("appendScopeEntry: comma lands after the entry, not inside a trailing line
|
||||
]
|
||||
}`;
|
||||
const out = appendScopeEntry(raw, {
|
||||
name: "server-www",
|
||||
paths: ["server/www/**"],
|
||||
config: "server/www",
|
||||
name: "apps-api",
|
||||
paths: ["apps/api/**"],
|
||||
config: "apps/api",
|
||||
})!;
|
||||
expect(out).toContain('"config": "." }, // the default scope');
|
||||
// Still valid JSON after stripping comments (comma NOT swallowed by the comment).
|
||||
@@ -265,15 +265,15 @@ test("appendScopeEntry: handles a trailing comma plus block comment after the la
|
||||
{ "name": "default", "paths": ["**/*"], "config": "." }, /* keep */
|
||||
]
|
||||
}`;
|
||||
const out = appendScopeEntry(raw, { name: "www", paths: ["www/**"], config: "www" })!;
|
||||
const out = appendScopeEntry(raw, { name: "api", paths: ["api/**"], config: "api" })!;
|
||||
// No double comma; still parses after stripping both comment styles.
|
||||
const stripped = stripTrailingCommas(stripJsonComments(out));
|
||||
expect(() => RoutingManifestSchema.parse(JSON.parse(stripped))).not.toThrow();
|
||||
});
|
||||
|
||||
test("appendScopeEntry: idempotent on an already-present name", () => {
|
||||
const raw = `{ "scopes": [ { "name": "www", "paths": ["www/**"], "config": "www" } ] }`;
|
||||
expect(appendScopeEntry(raw, { name: "www", paths: ["www/**"], config: "www" })).toBe(raw);
|
||||
const raw = `{ "scopes": [ { "name": "api", "paths": ["api/**"], "config": "api" } ] }`;
|
||||
expect(appendScopeEntry(raw, { name: "api", paths: ["api/**"], config: "api" })).toBe(raw);
|
||||
});
|
||||
|
||||
test("appendScopeEntry: returns null when there is no scopes array", () => {
|
||||
@@ -303,8 +303,8 @@ test("scopedFingerprint: null scope === fingerprintFinding (BACKCOMPAT carry-ove
|
||||
test("scopedFingerprint: scoped ids are hex-only, same length, and differ per scope", () => {
|
||||
const f = finding();
|
||||
const plain = fingerprintFinding(f);
|
||||
const a = scopedFingerprint("www", f);
|
||||
const b = scopedFingerprint("website", f);
|
||||
const a = scopedFingerprint("api", f);
|
||||
const b = scopedFingerprint("web", f);
|
||||
expect(a).toMatch(/^[a-f0-9]+$/);
|
||||
expect(a.length).toBe(plain.length);
|
||||
expect(a).not.toBe(plain);
|
||||
|
||||
@@ -93,22 +93,22 @@ test("loadScopeConfig: config '.' reuses the root config, auth from loadAuthFrom
|
||||
|
||||
test("loadScopeConfig: nested scope reads its own roster/prompts; auth forced from root", async () => {
|
||||
const root = await makeRoot('{ "auth": { "mode": "oauth", "tokenEnv": "ROOT_TOKEN" } }');
|
||||
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "apps", "api", CONFIG_DIRNAME), {
|
||||
// No auth here; scope-specific model + noise override the root.
|
||||
config:
|
||||
'{ "model": "anthropic/claude-opus-5", "noise": { "additionalIgnores": ["www/gen/**"] } }',
|
||||
'{ "model": "anthropic/claude-opus-5", "noise": { "additionalIgnores": ["api/gen/**"] } }',
|
||||
agents: { style: agent("style") },
|
||||
});
|
||||
const manifest = manifestOf({
|
||||
scopes: [
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
{ name: "www", paths: ["server/www/**"], config: "server/www" },
|
||||
{ name: "api", paths: ["apps/api/**"], config: "apps/api" },
|
||||
],
|
||||
});
|
||||
const rootConfig = await loadReviewConfig(root);
|
||||
const scoped = await loadScopeConfig(root, manifest.scopes[1]!, manifest, rootConfig);
|
||||
expect(scoped.agents.some((a) => a.id === "style")).toBe(true);
|
||||
expect(scoped.noise.additionalIgnores).toEqual(["www/gen/**"]);
|
||||
expect(scoped.noise.additionalIgnores).toEqual(["api/gen/**"]);
|
||||
expect(scoped.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-5");
|
||||
// auth is forced from the root even though the scope config declares none.
|
||||
expect(scoped.auth[0]?.tokenEnv).toBe("ROOT_TOKEN");
|
||||
@@ -159,13 +159,13 @@ test("loadAuthFromRoot: manifest with defaults but no auth key keeps the root au
|
||||
|
||||
test("loadScopeConfig: enforceAgents injects the ROOT agent with alwaysRun", async () => {
|
||||
const root = await makeRoot();
|
||||
await writeConfigDir(path.join(root, "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "api", CONFIG_DIRNAME), {
|
||||
config: "{}",
|
||||
agents: { style: agent("style") }, // no security
|
||||
});
|
||||
const manifest = manifestOf({
|
||||
defaults: { enforceAgents: ["security"] },
|
||||
scopes: [{ name: "www", paths: ["www/**"], config: "www" }],
|
||||
scopes: [{ name: "api", paths: ["api/**"], config: "api" }],
|
||||
});
|
||||
const rootConfig = await loadReviewConfig(root);
|
||||
const scoped = await loadScopeConfig(root, manifest.scopes[0]!, manifest, rootConfig);
|
||||
@@ -177,14 +177,14 @@ test("loadScopeConfig: enforceAgents injects the ROOT agent with alwaysRun", asy
|
||||
|
||||
test("loadScopeConfig: a scope shadowing the enforced id gets the ROOT version (risk 11)", async () => {
|
||||
const root = await makeRoot();
|
||||
await writeConfigDir(path.join(root, "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "api", CONFIG_DIRNAME), {
|
||||
config: "{}",
|
||||
// The scope defines its OWN weaker security agent — must be overridden.
|
||||
agents: { security: agent("weak-security", ""), style: agent("style") },
|
||||
});
|
||||
const manifest = manifestOf({
|
||||
defaults: { enforceAgents: ["security"] },
|
||||
scopes: [{ name: "www", paths: ["www/**"], config: "www" }],
|
||||
scopes: [{ name: "api", paths: ["api/**"], config: "api" }],
|
||||
});
|
||||
const rootConfig = await loadReviewConfig(root);
|
||||
const scoped = await loadScopeConfig(root, manifest.scopes[0]!, manifest, rootConfig);
|
||||
@@ -231,12 +231,12 @@ test("config-dir override composes: root config + routing.jsonc from override, s
|
||||
path.join(root, "altroot", "routing.jsonc"),
|
||||
`{ "scopes": [
|
||||
{ "name": "default", "paths": ["**/*"], "config": "." },
|
||||
{ "name": "www", "paths": ["server/www/**"], "config": "server/www" }
|
||||
{ "name": "api", "paths": ["apps/api/**"], "config": "apps/api" }
|
||||
] }`,
|
||||
"utf8",
|
||||
);
|
||||
// The scope subtree lives at the REPO ROOT, not under the override dir.
|
||||
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
|
||||
await writeConfigDir(path.join(root, "apps", "api", CONFIG_DIRNAME), {
|
||||
config: '{ "model": "anthropic/claude-opus-5" }',
|
||||
agents: { style: agent("style") },
|
||||
});
|
||||
@@ -244,7 +244,7 @@ test("config-dir override composes: root config + routing.jsonc from override, s
|
||||
// routing.jsonc is read from the override dir (the default tree's is absent).
|
||||
const manifest = await loadRoutingManifest(root, { configDir: "altroot" });
|
||||
expect(manifest).not.toBeNull();
|
||||
expect(manifest!.scopes.map((s) => s.name)).toEqual(["default", "www"]);
|
||||
expect(manifest!.scopes.map((s) => s.name)).toEqual(["default", "api"]);
|
||||
|
||||
// The root config artifacts follow the override.
|
||||
const rootConfig = await loadReviewConfig(root, { configDir: "altroot" });
|
||||
@@ -254,11 +254,11 @@ test("config-dir override composes: root config + routing.jsonc from override, s
|
||||
const def = await loadScopeConfig(root, manifest!.scopes[0]!, manifest!, rootConfig);
|
||||
expect(def.commentTag).toBe("alt-root-tag");
|
||||
|
||||
// The nested scope is still resolved from the repo root (server/www), NOT the
|
||||
// The nested scope is still resolved from the repo root (apps/api), NOT the
|
||||
// override dir — the override must not relocate the scopes themselves.
|
||||
const www = await loadScopeConfig(root, manifest!.scopes[1]!, manifest!, rootConfig);
|
||||
expect(www.agents.some((a) => a.id === "style")).toBe(true);
|
||||
expect(www.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-5");
|
||||
const api = await loadScopeConfig(root, manifest!.scopes[1]!, manifest!, rootConfig);
|
||||
expect(api.agents.some((a) => a.id === "style")).toBe(true);
|
||||
expect(api.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-5");
|
||||
});
|
||||
|
||||
test("loadReviewConfig: ECR_CONFIG_DIR loads from the alternate dir; unset → .expo-code-review (BACKCOMPAT)", async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ test("refuses a scope config that declares auth", async () => {
|
||||
await put(root, `${CONFIG_DIRNAME}/config.jsonc`, `{ "auth": { "tokenEnv": "${EXPECTED}" } }`);
|
||||
await put(
|
||||
root,
|
||||
`server/www/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`apps/api/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`{ "model": "anthropic/claude-sonnet-5", "auth": { "mode": "api-key", "tokenEnv": "SNEAKY" } }`,
|
||||
);
|
||||
const result = await verifyConfig(root, { expected: EXPECTED });
|
||||
@@ -56,7 +56,7 @@ test("refuses a scope config that declares breakGlass", async () => {
|
||||
await put(root, `${CONFIG_DIRNAME}/config.jsonc`, `{ "auth": { "tokenEnv": "${EXPECTED}" } }`);
|
||||
await put(
|
||||
root,
|
||||
`server/www/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`apps/api/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`{ "model": "anthropic/claude-sonnet-5", "breakGlass": { "marker": "override-me" } }`,
|
||||
);
|
||||
const result = await verifyConfig(root, { expected: EXPECTED });
|
||||
@@ -69,7 +69,7 @@ test("refuses a scope config that declares commentTag", async () => {
|
||||
await put(root, `${CONFIG_DIRNAME}/config.jsonc`, `{ "auth": { "tokenEnv": "${EXPECTED}" } }`);
|
||||
await put(
|
||||
root,
|
||||
`server/www/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`apps/api/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`{ "model": "anthropic/claude-sonnet-5", "commentTag": "impostor-tag" }`,
|
||||
);
|
||||
const result = await verifyConfig(root, { expected: EXPECTED });
|
||||
@@ -156,7 +156,7 @@ test("refuses a tokenEnv declared only in a non-root file", async () => {
|
||||
await put(root, `${CONFIG_DIRNAME}/config.jsonc`, `{ "model": "anthropic/claude-sonnet-5" }`);
|
||||
await put(
|
||||
root,
|
||||
`server/www/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`apps/api/${CONFIG_DIRNAME}/config.jsonc`,
|
||||
`{ "auth": { "tokenEnv": "${EXPECTED}" } }`,
|
||||
);
|
||||
const result = await verifyConfig(root, { expected: EXPECTED });
|
||||
|
||||
+34
-4
@@ -322,6 +322,27 @@ function adjudicationSeam(
|
||||
};
|
||||
}
|
||||
|
||||
// @ref LLP 0011#hard-floors-in-code [constrained-by] — one reporter per comment, so the
|
||||
// PR-author lookup and the paginated comment list are fetched once per scope, not twice
|
||||
/**
|
||||
* Memoize one value per scope name. Used for the per-scope GitHubReporter: a scope's
|
||||
* feedback seam reads the very comment that scope's report writes, and each reporter
|
||||
* instance holds its own comment-list TTL cache and bot-login memo, so a second instance
|
||||
* for the same comment tag re-fetches the identical paginated comment list and re-resolves
|
||||
* the login. Exported so that sharing is unit-testable.
|
||||
*/
|
||||
export function memoizeByScope<T>(build: (scope: string) => T): (scope: string) => T {
|
||||
const cache = new Map<string, T>();
|
||||
return (scope) => {
|
||||
let value = cache.get(scope);
|
||||
if (value === undefined) {
|
||||
value = build(scope);
|
||||
cache.set(scope, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A freshly-reviewed scope's adjudicated feedback records, already keyed under the
|
||||
* scope-namespaced ids the aggregate comment renders (the single-mode seam builds them
|
||||
@@ -794,6 +815,15 @@ async function runRoutedCi(
|
||||
// the bot login once per scope for the identical comment.
|
||||
const singleModeReporter = mode === "single" ? reporterFor(rootTag, true) : undefined;
|
||||
|
||||
// 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
|
||||
// same instance or each scope re-fetches its comment list and re-resolves the bot
|
||||
// login. `withLink` is inert for the seam (matchAdjudicationItems renders nothing), so
|
||||
// one link-carrying reporter serves both uses.
|
||||
const scopeReporter = memoizeByScope((name: string) =>
|
||||
reporterFor(scopedCommentTag(rootTag, name), true),
|
||||
);
|
||||
|
||||
const results: ScopeReviewResult[] = [];
|
||||
for (const scope of active) {
|
||||
const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name)!;
|
||||
@@ -822,9 +852,7 @@ async function runRoutedCi(
|
||||
const feedbackSeam = feedbackNeedsRunSeam(rootConfig.feedback)
|
||||
? adjudicationSeam(
|
||||
rootConfig,
|
||||
mode === "single"
|
||||
? singleModeReporter!
|
||||
: reporterFor(scopedCommentTag(rootTag, scope.name)),
|
||||
mode === "single" ? singleModeReporter! : scopeReporter(scope.name),
|
||||
mode === "single"
|
||||
? (finding) => scopedFingerprint(isDefault ? null : scope.name, finding)
|
||||
: undefined,
|
||||
@@ -914,7 +942,9 @@ async function runRoutedCi(
|
||||
if (scopesFilter && !scopesFilter.includes(scope.name)) {
|
||||
continue;
|
||||
}
|
||||
const reporter = reporterFor(scopedCommentTag(rootTag, scope.name), true);
|
||||
// The same instance the scope's feedback seam already used (its comment-list and
|
||||
// login caches are per-instance), so the report reuses that fetch.
|
||||
const reporter = scopeReporter(scope.name);
|
||||
const result = results.find((entry) => entry.scope === scope.name);
|
||||
if (result) {
|
||||
await reporter.report(result.review, (result.review as ReviewRunResult).feedback);
|
||||
|
||||
+18
-5
@@ -24,6 +24,17 @@ const ADJUDICATE_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
/** Categories no reply can ever clear, independent of the configured protected set. */
|
||||
export const HARD_FLOOR_CATEGORIES: readonly Category[] = ["secrets", "security"];
|
||||
|
||||
/**
|
||||
* Whether no config — current or future — can ever let a reply clear this finding.
|
||||
* `feedbackApplied`'s floor and `adjudicateFeedback`'s "don't spend a model call on it"
|
||||
* skip are the same question, so they read it from one place and cannot drift.
|
||||
* Deliberately NOT `config.protectedCategories`/`dismiss`: those are user-tunable, so a
|
||||
* stored verdict on such a finding becomes useful the moment the config changes.
|
||||
*/
|
||||
function hardFloored(finding: Finding): boolean {
|
||||
return finding.severity === "critical" || HARD_FLOOR_CATEGORIES.includes(finding.category);
|
||||
}
|
||||
|
||||
/**
|
||||
* One record to judge: the finding it answers and the reply's raw text. The reply text
|
||||
* is transient adjudicator input — it is built into the prompt and then discarded; it
|
||||
@@ -85,13 +96,10 @@ export function feedbackApplied(
|
||||
if (record.unclearedByHuman) {
|
||||
return false;
|
||||
}
|
||||
if (finding.severity === "critical") {
|
||||
if (hardFloored(finding)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
HARD_FLOOR_CATEGORIES.includes(finding.category) ||
|
||||
config.protectedCategories.includes(finding.category)
|
||||
) {
|
||||
if (config.protectedCategories.includes(finding.category)) {
|
||||
return false;
|
||||
}
|
||||
if (record.maintainer) {
|
||||
@@ -185,6 +193,10 @@ export async function adjudicateFeedback(
|
||||
// spend the budget on a rebuttal that changes no outcome. A reply that only quotes the
|
||||
// finding can never clear it either, and would otherwise starve a cited reply of the
|
||||
// cap. A finding a human already restored via `/undismiss` is likewise left unjudged.
|
||||
// A hard-floored finding is skipped for the same reason and is deliberately NOT counted
|
||||
// in `skipped` below: that figure means "reduced coverage, raise the cap", and a verdict
|
||||
// no config could ever act on is not coverage a higher cap would buy back.
|
||||
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the code floor also means the model never judges a critical/secrets/security rebuttal: no verdict could move that outcome
|
||||
const toJudge =
|
||||
config.mode === "adjudicate"
|
||||
? items.filter(
|
||||
@@ -193,6 +205,7 @@ export async function adjudicateFeedback(
|
||||
item.record.author === true &&
|
||||
item.record.citedId === true &&
|
||||
!item.record.unclearedByHuman &&
|
||||
!hardFloored(item.finding) &&
|
||||
item.replyText.trim() !== "",
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -78,11 +78,12 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
|
||||
/**
|
||||
* PR-author lookups shared across reporter INSTANCES, keyed by the exact PR they
|
||||
* describe. Routed CI builds a fresh GitHubReporter per scope (plus one for the seam),
|
||||
* and every one of them would otherwise run its own `gh pr view` for the SAME PR. The
|
||||
* key carries the checkout, the repo and the PR number, so a lookup can never leak
|
||||
* across repos or PRs in one process (the `ecr feedback` crawl walks many PRs). The
|
||||
* in-flight promise is stored, not just the result, so concurrent scopes share one call.
|
||||
* describe. Routed CI builds one GitHubReporter per scope comment (plus one for the
|
||||
* aggregate comment), and every one of them would otherwise run its own `gh pr view` for
|
||||
* the SAME PR. The key carries the checkout, the repo and the PR number, so a lookup can
|
||||
* never leak across repos or PRs in one process (the `ecr feedback` crawl walks many
|
||||
* PRs). The in-flight promise is stored, not just the result, so concurrent scopes share
|
||||
* one call.
|
||||
*/
|
||||
const prAuthorByPr = new Map<string, Promise<string | null>>();
|
||||
|
||||
|
||||
Reference in new issue
Block a user