@nanobpm/nano-workforce 0.187.4 → 0.187.6
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/SPEC.md +93 -7
- package/app/convergeGate.test.ts +161 -4
- package/app/convergenceEscalationGuard.test.ts +3 -2
- package/app/currentHead.ts +60 -0
- package/app/github.test.ts +189 -1
- package/app/github.ts +134 -27
- package/app/persist-escalation.test.ts +7 -5
- package/app/persist-round.test.ts +178 -11
- package/app/pollReviewsStale.test.ts +187 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/reviewWait.test.ts +33 -0
- package/app/reviewWait.ts +21 -0
- package/app/roundProgress.test.ts +755 -33
- package/app/roundProgress.ts +144 -0
- package/app/roundResultDefault.test.ts +10 -8
- package/app/service.test.ts +14 -0
- package/app/service.ts +72 -2
- package/db/migrations/102_rounds_process_instance_key.sql +28 -0
- package/db/migrations/103_pr_progress_idempotency.sql +29 -0
- package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
- package/e2e/convergence-escalation.e2e.ts +5 -4
- package/e2e/feature-run.e2e.ts +6 -1
- package/e2e/plan-fanout-sla.e2e.ts +5 -2
- package/e2e/plan-fanout.e2e.ts +6 -2
- package/e2e/support/time.ts +34 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +238 -148
- package/resources/prompts/review-round.md +10 -0
- package/test/derivation-parity/README.md +3 -3
- package/test/derivation-parity/derivation-parity.test.ts +9 -3
- package/test/derivation-parity/flows.ts +4 -4
- package/workers/capture-head/worker.test.ts +77 -0
- package/workers/capture-head/worker.ts +64 -0
- package/workers/converge-gate/worker.ts +48 -21
- package/workers/persist-escalation/worker.ts +4 -0
- package/workers/persist-round/worker.ts +75 -9
- package/workers/progress-check/worker.ts +359 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.187.6](https://github.com/nanobpm/nano-workforce/compare/v0.187.5...v0.187.6) (2026-09-16)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **convergence:** re-solicit on a stale Copilot review instead of re-escalating ([#799](https://github.com/nanobpm/nano-workforce/issues/799)) ([#803](https://github.com/nanobpm/nano-workforce/issues/803)) ([58a99b6](https://github.com/nanobpm/nano-workforce/commit/58a99b6024af6cef37f724f9d50cf6151a6f061c)), closes [#789](https://github.com/nanobpm/nano-workforce/issues/789)
|
|
6
|
+
|
|
7
|
+
## [0.187.5](https://github.com/nanobpm/nano-workforce/compare/v0.187.4...v0.187.5) (2026-09-15)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **convergence:** classify husked review rounds and bound-retry the no-progress loop ([#789](https://github.com/nanobpm/nano-workforce/issues/789)) ([faebcdc](https://github.com/nanobpm/nano-workforce/commit/faebcdcaca017fab8d7436a50e3dc09354dc3022)), closes [jwulf/c8ctl-plugin-nano#230](https://github.com/jwulf/c8ctl-plugin-nano/issues/230) [#786](https://github.com/nanobpm/nano-workforce/issues/786) [#786](https://github.com/nanobpm/nano-workforce/issues/786)
|
|
12
|
+
|
|
1
13
|
## [0.187.4](https://github.com/nanobpm/nano-workforce/compare/v0.187.3...v0.187.4) (2026-09-14)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -101,11 +101,17 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
101
101
|
│ <gateway: status>
|
|
102
102
|
│ ├── converged → [Mark converged] → (end: converged)
|
|
103
103
|
│ │
|
|
104
|
-
│ ├── addressed → [Record round] →
|
|
105
|
-
│ │
|
|
106
|
-
│ │
|
|
107
|
-
│ │
|
|
108
|
-
│ │
|
|
104
|
+
│ ├── addressed → [Record round] → [Check progress] (did the PR head advance?)
|
|
105
|
+
│ │ ├── progressed → <guard: round ≥ maxRounds → escalate "not converged"> │
|
|
106
|
+
│ │ │ → <event-based gateway: review ready or timeout?> │
|
|
107
|
+
│ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
|
|
108
|
+
│ │ │ └── =reviewWaitTimeout (timer catch) │
|
|
109
|
+
│ │ │ → [Escalate: review stalled] (blocked) │
|
|
110
|
+
│ │ │ → [Wait: wait-answer userTask] ────────────────────┤
|
|
111
|
+
│ │ └── no progress → <husk? no commit AND no terminal instance> │
|
|
112
|
+
│ │ ├── husk & retries < MAX → re-enter [Review round] (bypasses the round-cap guard) │
|
|
113
|
+
│ │ └── no-advance / husk cap → [Escalate: no progress] │
|
|
114
|
+
│ │ → [Wait: wait-answer userTask] ───────────────────┤
|
|
109
115
|
│ │ │
|
|
110
116
|
│ └── needs_input [Record escalation] │ │
|
|
111
117
|
│ or blocked → (kind = question | blocker) │ │
|
|
@@ -124,11 +130,38 @@ step, then retry the same round with the human's `answer`. They differ only by e
|
|
|
124
130
|
which the UI uses to label the card. Neither ends the run — a human always gets
|
|
125
131
|
a chance to unblock and resume.
|
|
126
132
|
|
|
127
|
-
Guard:
|
|
128
|
-
("not converged after N rounds") so a human
|
|
133
|
+
Guard: after progress classification, a **progressing** round with round ≥
|
|
134
|
+
MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
|
|
135
|
+
decides rather than looping forever. The guard sits *after* `check-progress`
|
|
136
|
+
(not before), so a husk auto-retry — which does not consume a round — bypasses
|
|
137
|
+
the cap and is re-tried onto a healthy worker even on the final configured round.
|
|
138
|
+
The **stale-review re-solicitation** path (below) likewise bypasses the cap: its
|
|
139
|
+
`f_guardMax` arm is gated on `round ≥ maxRounds and reviewStale != true`, so a
|
|
140
|
+
stale review received on the final configured round re-solicits a fresh review
|
|
141
|
+
instead of escalating (the review-wait timeout remains the backstop).
|
|
129
142
|
```
|
|
130
143
|
|
|
131
144
|
Notes:
|
|
145
|
+
- **Convergence comment-gate + stale-review re-solicitation (issue #799).** The
|
|
146
|
+
agent's self-reported `converged` does not finalize directly: it first runs the
|
|
147
|
+
deterministic `pr.converge-gate` (`check-converge` → `gw-converge-gate`). That
|
|
148
|
+
gate **blocks** convergence (`convergeBlocked = true` → escalate "unaddressed
|
|
149
|
+
comments") while any review thread is unresolved or any suppressed advisory
|
|
150
|
+
lacks a resolved `nano-ack:` thread; otherwise it proceeds to the scope
|
|
151
|
+
classifier and finalizes. A third arm handles a **stale review** — one whose
|
|
152
|
+
`commit_id` predates the PR's current HEAD (its advisories describe code the
|
|
153
|
+
head has moved past, e.g. an advisory already fixed in a later commit). Rather
|
|
154
|
+
than block/escalate on the obsolete body, the gate signals `reviewStale = true`
|
|
155
|
+
and `f_convergeStale` re-enters `persist-round` → `check-progress` (which parks
|
|
156
|
+
the PR in `waiting_review`, the single writer), so the poller re-solicits a
|
|
157
|
+
fresh review of the current HEAD. The head is read via the shared
|
|
158
|
+
branch-ref-preferring reader (`makeDefaultReadHead`, atomic with the push, #786)
|
|
159
|
+
in BOTH the gate and the poller so they agree on the current head. `reviewStale`
|
|
160
|
+
is written only by the gate and cleared on BOTH loop re-entry paths — by the
|
|
161
|
+
`wait-review` catch when a fresh review lands, and by `record-answer` when a
|
|
162
|
+
human resumes after the review-stall timer — so the marker cannot leak into a
|
|
163
|
+
later round. Because a stale review is not a failure to converge, this path
|
|
164
|
+
bypasses the round cap (see the Guard above).
|
|
132
165
|
- On `addressed`, the loop parks at an **event-based gateway** that races the
|
|
133
166
|
canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
|
|
134
167
|
poller when a fresh review lands)
|
|
@@ -149,6 +182,59 @@ Notes:
|
|
|
149
182
|
backstop when even repeated nudges fail.
|
|
150
183
|
- On `needs_input`, the same `round` is retried after the answer (the answer is
|
|
151
184
|
added to the agent's context; the round number does not advance).
|
|
185
|
+
- **No-progress guard + husk classification (issue #786).** Before the review
|
|
186
|
+
wait, an `addressed` round passes through `pr.progress-check`
|
|
187
|
+
(`workers/progress-check/worker.ts`, mirrored by `app/roundProgress.ts`): it
|
|
188
|
+
reads the PR's current head SHA (the branch ref, atomic with the push) and
|
|
189
|
+
compares it to the **round-entry head** — the head captured by `pr.capture-head`
|
|
190
|
+
immediately BEFORE `review-round` ran this round, published as the
|
|
191
|
+
`roundEntryHead` process variable. `pr.capture-head` sits on EVERY entry into
|
|
192
|
+
`review-round` (the first round from `Start`, a review-loop re-enter, a
|
|
193
|
+
human-answer resume, and a husk auto-retry), so within any round there is always
|
|
194
|
+
a baseline captured against the agent's own starting point — closing the
|
|
195
|
+
no-baseline gap where a FIRST addressed round had no prior-round head to compare
|
|
196
|
+
against (and either waved a first-round husk through as progress, or risked
|
|
197
|
+
mis-escalating a straggler push). If `roundEntryHead` is absent — an older
|
|
198
|
+
in-flight instance whose flow predates `capture-head`, or a capture read that
|
|
199
|
+
failed open (it publishes the empty string as its "unknown" sentinel) —
|
|
200
|
+
progress-check falls back to the head persisted from the previous round
|
|
201
|
+
(`last_round_head`). A round whose head DID advance past the round-entry baseline
|
|
202
|
+
is real progress and continues to the review-wait gateway. A round whose head did
|
|
203
|
+
NOT advance pushed no commit, so re-requesting a review would loop on
|
|
204
|
+
byte-identical code; `gw-progress` routes it to `gw-husk`, which SPLITS it on a
|
|
205
|
+
corroboration correlated to the COMPLETING `review-round` element-instance (NOT
|
|
206
|
+
an aggregate terminal count — a same-round human-answered resume is classified on
|
|
207
|
+
its own fresh attempt):
|
|
208
|
+
- a **husk** — no commit AND the completing `review-round` attempt is
|
|
209
|
+
NON-TERMINAL (the producer harness died mid-run, leaving a stuck instance) —
|
|
210
|
+
is auto-re-run onto a healthy worker up to `MAX_HUSK_RETRIES` (2) before
|
|
211
|
+
escalating; and
|
|
212
|
+
- a **no-advance** — the completing attempt ran to a terminal instance but
|
|
213
|
+
nothing was pushed — (and a husk that exhausts its retries) escalates to the
|
|
214
|
+
human `wait-answer` task.
|
|
215
|
+
The agent-instance read is AVAILABILITY-AWARE via a **two-tier probe** and fails
|
|
216
|
+
SAFE (ADR 0056). `review-round` is an external-agent service task, so a job that
|
|
217
|
+
husks BEFORE it ever registers an AgentInstance leaves the scoped `review-round`
|
|
218
|
+
search EMPTY — indistinguishable, on that query alone, from an engine that has no
|
|
219
|
+
AgentInstance projection at all. The probe therefore resolves an empty
|
|
220
|
+
`review-round` search against a SECOND, process-wide read:
|
|
221
|
+
- if the process-wide read also finds NO instance, the **channel is absent**
|
|
222
|
+
(an engine with no AgentInstance projection) — UNKNOWN, treated as no-advance,
|
|
223
|
+
never an auto-retry that could duplicate genuinely-completed work;
|
|
224
|
+
- if the process-wide read finds ANOTHER instance (from `classify-scope`, an
|
|
225
|
+
earlier round, etc.), the **channel is PRESENT** but this round registered
|
|
226
|
+
nothing — a genuine **pre-registration husk**, so it is classified as a husk
|
|
227
|
+
and auto-retried.
|
|
228
|
+
A head that cannot be read fails OPEN (continue), so a transient GitHub hiccup
|
|
229
|
+
never fabricates a no-progress escalation. Two supporting invariants keep an
|
|
230
|
+
auto-retry clean: `pr.persist-round`
|
|
231
|
+
records a round IDEMPOTENTLY on `(pr_key, round_no, process_instance_key)` — a husk
|
|
232
|
+
retry (same process instance) updates its row in place, while a resubmission that
|
|
233
|
+
re-opens the PR at round 1 in a NEW process instance inserts a fresh row and so
|
|
234
|
+
never clobbers a prior run's durable round history (migration 102) — and the guard flips the PR back to
|
|
235
|
+
the running `converging` status before a retry re-enters `review-round` so the
|
|
236
|
+
poller does not solicit a spurious review against the still-running round. The
|
|
237
|
+
round cap and the review-wait timeout remain the outer safety nets.
|
|
152
238
|
|
|
153
239
|
|
|
154
240
|
## 5. Agent job contract (`senior:pr-review`)
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
parseAckedAdvisories,
|
|
19
19
|
parseReviewThreadsPage,
|
|
20
20
|
parseSuppressedAdvisories,
|
|
21
|
+
pickLatestCopilotReview,
|
|
21
22
|
pickLatestCopilotReviewBody,
|
|
22
23
|
type ReviewThread,
|
|
23
24
|
} from "./github.ts";
|
|
@@ -432,12 +433,63 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
|
|
|
432
433
|
);
|
|
433
434
|
});
|
|
434
435
|
|
|
436
|
+
// The COMMIT-ID-carrying picker (`pickLatestCopilotReview`) is the ONLY production path that carries
|
|
437
|
+
// GitHub's `commit_id` into the stale-review guard (#799); the worker tests inject `{ commitId }`
|
|
438
|
+
// directly, so without these the picker's commit_id selection could regress (disabling stale
|
|
439
|
+
// detection) while every other test stays green. These lock: the NEWEST Copilot review's commit_id
|
|
440
|
+
// (and body) is returned; a no-Copilot-review read is verified `{ body: "", commitId: null }`; a
|
|
441
|
+
// truncated read fails closed to `null`.
|
|
442
|
+
test("pickLatestCopilotReview: returns the NEWEST Copilot review's body AND commit_id (oldest\u2192newest)", () => {
|
|
443
|
+
const picked = pickLatestCopilotReview(
|
|
444
|
+
[
|
|
445
|
+
{ user: { login: "human" }, body: "human review", commit_id: "humansha" },
|
|
446
|
+
{ user: { login: "Copilot" }, body: "old copilot review", commit_id: "oldsha" },
|
|
447
|
+
{ user: { login: "Copilot" }, body: "newest copilot review", commit_id: "newsha" },
|
|
448
|
+
],
|
|
449
|
+
false,
|
|
450
|
+
);
|
|
451
|
+
assertEquals(picked, { body: "newest copilot review", commitId: "newsha" });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('pickLatestCopilotReview: a complete read with NO Copilot review is verified { body: "", commitId: null }', () => {
|
|
455
|
+
assertEquals(pickLatestCopilotReview([{ user: { login: "human" }, body: "hi", commit_id: "x" }], false), {
|
|
456
|
+
body: "",
|
|
457
|
+
commitId: null,
|
|
458
|
+
});
|
|
459
|
+
assertEquals(pickLatestCopilotReview([], false), { body: "", commitId: null });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("pickLatestCopilotReview: a Copilot review missing commit_id yields commitId null (not stale)", () => {
|
|
463
|
+
// A review with no commit_id must not fabricate a stale verdict: `isReviewStale` fails safe on a
|
|
464
|
+
// null review commit_id, and this picker must surface that null rather than an empty string.
|
|
465
|
+
assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "b" }], false), {
|
|
466
|
+
body: "b",
|
|
467
|
+
commitId: null,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("pickLatestCopilotReview: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
|
|
472
|
+
assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "possibly stale", commit_id: "s" }], true), null);
|
|
473
|
+
});
|
|
474
|
+
|
|
435
475
|
async function makeUnderTest(deps: {
|
|
436
476
|
readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
|
|
437
477
|
readReviewBody: (repo: string, n: number) => Promise<string | null>;
|
|
478
|
+
// The commit SHA the latest Copilot review was submitted against, and the PR's current HEAD SHA
|
|
479
|
+
// (issue #799). Absent ⇒ both null ⇒ never stale (the pre-#799 behaviour every existing test
|
|
480
|
+
// relies on). A stale-review test supplies a `reviewCommitId` that differs from `headSha`.
|
|
481
|
+
reviewCommitId?: string | null;
|
|
482
|
+
headSha?: string | null;
|
|
438
483
|
}) {
|
|
439
484
|
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
440
|
-
return makeHandler(
|
|
485
|
+
return makeHandler({
|
|
486
|
+
readThreads: deps.readThreads,
|
|
487
|
+
readReview: async (repo, n) => {
|
|
488
|
+
const body = await deps.readReviewBody(repo, n);
|
|
489
|
+
return body === null ? null : { body, commitId: deps.reviewCommitId ?? null };
|
|
490
|
+
},
|
|
491
|
+
readHeadSha: async () => deps.headSha ?? null,
|
|
492
|
+
});
|
|
441
493
|
}
|
|
442
494
|
|
|
443
495
|
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
@@ -446,7 +498,7 @@ test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
|
446
498
|
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
447
499
|
});
|
|
448
500
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
449
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
501
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
450
502
|
});
|
|
451
503
|
|
|
452
504
|
test("converge-gate: an unresolved thread blocks convergence", async () => {
|
|
@@ -488,7 +540,7 @@ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed",
|
|
|
488
540
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
489
541
|
});
|
|
490
542
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
491
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
543
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
492
544
|
});
|
|
493
545
|
|
|
494
546
|
test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
|
|
@@ -544,7 +596,7 @@ test("converge-gate: a non-string prKey does not throw — resolves from repo/pr
|
|
|
544
596
|
readReviewBody: async () => "",
|
|
545
597
|
});
|
|
546
598
|
const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
547
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
599
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
548
600
|
});
|
|
549
601
|
|
|
550
602
|
test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
|
|
@@ -570,6 +622,57 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
|
|
|
570
622
|
assertEquals(seen, ["o/r", 7]);
|
|
571
623
|
});
|
|
572
624
|
|
|
625
|
+
// ── Stale-review guard (issue #799) ──────────────────────────────────────────
|
|
626
|
+
// FM2: when the PR HEAD has advanced past the commit the latest Copilot review was submitted
|
|
627
|
+
// against, that review is stale — its suppressed advisories may already be fixed in code. The gate
|
|
628
|
+
// must NOT block/escalate on it; it must signal `reviewStale` so the loop re-solicits a fresh
|
|
629
|
+
// review of the current HEAD. FM1 (an applied-but-unacked advisory re-escalating forever) is
|
|
630
|
+
// structurally cured by this: once the fixing commit lands, the review that still lists the
|
|
631
|
+
// advisory is stale, so the gate re-solicits instead of re-blocking.
|
|
632
|
+
|
|
633
|
+
test("converge-gate #799: a STALE review (commit_id predates HEAD) does not block — signals reviewStale", async () => {
|
|
634
|
+
// The review still lists an unacked suppressed advisory (would block if evaluated), but it was
|
|
635
|
+
// submitted against an OLD commit — the agent has since pushed a fix. The gate must re-solicit,
|
|
636
|
+
// not escalate.
|
|
637
|
+
const handler = await makeUnderTest({
|
|
638
|
+
readThreads: async () => [],
|
|
639
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
640
|
+
reviewCommitId: "oldsha1111111111111111111111111111111111",
|
|
641
|
+
headSha: "newsha2222222222222222222222222222222222",
|
|
642
|
+
});
|
|
643
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
644
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: true });
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
|
|
648
|
+
// Same unacked advisory, but the review's commit_id MATCHES HEAD — it is fresh, so the ordinary
|
|
649
|
+
// gate runs and blocks. Proves the stale guard does not swallow a genuine block.
|
|
650
|
+
const handler = await makeUnderTest({
|
|
651
|
+
readThreads: async () => [],
|
|
652
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
653
|
+
reviewCommitId: "samesha33333333333333333333333333333333",
|
|
654
|
+
headSha: "samesha33333333333333333333333333333333",
|
|
655
|
+
});
|
|
656
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
657
|
+
assertEquals(out.convergeBlocked, true);
|
|
658
|
+
assertEquals(out.reviewStale, false);
|
|
659
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT stale (evaluates normally)", async () => {
|
|
663
|
+
// Fail-safe: without both SHAs we cannot prove staleness, so the gate must fall through to the
|
|
664
|
+
// ordinary evaluation (here: block on the unacked advisory), never fabricate a re-solicit loop.
|
|
665
|
+
const handler = await makeUnderTest({
|
|
666
|
+
readThreads: async () => [],
|
|
667
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
668
|
+
reviewCommitId: null,
|
|
669
|
+
headSha: "newsha2222222222222222222222222222222222",
|
|
670
|
+
});
|
|
671
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
672
|
+
assertEquals(out.convergeBlocked, true);
|
|
673
|
+
assertEquals(out.reviewStale, false);
|
|
674
|
+
});
|
|
675
|
+
|
|
573
676
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
574
677
|
|
|
575
678
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
@@ -606,6 +709,60 @@ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition",
|
|
|
606
709
|
assertStringIncludes(f, "convergeBlocked = true");
|
|
607
710
|
});
|
|
608
711
|
|
|
712
|
+
test("gw-converge-gate routes a STALE review back to persist-round (re-solicit), not to escalation (#799)", () => {
|
|
713
|
+
const f = flowElement("f_convergeStale");
|
|
714
|
+
assert(f, "f_convergeStale flow missing");
|
|
715
|
+
assertStringIncludes(f, 'sourceRef="gw-converge-gate"');
|
|
716
|
+
// A stale review re-enters the round loop via persist-round → check-progress, which parks
|
|
717
|
+
// waiting_review (the single writer) and the poller re-solicits a fresh review.
|
|
718
|
+
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
719
|
+
assertStringIncludes(f, "reviewStale = true");
|
|
720
|
+
// persist-round must accept the stale re-entry as an incoming.
|
|
721
|
+
const pr = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
722
|
+
assert(pr, "persist-round task missing");
|
|
723
|
+
assertStringIncludes(pr[0], "<bpmn:incoming>f_convergeStale</bpmn:incoming>");
|
|
724
|
+
// The gate's output envelope must declare reviewStale for the FEEL condition to read it.
|
|
725
|
+
assertStringIncludes(flat, 'id="PrConvergeGateOut"');
|
|
726
|
+
assert(
|
|
727
|
+
/<nano:shape\b[^>]*\bid="PrConvergeGateOut"[^>]*>.*?name="reviewStale".*?<\/nano:shape>/.test(flat),
|
|
728
|
+
"PrConvergeGateOut must declare reviewStale",
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("the round cap does NOT escalate a stale-retry round — f_guardMax is gated on reviewStale != true (#799)", () => {
|
|
733
|
+
// A stale review re-enters persist-round → check-progress → gw-guard. On the FINAL configured round
|
|
734
|
+
// the cap would escalate a human instead of soliciting a fresh review — but a stale review is not a
|
|
735
|
+
// failure to converge, it is a review of obsolete code. The round cap must therefore bypass the
|
|
736
|
+
// stale-retry path (the review-wait timeout remains the backstop against an indefinitely stalled
|
|
737
|
+
// re-solicitation).
|
|
738
|
+
const f = flowElement("f_guardMax");
|
|
739
|
+
assert(f, "f_guardMax flow missing");
|
|
740
|
+
assertStringIncludes(f, "maxRounds and reviewStale != true");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
|
|
744
|
+
// reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
|
|
745
|
+
// known-stale, so wait-review resets it to false alongside the round increment. Without this reset
|
|
746
|
+
// a stale marker would persist and disable the round cap for a subsequent addressed round.
|
|
747
|
+
const wr = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[^>]*>.*?<\/bpmn:intermediateCatchEvent>/);
|
|
748
|
+
assert(wr, "wait-review catch event missing");
|
|
749
|
+
assertStringIncludes(wr[0], '<zeebe:output source="=round + 1" target="round" />');
|
|
750
|
+
assertStringIncludes(wr[0], '<zeebe:output source="=false" target="reviewStale" />');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("record-answer clears reviewStale so a human-resumed round after a review-stall timeout is re-capped (#799)", () => {
|
|
754
|
+
// reviewStale is cleared on the wait-review MESSAGE arm, but the review-wait TIMER arm bypasses it:
|
|
755
|
+
// wait-review-timeout → persist-review-stalled → wait-answer → record-answer → capture-head. A
|
|
756
|
+
// stale-retry round that times out and is resumed by a human therefore re-enters the loop still
|
|
757
|
+
// carrying reviewStale = true, which — via `f_guardMax`'s `reviewStale != true` guard — would keep
|
|
758
|
+
// the round cap disabled for that (and every subsequent) human-directed addressed round. Once a
|
|
759
|
+
// human is answering escalations the automated stale-retry is over, so record-answer must reset the
|
|
760
|
+
// marker; a genuinely-still-stale next review re-sets it at the converge-gate.
|
|
761
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
762
|
+
assert(task, "record-answer task missing");
|
|
763
|
+
assertStringIncludes(task[0], '<zeebe:output source="=false" target="reviewStale" />');
|
|
764
|
+
});
|
|
765
|
+
|
|
609
766
|
test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
|
|
610
767
|
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
|
|
611
768
|
assert(gw, "gw-converge-gate gateway missing");
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
//
|
|
20
20
|
// The fix (mirroring the merge loop's `gw-merge-escalated`, PR #331): route EVERY arm that can reach
|
|
21
21
|
// `wait-answer` through the single `gw-escalated` guard, so a `persist-escalation` returning
|
|
22
|
-
// `escalated:false` RE-ENTERS the loop (`
|
|
22
|
+
// `escalated:false` RE-ENTERS the loop (round processing, `persist-round`) instead of parking a dead
|
|
23
|
+
// wait. This makes the
|
|
23
24
|
// invariant structural — `wait-answer` is reachable ONLY from a `gw-escalated == true` edge, so a
|
|
24
25
|
// "durable answer-wait with no escalation" is unrepresentable.
|
|
25
26
|
//
|
|
@@ -77,7 +78,7 @@ test("gw-escalated honours persist-escalation's escalated output for every arm",
|
|
|
77
78
|
assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
|
|
78
79
|
// escalated:false (a non-escalation, e.g. a blank convergeBlockReason) → re-enter the loop, not a dead wait.
|
|
79
80
|
assert(gatewayDefault("gw-escalated", "f_escReenter"), "gw-escalated default must re-enter the loop");
|
|
80
|
-
assert(flowHasId("f_escReenter", "gw-escalated", "
|
|
81
|
+
assert(flowHasId("f_escReenter", "gw-escalated", "persist-round"), "the non-escalation arm must re-enter round processing (persist-round), not park a wait");
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
test("wait-answer is reachable ONLY from the gw-escalated == true edge (structural invariant)", () => {
|
|
@@ -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, fetchIssueTitle, fetchPrFiles, 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,
|
|
@@ -723,3 +787,127 @@ test("checkConclusions: in-flight runs map to '' for both CheckRun and StatusCon
|
|
|
723
787
|
"legacy-error": "ERROR",
|
|
724
788
|
});
|
|
725
789
|
});
|
|
790
|
+
|
|
791
|
+
// ── fetchBranchHead — the atomic branch-ref reader (issue #786) ──────────────
|
|
792
|
+
//
|
|
793
|
+
// The no-progress guard reads the branch ref (git/ref/heads/<branch>), updated ATOMICALLY with the
|
|
794
|
+
// push, rather than the PR object's asynchronously-denormalized head.sha, so a lagging PR projection
|
|
795
|
+
// can never fabricate a stale-but-valid no-advance escalation. Force the token transport and stub
|
|
796
|
+
// `globalThis.fetch` to serve the git-ref endpoint.
|
|
797
|
+
async function withRefFetch<T>(
|
|
798
|
+
serve: (path: string) => { status: number; body: unknown },
|
|
799
|
+
fn: () => Promise<T>,
|
|
800
|
+
): Promise<T> {
|
|
801
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
802
|
+
const prevFetch = globalThis.fetch;
|
|
803
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
804
|
+
globalThis.fetch = ((url: string | URL | Request): Promise<Response> => {
|
|
805
|
+
const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
|
|
806
|
+
const { status, body } = serve(path);
|
|
807
|
+
return Promise.resolve(new Response(JSON.stringify(body), { status }));
|
|
808
|
+
}) as typeof fetch;
|
|
809
|
+
try {
|
|
810
|
+
return await fn();
|
|
811
|
+
} finally {
|
|
812
|
+
globalThis.fetch = prevFetch;
|
|
813
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
814
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
test("fetchBranchHead: returns the branch ref's atomic head SHA", async () => {
|
|
819
|
+
const sha = await withRefFetch(
|
|
820
|
+
(path) => {
|
|
821
|
+
assertEquals(path, "o/r/git/ref/heads/feat/x");
|
|
822
|
+
return { status: 200, body: { object: { sha: "deadbeef" } } };
|
|
823
|
+
},
|
|
824
|
+
() => fetchBranchHead("o/r", "feat/x", "tok"),
|
|
825
|
+
);
|
|
826
|
+
assertEquals(sha, "deadbeef");
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("fetchBranchHead: a 404 (branch absent) resolves to null, never throws", async () => {
|
|
830
|
+
const sha = await withRefFetch(
|
|
831
|
+
() => ({ status: 404, body: { message: "Not Found" } }),
|
|
832
|
+
() => fetchBranchHead("o/r", "feat/missing", "tok"),
|
|
833
|
+
);
|
|
834
|
+
assertEquals(sha, null);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("fetchBranchHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
|
|
838
|
+
// The documented contract promises `null` when no transport is usable, matching fetchPrHead /
|
|
839
|
+
// fetchPrBase — a missing token under the token transport must not surface an exception to callers
|
|
840
|
+
// relying on the Promise<string | null> shape.
|
|
841
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
842
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
843
|
+
try {
|
|
844
|
+
const sha = await fetchBranchHead("o/r", "feat/x", "");
|
|
845
|
+
assertEquals(sha, null);
|
|
846
|
+
} finally {
|
|
847
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
848
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// ── fetchPrHead — the PR head reader surfaces the head branch's OWNING repo (issue #786) ─────
|
|
853
|
+
//
|
|
854
|
+
// The no-progress head reader resolves the head ref in `headRepo`, so a cross-repo (fork) PR reads
|
|
855
|
+
// the fork's ref, not a same-named branch in the base repo (which would resolve to an unrelated
|
|
856
|
+
// SHA). These assert the transport-level mapping of the source repository through `fetchPrHead`'s
|
|
857
|
+
// REST branch (forced via the token transport), which the handler-level tests — injecting an
|
|
858
|
+
// already-parsed `{ headRepo }` — do not exercise.
|
|
859
|
+
test("fetchPrHead: REST maps head.repo.full_name to the fork's source repository", async () => {
|
|
860
|
+
const head = await withRefFetch(
|
|
861
|
+
(path) => {
|
|
862
|
+
assertEquals(path, "base/repo/pulls/789");
|
|
863
|
+
return {
|
|
864
|
+
status: 200,
|
|
865
|
+
body: {
|
|
866
|
+
head: { ref: "feat/x", sha: "cafef00d", repo: { full_name: "fork-owner/repo" } },
|
|
867
|
+
base: { ref: "main" },
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
},
|
|
871
|
+
() => fetchPrHead("base/repo", 789, "tok"),
|
|
872
|
+
);
|
|
873
|
+
assertEquals(head, { headRef: "feat/x", headSha: "cafef00d", baseRef: "main", headRepo: "fork-owner/repo" });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("fetchPrHead: REST fails open to headRepo=null when the head repo is absent (deleted fork)", async () => {
|
|
877
|
+
const head = await withRefFetch(
|
|
878
|
+
() => ({
|
|
879
|
+
status: 200,
|
|
880
|
+
body: { head: { ref: "feat/x", sha: "cafef00d", repo: null }, base: { ref: "main" } },
|
|
881
|
+
}),
|
|
882
|
+
() => fetchPrHead("base/repo", 789, "tok"),
|
|
883
|
+
);
|
|
884
|
+
// A null head repo must surface as headRepo=null (the reader then fails open), never the base repo.
|
|
885
|
+
assertEquals(head?.headRepo, null);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("fetchPrHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
|
|
889
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
890
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
891
|
+
try {
|
|
892
|
+
const head = await fetchPrHead("o/r", 1, "");
|
|
893
|
+
assertEquals(head, null);
|
|
894
|
+
} finally {
|
|
895
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
896
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// A branch name may legally contain `#`, `?`, or spaces. The reader must percent-encode each ref
|
|
901
|
+
// SEGMENT (preserving `/`) before building the API path/URL — otherwise a `#` starts a URL fragment,
|
|
902
|
+
// the path is truncated to the wrong ref, and the no-progress guard fails open (issue #786).
|
|
903
|
+
test("fetchBranchHead: percent-encodes a special-character branch ref (preserving '/')", async () => {
|
|
904
|
+
const sha = await withRefFetch(
|
|
905
|
+
(path) => {
|
|
906
|
+
// The `#` must survive as %23 inside the path, not truncate it into a URL fragment.
|
|
907
|
+
assertEquals(path, "o/r/git/ref/heads/feat/x%23123");
|
|
908
|
+
return { status: 200, body: { object: { sha: "cafef00d" } } };
|
|
909
|
+
},
|
|
910
|
+
() => fetchBranchHead("o/r", "feat/x#123", "tok"),
|
|
911
|
+
);
|
|
912
|
+
assertEquals(sha, "cafef00d");
|
|
913
|
+
});
|