@nanobpm/nano-workforce 0.187.5 → 0.188.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/SPEC.md +90 -18
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +393 -5
- package/app/convergeGate.ts +41 -5
- package/app/currentHead.ts +60 -0
- package/app/github.test.ts +65 -1
- package/app/github.ts +138 -32
- package/app/pollReviewsStale.test.ts +187 -0
- package/app/reviewWait.test.ts +33 -0
- package/app/reviewWait.ts +21 -0
- package/app/service.test.ts +40 -1
- package/app/service.ts +56 -2
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +176 -121
- package/resources/prompts/review-round.md +10 -0
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +84 -25
- package/workers/progress-check/worker.ts +6 -44
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The PR "current HEAD" reader shared by every step that must gate on the head the push landed —
|
|
2
|
+
// the capture-head / progress-check steps, the converge-gate's stale-review guard, AND the poller's
|
|
3
|
+
// stale-review re-solicitation (#799). It lives in this neutral module (not in a worker) so the
|
|
4
|
+
// poller in `app/service.ts` can reuse the EXACT reader the workers use without importing a worker
|
|
5
|
+
// (which would form a `service.ts ↔ worker` cycle) and without a second, drift-prone copy of the
|
|
6
|
+
// branch-ref-over-`head.sha` preference (#786).
|
|
7
|
+
import type { fetchBranchHead, fetchPrHead } from "./github.ts";
|
|
8
|
+
|
|
9
|
+
// Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
|
|
10
|
+
// binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
|
|
11
|
+
// `null` so the guard fails OPEN. It reads the BRANCH ref (`git/ref/heads/<branch>`) — updated
|
|
12
|
+
// atomically with the push — in preference to the PR object's asynchronously-denormalized
|
|
13
|
+
// `head.sha`, so a lagging PR projection can never fabricate a stale-but-valid no-advance
|
|
14
|
+
// escalation (#786). Once a head ref is known this trusts ONLY its atomic ref: a failed/absent
|
|
15
|
+
// ref read fails OPEN (`null`), never falling back to `head.sha`. The PR head is used only when
|
|
16
|
+
// the PR carries NO head ref at all.
|
|
17
|
+
// The optional `token` lets a caller that already holds a per-call GitHub credential (e.g. the
|
|
18
|
+
// poller in `app/service.ts`, which is handed a `token` for its review fetch) read the head with the
|
|
19
|
+
// SAME credential rather than silently diverging to `process.env.GITHUB_TOKEN`. Omitting it keeps
|
|
20
|
+
// the env-token default, so the worker callers (capture-head / progress-check / converge-gate) are
|
|
21
|
+
// unchanged. Binding both reads to one credential closes the drift where a caller supplying a token
|
|
22
|
+
// without that env var would get a `null` head (→ `isReviewStale` fails open, advancing a stale
|
|
23
|
+
// review); see #799.
|
|
24
|
+
export type HeadReader = (repo: string, prNumber: number, token?: string) => Promise<string | null>;
|
|
25
|
+
|
|
26
|
+
/** Build the real head reader from the GitHub fetchers (injected so tests can stub them). Prefers
|
|
27
|
+
* the branch ref (atomic with the push) over the PR object's denormalized `head.sha` (#786); fails
|
|
28
|
+
* OPEN (`null`) on any unreadable state so a transport hiccup never fabricates a stale verdict. The
|
|
29
|
+
* single canonical implementation — capture-head, progress-check, converge-gate, and the poller all
|
|
30
|
+
* bind this so they gate on the same head. */
|
|
31
|
+
export function makeDefaultReadHead(deps: {
|
|
32
|
+
fetchPrHead: typeof fetchPrHead;
|
|
33
|
+
fetchBranchHead: typeof fetchBranchHead;
|
|
34
|
+
}): HeadReader {
|
|
35
|
+
return async (repo, prNumber, token) => {
|
|
36
|
+
const tok = token ?? process.env.GITHUB_TOKEN ?? "";
|
|
37
|
+
const pr = await deps.fetchPrHead(repo, prNumber, tok).catch(() => null);
|
|
38
|
+
if (!pr) return null;
|
|
39
|
+
// Prefer the branch ref (atomic with the push) over the PR object's denormalized head.sha (#786).
|
|
40
|
+
// Once the head branch is known, trust ONLY its atomic ref: a failed/absent ref read fails OPEN
|
|
41
|
+
// (`null`) rather than falling back to the PR object's asynchronously-denormalized head.sha, which
|
|
42
|
+
// can still report a stale-but-valid SHA after a push and fabricate a no-advance escalation — the
|
|
43
|
+
// very projection this branch-ref read exists to avoid. The ref is read in the repository the
|
|
44
|
+
// head branch actually lives in (the fork for a cross-repo PR — see below), so a fork PR fails
|
|
45
|
+
// open safely instead of comparing an unrelated base-repo SHA. Fall back to the PR head only when
|
|
46
|
+
// there is NO head ref.
|
|
47
|
+
if (pr.headRef) {
|
|
48
|
+
// Resolve the head ref in the repository the head branch actually lives in — the FORK for a
|
|
49
|
+
// cross-repo PR (`pr.headRepo`), else the base `repo`. Querying the base repo unconditionally
|
|
50
|
+
// would, for a fork PR whose head branch shares a name with a base-repo branch, read the
|
|
51
|
+
// unrelated base-branch SHA and fabricate progress/no-progress (#786). When the head repo
|
|
52
|
+
// cannot be resolved (a deleted fork ⇒ `headRepo:null`) fail OPEN to `null` rather than fall
|
|
53
|
+
// back to the base repo and risk that collision.
|
|
54
|
+
const headRepo = pr.headRepo;
|
|
55
|
+
if (!headRepo) return null;
|
|
56
|
+
return await deps.fetchBranchHead(headRepo, pr.headRef, tok).catch(() => null);
|
|
57
|
+
}
|
|
58
|
+
return pr.headSha ?? null;
|
|
59
|
+
};
|
|
60
|
+
}
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -60,6 +60,70 @@ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + ne
|
|
|
60
60
|
);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// ── fetchPrReviews token-transport paging (issue #799) ──────────────────────────────────────────
|
|
64
|
+
// The poller picks the NEWEST review by id, so `fetchPrReviews` must page the FULL (oldest→newest)
|
|
65
|
+
// list — reading only the first `per_page=100` page would surface the oldest 100 and miss the
|
|
66
|
+
// genuinely newest review on a >100-review convergence loop. The token transport mirrors
|
|
67
|
+
// `fetchPrFiles`: it fails CLOSED (throws) when the paging cap genuinely truncates rather than
|
|
68
|
+
// returning a partial list the poller would treat as complete.
|
|
69
|
+
|
|
70
|
+
// A fake `fetch` that serves `pages` of review batches; each page N (1-based) returns `pages[N-1]`
|
|
71
|
+
// reviews (with ascending ids), setting `Link: rel="next"` whenever a later page exists.
|
|
72
|
+
function stubReviewFetch(pages: number[]) {
|
|
73
|
+
return (url: string | URL | Request): Promise<Response> => {
|
|
74
|
+
const u = new URL(String(url));
|
|
75
|
+
const page = Number(u.searchParams.get("page") ?? "1");
|
|
76
|
+
const count = pages[page - 1] ?? 0;
|
|
77
|
+
const start = pages.slice(0, page - 1).reduce((a, b) => a + b, 0);
|
|
78
|
+
const body = Array.from({ length: count }, (_, i) => ({
|
|
79
|
+
id: start + i + 1,
|
|
80
|
+
state: "COMMENTED",
|
|
81
|
+
submitted_at: "2026-01-01T00:00:00Z",
|
|
82
|
+
}));
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
if (page < pages.length) {
|
|
85
|
+
headers.set("link", `<https://api.github.com/next?page=${page + 1}>; rel="next"`);
|
|
86
|
+
}
|
|
87
|
+
return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers }));
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function withReviewTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
|
|
92
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
93
|
+
const prevFetch = globalThis.fetch;
|
|
94
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
95
|
+
globalThis.fetch = stubReviewFetch(pages) as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
return await fn();
|
|
98
|
+
} finally {
|
|
99
|
+
globalThis.fetch = prevFetch;
|
|
100
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
101
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("fetchPrReviews: pages the full list beyond the first 100 (newest review is seen)", async () => {
|
|
106
|
+
const reviews = await withReviewTransport([100, 37], () => fetchPrReviews("o/r", 1, "tok"));
|
|
107
|
+
assertEquals(reviews?.length, 137);
|
|
108
|
+
// The genuinely newest review (highest id) is on the SECOND page — it must be present.
|
|
109
|
+
assertEquals(reviews?.[reviews.length - 1]?.id, 137);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("fetchPrReviews: no token → null (idle, not a throw)", async () => {
|
|
113
|
+
const reviews = await withReviewTransport([100], () => fetchPrReviews("o/r", 2, ""));
|
|
114
|
+
assertEquals(reviews, null);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fetchPrReviews: throws when the cap genuinely truncates (full last page + next)", async () => {
|
|
118
|
+
// MAX_PAGES=20 full pages, and the 20th still advertises `rel="next"` → fail closed.
|
|
119
|
+
const capped = Array.from({ length: 21 }, () => 100);
|
|
120
|
+
await assertRejects(
|
|
121
|
+
() => withReviewTransport(capped, () => fetchPrReviews("o/r", 3, "tok")),
|
|
122
|
+
Error,
|
|
123
|
+
"truncated",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
63
127
|
// ── ensureBaseBranch (ADR 0003 rule 2) ──────────────────────────────────────
|
|
64
128
|
// Force the token transport and stub `globalThis.fetch` so the create-if-missing primitive is
|
|
65
129
|
// exercised end-to-end without touching the network: git-ref lookups, default-branch resolution,
|
package/app/github.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface GhReview {
|
|
|
23
23
|
id: number;
|
|
24
24
|
state: string;
|
|
25
25
|
submitted_at?: string;
|
|
26
|
+
/** The commit SHA the review was submitted against (GitHub's `commit_id`). Used to detect a
|
|
27
|
+
* review that predates the PR's current HEAD — a STALE review whose advisories are about code the
|
|
28
|
+
* head has since moved past (issue #799). Absent on data GitHub did not carry a `commit_id` for. */
|
|
29
|
+
commit_id?: string | null;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export type GithubTransport = "gh" | "token" | "auto";
|
|
@@ -61,7 +65,12 @@ function isGhAvailable(): Promise<boolean> {
|
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
|
|
64
|
-
* the caller can log-and-continue; returns `null` when no transport is usable (idle).
|
|
68
|
+
* the caller can log-and-continue; returns `null` when no transport is usable (idle). Pages the
|
|
69
|
+
* FULL (oldest→newest) reviews list — the poller picks the newest fresh review by id, so reading
|
|
70
|
+
* only the first `per_page=100` page would, on a >100-review convergence loop, surface the OLDEST
|
|
71
|
+
* 100 and miss the genuinely newest review (repeatedly nudging while a current-head review sits on a
|
|
72
|
+
* later page, or classifying an old review as stale). This mirrors {@link fetchLatestCopilotReview}'s
|
|
73
|
+
* paging so both readers agree on which review is newest. */
|
|
65
74
|
export async function fetchPrReviews(
|
|
66
75
|
repo: string,
|
|
67
76
|
number: number | string,
|
|
@@ -71,17 +80,43 @@ export async function fetchPrReviews(
|
|
|
71
80
|
const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
|
|
72
81
|
const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
|
|
73
82
|
if (useGh) {
|
|
74
|
-
|
|
83
|
+
// `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
|
|
84
|
+
// convergence loop still surfaces the genuinely newest review rather than the oldest 100. Plain
|
|
85
|
+
// `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
|
|
86
|
+
// cannot read; `--slurp` wraps the pages in an outer array we flatten one level (mirrors
|
|
87
|
+
// {@link githubReleasesCommand}/{@link parseReleases}).
|
|
88
|
+
const out = await runGh([
|
|
89
|
+
"api", "--paginate", "--slurp", path, "-H", "Accept: application/vnd.github+json",
|
|
90
|
+
]);
|
|
75
91
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
76
|
-
return JSON.parse(out) as GhReview[];
|
|
92
|
+
return (JSON.parse(out) as GhReview[][]).flat();
|
|
77
93
|
}
|
|
78
94
|
if (!token) return null; // token mode with no token → poller idles
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
// Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
|
|
96
|
+
// a genuinely deeper history we can't reach is unverifiable → fail CLOSED (throw) rather than
|
|
97
|
+
// return a partial list the poller would treat as complete (selecting an older review, re-nudging).
|
|
98
|
+
const reviews: GhReview[] = [];
|
|
99
|
+
const MAX_PAGES = 20;
|
|
100
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
101
|
+
const r = await fetch(`https://api.github.com/${path}&page=${page}`, {
|
|
102
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
103
|
+
});
|
|
104
|
+
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
105
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
106
|
+
const batch = (await r.json()) as GhReview[];
|
|
107
|
+
reviews.push(...batch);
|
|
108
|
+
// A short final page means we've read every review — the list is complete.
|
|
109
|
+
if (batch.length < 100) return reviews;
|
|
110
|
+
// A full page on the last allowed page is only truncated if GitHub says there's more; trust the
|
|
111
|
+
// `Link` header's `rel="next"` (mirrors {@link fetchPrFiles}) so an exact multiple of 100 isn't a
|
|
112
|
+
// false positive, and throw when the cap genuinely truncates rather than under-reading history.
|
|
113
|
+
if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`github pr reviews truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-review paging cap`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return reviews;
|
|
85
120
|
}
|
|
86
121
|
|
|
87
122
|
// ── Review-comment convergence gate (don't converge with unaddressed comments) ──────────────
|
|
@@ -246,25 +281,59 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
|
|
|
246
281
|
return out;
|
|
247
282
|
}
|
|
248
283
|
|
|
284
|
+
/** The line-stable advisory keys carried by a SINGLE comment body's canonical `nano-ack: <path> ::
|
|
285
|
+
* <text>` markers. This is the SOLE recognizer of an acknowledgement, shared by `isAckThread` and
|
|
286
|
+
* `parseAckedAdvisories` so "is this an ack?" has ONE canonical implementation (derivation over
|
|
287
|
+
* duplication — no drift between the two consumers). The bare `nano-ack: <path>:<line>` form yields
|
|
288
|
+
* NOTHING here: `NEW_ACK` requires the ` :: <text>` prose (a bare `path:line` is prose-blind and
|
|
289
|
+
* would false-OPEN a new advisory re-emitted at a previously-acked line). */
|
|
290
|
+
function canonicalAckKeys(body: string): string[] {
|
|
291
|
+
const keys: string[] = [];
|
|
292
|
+
ACK_MARKER.lastIndex = 0;
|
|
293
|
+
let m: RegExpExecArray | null;
|
|
294
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
295
|
+
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
296
|
+
const nw = NEW_ACK.exec(m[1].trim());
|
|
297
|
+
if (nw) keys.push(advisoryStableKey(nw[1], nw[2]));
|
|
298
|
+
}
|
|
299
|
+
return keys;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** True when a review thread is a DEDICATED `nano-ack:` acknowledgement thread — one whose ROOT
|
|
303
|
+
* comment (`bodies[0]`, the thread-opening comment) carries a valid canonical `nano-ack: <path> ::
|
|
304
|
+
* <text>` marker — rather than a substantive code-review thread. This is a CLASSIFICATION only: the
|
|
305
|
+
* converge gate never DROPS an unresolved thread on the strength of this predicate. An unresolved ack
|
|
306
|
+
* thread still BLOCKS convergence (it is a genuinely-open GitHub thread); the classification only
|
|
307
|
+
* routes that block onto the recoverable ack-only path — a partially-completed acknowledgement the
|
|
308
|
+
* bounded #796 auto-ack retry can finish (post-and-resolve) — instead of escalating a human. A
|
|
309
|
+
* substantive unresolved thread escalates to a human.
|
|
310
|
+
*
|
|
311
|
+
* Because the gate BLOCKS either way, this predicate is FAIL-CLOSED even under a false positive:
|
|
312
|
+
* 1. Only the canonical prose-keyed form counts (via `canonicalAckKeys`); the retired bare
|
|
313
|
+
* `nano-ack: <path>:<line>` form does NOT — matching `parseAckedAdvisories`.
|
|
314
|
+
* 2. Only the ROOT comment is inspected — a reviewer's substantive finding is ALWAYS its thread's
|
|
315
|
+
* root and (canonical-form) never carries this marker, so a substantive thread that merely
|
|
316
|
+
* quotes or replies `nano-ack:` in a later comment is not mis-classified.
|
|
317
|
+
* 3. Even if a root DID quote the canonical marker mid-prose and were mis-labelled an ack, the
|
|
318
|
+
* thread is NOT excluded — it still blocks (as ack-only), and the bounded auto-ack retry cannot
|
|
319
|
+
* ack a non-advisory, so it escalates to a human on exhaustion. Marker presence never finalizes
|
|
320
|
+
* the gate with an open thread (the fail-OPEN this design forecloses). */
|
|
321
|
+
export function isAckThread(thread: ReviewThread): boolean {
|
|
322
|
+
const root = thread.bodies[0];
|
|
323
|
+
return root !== undefined && canonicalAckKeys(root).length > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
249
326
|
/** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
|
|
250
327
|
* count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
|
|
251
|
-
* parsed from the `nano-ack: <path> :: <text>` form ONLY
|
|
252
|
-
* intentionally NOT honoured: its `path:line` key is blind to the
|
|
253
|
-
* a genuinely new advisory re-emitted at a previously-acked line.
|
|
254
|
-
* acked iff its stable key appears here. */
|
|
328
|
+
* parsed from the `nano-ack: <path> :: <text>` form ONLY (via the shared `canonicalAckKeys`). A bare
|
|
329
|
+
* `nano-ack: <path>:<line>` marker is intentionally NOT honoured: its `path:line` key is blind to the
|
|
330
|
+
* advisory prose and would false-OPEN a genuinely new advisory re-emitted at a previously-acked line.
|
|
331
|
+
* The gate treats an advisory as acked iff its stable key appears here. */
|
|
255
332
|
export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
256
333
|
const acked = new Set<string>();
|
|
257
334
|
for (const t of threads) {
|
|
258
335
|
if (!t.isResolved) continue;
|
|
259
|
-
for (const body of t.bodies)
|
|
260
|
-
ACK_MARKER.lastIndex = 0;
|
|
261
|
-
let m: RegExpExecArray | null;
|
|
262
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
263
|
-
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
264
|
-
const nw = NEW_ACK.exec(m[1].trim());
|
|
265
|
-
if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
|
|
266
|
-
}
|
|
267
|
-
}
|
|
336
|
+
for (const body of t.bodies) for (const k of canonicalAckKeys(body)) acked.add(k);
|
|
268
337
|
}
|
|
269
338
|
return [...acked];
|
|
270
339
|
}
|
|
@@ -279,36 +348,73 @@ export function pickLatestCopilotReviewBody(
|
|
|
279
348
|
reviews: { user?: { login?: string }; body?: string }[],
|
|
280
349
|
truncated: boolean,
|
|
281
350
|
): string | null {
|
|
351
|
+
const picked = pickLatestCopilotReview(reviews, truncated);
|
|
352
|
+
return picked === null ? null : picked.body;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Pick the newest Copilot review — body AND the commit SHA it was submitted against — from a
|
|
356
|
+
* reviews list (GitHub returns them oldest→newest). Semantics mirror {@link pickLatestCopilotReviewBody}
|
|
357
|
+
* exactly: `truncated = true` fails CLOSED (`null`, unverifiable); a verified-complete read with no
|
|
358
|
+
* Copilot review returns `{ body: "", commitId: null }` (a verified "no advisories"). The `commitId`
|
|
359
|
+
* lets a caller detect a review that predates the PR's current HEAD — a STALE review whose advisories
|
|
360
|
+
* are about code the head has since moved past (issue #799). Pure; unit-tested. */
|
|
361
|
+
export function pickLatestCopilotReview(
|
|
362
|
+
reviews: { user?: { login?: string }; body?: string; commit_id?: string | null }[],
|
|
363
|
+
truncated: boolean,
|
|
364
|
+
): { body: string; commitId: string | null } | null {
|
|
282
365
|
if (truncated) return null;
|
|
283
366
|
const copilot = reviews.filter((rv) => isCopilot(rv.user?.login));
|
|
284
|
-
|
|
367
|
+
const latest = copilot[copilot.length - 1];
|
|
368
|
+
return { body: latest?.body ?? "", commitId: latest?.commit_id ?? null };
|
|
285
369
|
}
|
|
286
370
|
|
|
287
371
|
/** Fetch the latest Copilot review body for a PR (the newest review authored by the automated
|
|
288
372
|
* Copilot reviewer). Returns `null` ONLY when no transport is usable (unverifiable → the worker
|
|
289
373
|
* fails closed); returns `""` when transport is usable but the PR has no Copilot review yet (a
|
|
290
374
|
* verified "no suppressed advisories"). Throws on a genuine transport failure. This split keeps
|
|
291
|
-
* `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension.
|
|
375
|
+
* `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension.
|
|
376
|
+
* Thin wrapper over {@link fetchLatestCopilotReview} (the single fetch implementation). */
|
|
292
377
|
export async function fetchLatestCopilotReviewBody(
|
|
293
378
|
repo: string,
|
|
294
379
|
number: number | string,
|
|
295
380
|
token: string,
|
|
296
381
|
): Promise<string | null> {
|
|
382
|
+
const picked = await fetchLatestCopilotReview(repo, number, token);
|
|
383
|
+
return picked === null ? null : picked.body;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Fetch the latest Copilot review — body AND the commit SHA it was submitted against — for a PR.
|
|
387
|
+
* Same null/`""`-vs-unverifiable semantics as {@link fetchLatestCopilotReviewBody} (which delegates
|
|
388
|
+
* here): `null` ONLY when no transport is usable (unverifiable → fail closed); a verified read with
|
|
389
|
+
* no Copilot review yet returns `{ body: "", commitId: null }`. The `commitId` lets the convergence
|
|
390
|
+
* gate detect a review that predates the PR's current HEAD — a STALE review whose advisories are
|
|
391
|
+
* about code the head has since moved past (issue #799) — and re-solicit a fresh review rather than
|
|
392
|
+
* block/escalate against the obsolete body. Throws on a genuine transport failure. */
|
|
393
|
+
export async function fetchLatestCopilotReview(
|
|
394
|
+
repo: string,
|
|
395
|
+
number: number | string,
|
|
396
|
+
token: string,
|
|
397
|
+
): Promise<{ body: string; commitId: string | null } | null> {
|
|
297
398
|
const mode = githubTransport();
|
|
298
399
|
const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
|
|
299
400
|
const basePath = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
|
|
300
401
|
interface Review {
|
|
301
402
|
user?: { login?: string };
|
|
302
403
|
body?: string;
|
|
404
|
+
commit_id?: string | null;
|
|
303
405
|
}
|
|
304
406
|
if (useGh) {
|
|
305
|
-
// `--paginate`
|
|
407
|
+
// `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
|
|
306
408
|
// convergence loop still surfaces the genuinely newest Copilot review rather than the oldest
|
|
307
|
-
// 100 — reading only the first page here would fail-OPEN the advisory dimension.
|
|
308
|
-
|
|
409
|
+
// 100 — reading only the first page here would fail-OPEN the advisory dimension. Plain
|
|
410
|
+
// `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
|
|
411
|
+
// cannot read; `--slurp` wraps the pages in an outer array we flatten one level.
|
|
412
|
+
const out = await runGh([
|
|
413
|
+
"api", "--paginate", "--slurp", basePath, "-H", "Accept: application/vnd.github+json",
|
|
414
|
+
]);
|
|
309
415
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
310
|
-
const reviews = JSON.parse(out) as Review[];
|
|
311
|
-
return
|
|
416
|
+
const reviews = (JSON.parse(out) as Review[][]).flat();
|
|
417
|
+
return pickLatestCopilotReview(reviews, false);
|
|
312
418
|
}
|
|
313
419
|
if (!token) return null;
|
|
314
420
|
// Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
|
|
@@ -324,14 +430,14 @@ export async function fetchLatestCopilotReviewBody(
|
|
|
324
430
|
const batch = (await r.json()) as Review[];
|
|
325
431
|
reviews.push(...batch);
|
|
326
432
|
// A short page means we've read every review — the list is complete.
|
|
327
|
-
if (batch.length < 100) return
|
|
433
|
+
if (batch.length < 100) return pickLatestCopilotReview(reviews, false);
|
|
328
434
|
// A full page on the last allowed page is only truncated if GitHub says there's more; trust the
|
|
329
435
|
// `Link` header's `rel="next"` so an exact multiple of 100 isn't a false positive.
|
|
330
436
|
if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
|
|
331
|
-
return
|
|
437
|
+
return pickLatestCopilotReview(reviews, true);
|
|
332
438
|
}
|
|
333
439
|
}
|
|
334
|
-
return
|
|
440
|
+
return pickLatestCopilotReview(reviews, false);
|
|
335
441
|
}
|
|
336
442
|
|
|
337
443
|
/** Raw GraphQL response shape for the review-threads query. */
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Behavioral regression for the poller's stale-review branch (#799, FM2).
|
|
2
|
+
//
|
|
3
|
+
// `pollReviews` must only resume the convergence loop on a review of the CURRENT head. When the PR
|
|
4
|
+
// HEAD has advanced past the commit the newest review was submitted against, that review is STALE:
|
|
5
|
+
// its advisories describe code the head already moved past. Publishing `readiness-ready` on it would
|
|
6
|
+
// resume the loop on obsolete findings and re-escalate a human on an already-fixed advisory. The
|
|
7
|
+
// poller must instead treat a stale review like "no fresh review" — (re-)solicit and wait — WITHOUT
|
|
8
|
+
// advancing `last_review_id` or emitting the readiness signal.
|
|
9
|
+
//
|
|
10
|
+
// The pure `isReviewStale` predicate and the injected converge-gate already have coverage; this
|
|
11
|
+
// test locks the poller's own STATE TRANSITION (which those cannot), driving the real token-mode
|
|
12
|
+
// transport with a stubbed `fetch` so a differing branch-head SHA vs review `commit_id` is exercised
|
|
13
|
+
// end-to-end. A control with a CURRENT-head review asserts the loop still resumes.
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assertEquals } from "#test-assert";
|
|
16
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
17
|
+
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
18
|
+
import { pollReviews } from "./service.ts";
|
|
19
|
+
|
|
20
|
+
function memTable(rows: any[], key: string) {
|
|
21
|
+
return {
|
|
22
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
23
|
+
all: () => Promise.resolve([...rows]),
|
|
24
|
+
find: (q: any) =>
|
|
25
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
26
|
+
findOne: (q: any) =>
|
|
27
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
28
|
+
insert: (r: any) => {
|
|
29
|
+
rows.push(r);
|
|
30
|
+
return Promise.resolve(r);
|
|
31
|
+
},
|
|
32
|
+
update: (k: any, patch: any) => {
|
|
33
|
+
const r = rows.find((x) => x[key] === k);
|
|
34
|
+
if (r) Object.assign(r, patch);
|
|
35
|
+
return Promise.resolve(r);
|
|
36
|
+
},
|
|
37
|
+
delete: (k: any) => {
|
|
38
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REPO = "owner/repo";
|
|
45
|
+
const NUMBER = 7;
|
|
46
|
+
const HEAD_REF = "feat/x";
|
|
47
|
+
|
|
48
|
+
/** Stub the token transport for exactly the endpoints the stale-review branch reads:
|
|
49
|
+
* - the paged reviews list (one short page → complete),
|
|
50
|
+
* - the PR object (for the head ref/repo),
|
|
51
|
+
* - the atomic branch ref (`git/ref/heads/<branch>`) the shared reader prefers, and
|
|
52
|
+
* - the requested-reviewers GET/POST the re-solicitation nudge uses.
|
|
53
|
+
* `branchHead` drives staleness: when it differs from the review's `commit_id` the review is stale.
|
|
54
|
+
* Every non-GET request is recorded in `posts` so a test can assert the nudge's reviewer-request POST
|
|
55
|
+
* actually fired (a regression that dropped the stale branch's nudge would leave `posts` empty). */
|
|
56
|
+
function reviewFetch(opts: { reviewCommitId: string; branchHead: string; posts: string[] }) {
|
|
57
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
58
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
59
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
60
|
+
const json = (body: unknown, status = 200) =>
|
|
61
|
+
Promise.resolve(
|
|
62
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }),
|
|
63
|
+
);
|
|
64
|
+
if (u.includes(`/pulls/${NUMBER}/requested_reviewers`)) {
|
|
65
|
+
if (method !== "GET") {
|
|
66
|
+
opts.posts.push(`${method} ${u}`);
|
|
67
|
+
return json({}, 201); // reviewer requested
|
|
68
|
+
}
|
|
69
|
+
return json({ users: [] }); // none pending → the nudge proceeds to the POST
|
|
70
|
+
}
|
|
71
|
+
if (u.includes(`/pulls/${NUMBER}/reviews`)) {
|
|
72
|
+
return json([
|
|
73
|
+
{ id: 5, state: "COMMENTED", submitted_at: "2026-09-16T00:45:33Z", commit_id: opts.reviewCommitId },
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
if (u.includes(`/git/ref/heads/${HEAD_REF}`)) {
|
|
77
|
+
return json({ object: { sha: opts.branchHead } });
|
|
78
|
+
}
|
|
79
|
+
if (u.endsWith(`/pulls/${NUMBER}`)) {
|
|
80
|
+
return json({ head: { ref: HEAD_REF, sha: opts.branchHead, repo: { full_name: REPO } }, base: { ref: "main" } });
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`unexpected fetch: ${method} ${u}`);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function makeEngine() {
|
|
87
|
+
const published: Array<{ name: string; correlationKey: string }> = [];
|
|
88
|
+
const engine = {
|
|
89
|
+
publishMessage: (m: { name: string; correlationKey: string }) => {
|
|
90
|
+
published.push({ name: m.name, correlationKey: m.correlationKey });
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
},
|
|
93
|
+
} as any;
|
|
94
|
+
return { engine, published };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function withTokenTransport<T>(run: () => Promise<T>): Promise<T> {
|
|
98
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
99
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
100
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
101
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
102
|
+
return run().finally(() => {
|
|
103
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
104
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
105
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
106
|
+
else delete process.env["GITHUB_TOKEN"];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function prRow() {
|
|
111
|
+
return {
|
|
112
|
+
pr_key: `${REPO}#${NUMBER}`,
|
|
113
|
+
repo: REPO,
|
|
114
|
+
number: NUMBER,
|
|
115
|
+
status: "waiting_review",
|
|
116
|
+
last_review_id: 0,
|
|
117
|
+
waiting_since: "2026-09-16T00:00:00Z",
|
|
118
|
+
// An EXPIRED nudge timestamp so `maybeRerequestReview` does NOT short-circuit at its cooldown —
|
|
119
|
+
// the stale branch must actually re-solicit a fresh review, and the test asserts that POST fired
|
|
120
|
+
// (a recent nudge would mask a regression that dropped the nudge entirely, #799 review).
|
|
121
|
+
last_nudge_at: "2000-01-01T00:00:00Z",
|
|
122
|
+
updated_at: "t0",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("pollReviews: a STALE review (branch head past the review's commit) nudges but does NOT resume the loop", async () => {
|
|
127
|
+
const row = prRow();
|
|
128
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
129
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
130
|
+
};
|
|
131
|
+
const data = {
|
|
132
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
133
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
134
|
+
),
|
|
135
|
+
} as any;
|
|
136
|
+
const { engine, published } = makeEngine();
|
|
137
|
+
const posts: string[] = [];
|
|
138
|
+
const prevFetch = globalThis.fetch;
|
|
139
|
+
await withTokenTransport(async () => {
|
|
140
|
+
// review was submitted against SHA_OLD, but the branch head has advanced to SHA_NEW → stale.
|
|
141
|
+
globalThis.fetch = reviewFetch({ reviewCommitId: "SHA_OLD", branchHead: "SHA_NEW", posts }) as typeof fetch;
|
|
142
|
+
try {
|
|
143
|
+
await pollReviews(data, engine, "test-token");
|
|
144
|
+
} finally {
|
|
145
|
+
globalThis.fetch = prevFetch;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
assertEquals(published.length, 0, "no readiness-ready signal is published for a stale review");
|
|
149
|
+
assertEquals(row.last_review_id, 0, "last_review_id is NOT advanced past the stale review");
|
|
150
|
+
assertEquals(row.status, "waiting_review", "the PR stays parked awaiting a fresh review");
|
|
151
|
+
assertEquals(posts.length, 1, "the stale branch re-solicits a fresh Copilot review (one nudge POST)");
|
|
152
|
+
assertEquals(
|
|
153
|
+
posts[0],
|
|
154
|
+
`POST https://api.github.com/repos/${REPO}/pulls/${NUMBER}/requested_reviewers`,
|
|
155
|
+
"the nudge POSTs the requested-reviewers endpoint",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("pollReviews: a CURRENT-head review (control) resumes the loop and publishes the readiness signal", async () => {
|
|
160
|
+
const row = prRow();
|
|
161
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
162
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
163
|
+
};
|
|
164
|
+
const data = {
|
|
165
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
166
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
167
|
+
),
|
|
168
|
+
} as any;
|
|
169
|
+
const { engine, published } = makeEngine();
|
|
170
|
+
const posts: string[] = [];
|
|
171
|
+
const prevFetch = globalThis.fetch;
|
|
172
|
+
await withTokenTransport(async () => {
|
|
173
|
+
// review's commit_id equals the current branch head → NOT stale.
|
|
174
|
+
globalThis.fetch = reviewFetch({ reviewCommitId: "SHA_NEW", branchHead: "SHA_NEW", posts }) as typeof fetch;
|
|
175
|
+
try {
|
|
176
|
+
await pollReviews(data, engine, "test-token");
|
|
177
|
+
} finally {
|
|
178
|
+
globalThis.fetch = prevFetch;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
assertEquals(published.length, 1, "the readiness-ready signal is published for a fresh review");
|
|
182
|
+
assertEquals(published[0]?.name, READINESS_READY_MESSAGE);
|
|
183
|
+
assertEquals(published[0]?.correlationKey, `${REPO}#${NUMBER}`);
|
|
184
|
+
assertEquals(row.last_review_id, 5, "last_review_id advances to the fresh review");
|
|
185
|
+
assertEquals(row.status, "converging", "the loop resumes (status flips to converging)");
|
|
186
|
+
assertEquals(posts.length, 0, "a current-head review resumes directly — no re-solicitation nudge");
|
|
187
|
+
});
|
package/app/reviewWait.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
10
10
|
DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
11
11
|
isoDurationToMs,
|
|
12
|
+
isReviewStale,
|
|
12
13
|
MAX_REVIEW_NUDGE_MINUTES,
|
|
13
14
|
reviewWaitTimeout,
|
|
14
15
|
} from "./reviewWait.ts";
|
|
@@ -95,3 +96,35 @@ test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
|
|
|
95
96
|
test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
|
|
96
97
|
assertEquals(clampNudgeMinutes("", MAX_REVIEW_NUDGE_MINUTES + 50), MAX_REVIEW_NUDGE_MINUTES);
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
// ── isReviewStale (issue #799) ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
test("isReviewStale: differing review commit_id and HEAD sha is stale", () => {
|
|
103
|
+
assertEquals(isReviewStale("aaa1111", "bbb2222"), true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("isReviewStale: matching review commit_id and HEAD sha is NOT stale", () => {
|
|
107
|
+
assertEquals(isReviewStale("aaa1111", "aaa1111"), false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("isReviewStale: surrounding whitespace is ignored in the comparison", () => {
|
|
111
|
+
assertEquals(isReviewStale(" aaa1111 ", "aaa1111"), false);
|
|
112
|
+
assertEquals(isReviewStale("aaa1111", " bbb2222 "), true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("isReviewStale: a missing review commit_id fails safe to NOT stale", () => {
|
|
116
|
+
assertEquals(isReviewStale(null, "bbb2222"), false);
|
|
117
|
+
assertEquals(isReviewStale(undefined, "bbb2222"), false);
|
|
118
|
+
assertEquals(isReviewStale("", "bbb2222"), false);
|
|
119
|
+
assertEquals(isReviewStale(" ", "bbb2222"), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("isReviewStale: a missing HEAD sha fails safe to NOT stale", () => {
|
|
123
|
+
assertEquals(isReviewStale("aaa1111", null), false);
|
|
124
|
+
assertEquals(isReviewStale("aaa1111", undefined), false);
|
|
125
|
+
assertEquals(isReviewStale("aaa1111", ""), false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("isReviewStale: both missing is NOT stale", () => {
|
|
129
|
+
assertEquals(isReviewStale(null, null), false);
|
|
130
|
+
});
|
package/app/reviewWait.ts
CHANGED
|
@@ -93,3 +93,24 @@ export function clampNudgeMinutes(
|
|
|
93
93
|
if (i < 1) return safeFallback;
|
|
94
94
|
return Math.min(i, MAX_REVIEW_NUDGE_MINUTES);
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Is the latest Copilot review STALE relative to the PR's current HEAD? (issue #799)
|
|
98
|
+
*
|
|
99
|
+
* A review is stale when it was submitted against a commit the head has since moved past — its
|
|
100
|
+
* advisories describe code that no longer exists, so the convergence gate must NOT block/escalate
|
|
101
|
+
* against it and the poller must NOT unpark the loop on it; instead a fresh review of the current
|
|
102
|
+
* HEAD must be solicited and gated on. Staleness is a plain SHA inequality.
|
|
103
|
+
*
|
|
104
|
+
* Fails SAFE to "not stale" when either SHA is unknown (`null`/`undefined`/blank): GitHub did not
|
|
105
|
+
* carry a `commit_id` for the review, or the head could not be read. Without both SHAs we cannot
|
|
106
|
+
* prove the review predates the head, so we must not fabricate a stale verdict that would loop the
|
|
107
|
+
* loop re-soliciting forever — the review-wait timeout and the round cap remain the safety nets. */
|
|
108
|
+
export function isReviewStale(
|
|
109
|
+
reviewCommitId: string | null | undefined,
|
|
110
|
+
headSha: string | null | undefined,
|
|
111
|
+
): boolean {
|
|
112
|
+
const rev = (reviewCommitId ?? "").trim();
|
|
113
|
+
const head = (headSha ?? "").trim();
|
|
114
|
+
if (rev === "" || head === "") return false;
|
|
115
|
+
return rev !== head;
|
|
116
|
+
}
|