@nanobpm/nano-workforce 0.198.1 → 0.200.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/SPEC.md +23 -1
- package/app/contracts.ts +8 -0
- package/app/convergeAdversarial.test.ts +294 -0
- package/app/roundProgress.test.ts +7 -1
- package/app/roundResultDefault.test.ts +24 -2
- package/app/service.test.ts +178 -1
- package/app/service.ts +70 -11
- package/nano.app.json +2 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +291 -163
- package/resources/prompts/adversarial-review.md +116 -0
- package/resources/prompts/fix-ci.md +16 -8
- package/resources/prompts/rebase.md +12 -6
- package/resources/prompts/review-round.md +93 -26
- package/resources/prompts/scope-classify.md +6 -2
- package/scripts/prompt-result-examples.test.ts +421 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.200.0](https://github.com/nanobpm/nano-workforce/compare/v0.199.0...v0.200.0) (2026-10-02)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **convergence:** local adversarial-review agent pass before Copilot ([#845](https://github.com/nanobpm/nano-workforce/issues/845)) ([3aec0da](https://github.com/nanobpm/nano-workforce/commit/3aec0da99224dd29f43566a9c75a5cf34dbc843a)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#822](https://github.com/nanobpm/nano-workforce/issues/822)
|
|
6
|
+
|
|
7
|
+
## [0.199.0](https://github.com/nanobpm/nano-workforce/compare/v0.198.1...v0.199.0) (2026-10-02)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **review-round:** fix the bug class + self-review before push; heredoc result examples ([#843](https://github.com/nanobpm/nano-workforce/issues/843)) ([0b2f26f](https://github.com/nanobpm/nano-workforce/commit/0b2f26f817f15191ef530075d34f6131c99e43cb)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842)
|
|
12
|
+
|
|
1
13
|
## [0.198.1](https://github.com/nanobpm/nano-workforce/compare/v0.198.0...v0.198.1) (2026-10-02)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -110,7 +110,13 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
110
110
|
│ │ → [Escalate: unaddressed comments] (blocked) │
|
|
111
111
|
│ │ → [Wait: wait-answer userTask] ────────────────────────────┤
|
|
112
112
|
│ │ │
|
|
113
|
-
│ ├── addressed →
|
|
113
|
+
│ ├── addressed → <adversarial pass due? (#844)> │
|
|
114
|
+
│ │ (or waiting) ├── pass due (addressed, or first push; budget unspent) │
|
|
115
|
+
│ │ │ → [Adversarial review] (service task, taskType: senior:adversarial-review) │
|
|
116
|
+
│ │ │ → <findings?> ── findings → re-dispatch [Review round] (round unchanged) │
|
|
117
|
+
│ │ │ └── clean → [Record round] │
|
|
118
|
+
│ │ └── disabled / budget spent → [Record round] │
|
|
119
|
+
│ │ [Record round] → [Check progress] (did the PR head advance?) │
|
|
114
120
|
│ │ ├── progressed → <guard: round ≥ maxRounds → escalate "not converged"> │
|
|
115
121
|
│ │ │ → <event-based gateway: review ready or timeout?> │
|
|
116
122
|
│ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐ │
|
|
@@ -139,6 +145,21 @@ step, then retry the same round with the human's `answer`. They differ only by e
|
|
|
139
145
|
which the UI uses to label the card. Neither ends the run — a human always gets
|
|
140
146
|
a chance to unblock and resume.
|
|
141
147
|
|
|
148
|
+
**Local adversarial-review stage (issue #844).** Before an `addressed` or first-push
|
|
149
|
+
`waiting` round parks on `waiting_review` (the only status the poller solicits a Copilot
|
|
150
|
+
review for), the loop runs a bounded local adversarial pass over the round's diff. The
|
|
151
|
+
`<adversarial pass due?>` gate (`gw-adv-due`) fires when the per-round budget is unspent
|
|
152
|
+
and the round is `addressed` **or** the first push (`advMax != null and advPass < advMax
|
|
153
|
+
and (status != "waiting" or round = 1)`); otherwise (disabled, budget spent) it skips
|
|
154
|
+
straight to `[Record round]`. The `senior:adversarial-review` service task (prompt
|
|
155
|
+
delivered via the `prompts/adversarial-review.md` linked resource) critiques the diff and
|
|
156
|
+
returns `adversarialFindings`. Non-blank findings **re-dispatch `[Review round]` within the
|
|
157
|
+
same round** (round unchanged) with `adversarialFindings` seeded, so the agent fixes them
|
|
158
|
+
before this app's poller solicits the Copilot review (the only `waiting_review` trigger); a
|
|
159
|
+
clean result records the round and proceeds to `[Check progress]`. The pass budget is
|
|
160
|
+
`MAX_ADVERSARIAL_PASSES` (default 1,
|
|
161
|
+
`NANO_PR_MAX_ADVERSARIAL_PASSES=0` disables the stage).
|
|
162
|
+
|
|
142
163
|
**Durable adjudication auto-resume (issue #806).** A human's answer to a `wait-answer`
|
|
143
164
|
is remembered durably, keyed by `(PR, canonical question fingerprint)` — the
|
|
144
165
|
`record-answer` step persists it (`pr_adjudications`), and before the poller surfaces a
|
|
@@ -350,6 +371,7 @@ Notes:
|
|
|
350
371
|
| `prNumber` | int | |
|
|
351
372
|
| `round` | int | 1-based round counter |
|
|
352
373
|
| `answer` | string? | present only when resuming from an escalation |
|
|
374
|
+
| `adversarialFindings` | string? | present only when the local adversarial-review stage (§4) produced findings on this round's push — the agent is re-dispatched within the same round to address them |
|
|
353
375
|
|
|
354
376
|
The base instructions are **not** a job variable: they are delivered as a
|
|
355
377
|
**linked resource** on the `senior:pr-review` task —
|
package/app/contracts.ts
CHANGED
|
@@ -140,6 +140,14 @@ export const ENV_CONTRACTS = {
|
|
|
140
140
|
"Maximum bounded review-round re-dispatches the convergence loop makes to auto-ack unacked suppressed advisories before escalating to a human (#796); 0 escalates on the first ack-only block.",
|
|
141
141
|
default: "2",
|
|
142
142
|
},
|
|
143
|
+
NANO_PR_MAX_ADVERSARIAL_PASSES: {
|
|
144
|
+
category: "env",
|
|
145
|
+
name: "NANO_PR_MAX_ADVERSARIAL_PASSES",
|
|
146
|
+
owner: "app/service.ts",
|
|
147
|
+
semantics:
|
|
148
|
+
"Maximum local adversarial-review agent passes per convergence round before the round parks for a Copilot review (#844); non-blank findings re-dispatch review-round. 0 disables the stage.",
|
|
149
|
+
default: "1",
|
|
150
|
+
},
|
|
143
151
|
NANO_PR_MAX_MERGE_STALL_ROUNDS: {
|
|
144
152
|
category: "env",
|
|
145
153
|
name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Behavioural coverage for the local adversarial-review pass on the convergence loop's ADDRESSED
|
|
2
|
+
// path (issue #844, sub-issue of #842).
|
|
3
|
+
//
|
|
4
|
+
// Copilot had become our discovery loop: each finding costs a full GitHub re-review round, and many
|
|
5
|
+
// are the obvious "next" finding a second reader would catch locally. So an `addressed` round now
|
|
6
|
+
// runs a separate `senior:adversarial-review` agent BEFORE the round is persisted / parked on
|
|
7
|
+
// `waiting_review` (the only status the poller solicits a Copilot review for). Non-blank findings
|
|
8
|
+
// loop straight back into `review-round` (carrying `adversarialFindings`) — not through
|
|
9
|
+
// `capture-head`, so the round-entry head and the no-progress check still span the whole round.
|
|
10
|
+
// The pass count is BOUNDED per round by `advPass`/`advMax` and reset when a new review lands.
|
|
11
|
+
//
|
|
12
|
+
// These deploy the committed convergence-loop into the real WASM engine and drive tokens through it,
|
|
13
|
+
// asserting what the loop DOES (element run counts, job inputs, the parked wait), not how it is drawn.
|
|
14
|
+
import { after, test } from "node:test";
|
|
15
|
+
import { assert } from "#test-assert";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { assertThatInstance, byProcessId, createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
|
|
18
|
+
|
|
19
|
+
const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
20
|
+
|
|
21
|
+
type Output = Record<string, unknown>;
|
|
22
|
+
type Job = { variables: Record<string, unknown> };
|
|
23
|
+
type Responder = Output | Output[] | ((job: Job) => Output);
|
|
24
|
+
|
|
25
|
+
const ALL_JOB_TYPES = [
|
|
26
|
+
"senior:pr-review",
|
|
27
|
+
"senior:adversarial-review",
|
|
28
|
+
"pr.capture-head",
|
|
29
|
+
"pr.persist-round",
|
|
30
|
+
"pr.progress-check",
|
|
31
|
+
"pr.converge-gate",
|
|
32
|
+
"senior:scope-classify",
|
|
33
|
+
"pr.finalize",
|
|
34
|
+
"pr.persist-escalation",
|
|
35
|
+
"pr.answer-escalation",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const FINDINGS = "Open: src/a.ts:10 fail-open on unknown input (high)";
|
|
39
|
+
const CLEAN = { adversarialFindings: "", adversarialSummary: "no findings" };
|
|
40
|
+
|
|
41
|
+
const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
42
|
+
"senior:pr-review": { status: "addressed", summary: "fixed" },
|
|
43
|
+
"senior:adversarial-review": CLEAN,
|
|
44
|
+
"pr.capture-head": { roundEntryHead: "sha-entry" },
|
|
45
|
+
"pr.persist-round": {},
|
|
46
|
+
"pr.progress-check": { progressed: true },
|
|
47
|
+
"pr.converge-gate": { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
|
|
48
|
+
"senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
|
|
49
|
+
"pr.finalize": {},
|
|
50
|
+
"pr.persist-escalation": { escalated: true },
|
|
51
|
+
"pr.answer-escalation": {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DEFAULT_VARS: Record<string, unknown> = {
|
|
55
|
+
prKey: "o/r#1",
|
|
56
|
+
repo: "o/r",
|
|
57
|
+
prNumber: 1,
|
|
58
|
+
prUrl: "https://example.test/pr/1",
|
|
59
|
+
round: 1,
|
|
60
|
+
maxRounds: 20,
|
|
61
|
+
reviewWaitTimeout: "PT30M",
|
|
62
|
+
ackRetryRound: 0,
|
|
63
|
+
ackRetryMax: 2,
|
|
64
|
+
advPass: 0,
|
|
65
|
+
advMax: 1,
|
|
66
|
+
abandonBrief: null,
|
|
67
|
+
status: null,
|
|
68
|
+
question: null,
|
|
69
|
+
summary: null,
|
|
70
|
+
answer: null,
|
|
71
|
+
scopePending: false,
|
|
72
|
+
scopeAnswer: null,
|
|
73
|
+
convergeBlocked: null,
|
|
74
|
+
convergeBlockReason: null,
|
|
75
|
+
convergeAckOnly: null,
|
|
76
|
+
reviewStale: null,
|
|
77
|
+
roundEntryHead: null,
|
|
78
|
+
huskRetries: 0,
|
|
79
|
+
progressed: true,
|
|
80
|
+
escalated: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const engines: WasmEngineClient[] = [];
|
|
84
|
+
after(async () => {
|
|
85
|
+
await Promise.all(engines.map((e) => e.close()));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Boot the loop; records every job's input variables per job type for assertions. */
|
|
89
|
+
async function boot(opts: { responses?: Record<string, Responder>; vars?: Record<string, unknown> } = {}) {
|
|
90
|
+
const engine = await createWasmEngineClient();
|
|
91
|
+
engines.push(engine);
|
|
92
|
+
await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
|
|
93
|
+
const responses = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
|
|
94
|
+
const seen: Record<string, Record<string, unknown>[]> = {};
|
|
95
|
+
for (const jobType of ALL_JOB_TYPES) {
|
|
96
|
+
const responder = responses[jobType];
|
|
97
|
+
const queue = Array.isArray(responder) ? [...responder] : null;
|
|
98
|
+
seen[jobType] = [];
|
|
99
|
+
await engine.registerWorker(jobType, (job) => {
|
|
100
|
+
seen[jobType].push({ ...(job as Job).variables });
|
|
101
|
+
if (queue) return queue.length > 1 ? queue.shift()! : (queue[0] ?? {});
|
|
102
|
+
if (typeof responder === "function") return responder(job as Job);
|
|
103
|
+
return responder ?? {};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await engine.createInstance({
|
|
107
|
+
processDefinitionId: "convergence-loop",
|
|
108
|
+
awaitCompletion: false,
|
|
109
|
+
variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
|
|
110
|
+
});
|
|
111
|
+
return { engine, seen };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function completions(engine: WasmEngineClient, elementId: string): number {
|
|
115
|
+
const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
|
|
116
|
+
return (snap.elementStats ?? []).find((s) => s.elementId === elementId)?.completed ?? 0;
|
|
117
|
+
}
|
|
118
|
+
function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
|
|
119
|
+
const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
|
|
120
|
+
return snap.instances?.[0]?.variables ?? {};
|
|
121
|
+
}
|
|
122
|
+
function runs(engine: WasmEngineClient): string {
|
|
123
|
+
return ["review-round", "adversarial-review", "persist-round", "check-progress"]
|
|
124
|
+
.map((id) => `${id}=${completions(engine, id)}`)
|
|
125
|
+
.join(" ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test("addressed + adversarial findings loop back into review-round with the findings, then park for Copilot", async () => {
|
|
129
|
+
const { engine, seen } = await boot({
|
|
130
|
+
responses: { "senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }, CLEAN] },
|
|
131
|
+
vars: { advMax: 2 },
|
|
132
|
+
});
|
|
133
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
134
|
+
assert(completions(engine, "review-round") === 2, `review-round should run twice: ${runs(engine)}`);
|
|
135
|
+
assert(completions(engine, "adversarial-review") === 2, `adversarial-review should run twice: ${runs(engine)}`);
|
|
136
|
+
// The round is persisted and progress-checked ONCE, only after the adversarial pass came back clean.
|
|
137
|
+
assert(completions(engine, "persist-round") === 1, `persist-round once: ${runs(engine)}`);
|
|
138
|
+
assert(completions(engine, "check-progress") === 1, `check-progress once: ${runs(engine)}`);
|
|
139
|
+
// The loop goes straight back to review-round, not through capture-head (round-entry head kept).
|
|
140
|
+
assert(completions(engine, "capture-head") === 1, "the findings loop must not re-capture the round-entry head");
|
|
141
|
+
const reviewJobs = seen["senior:pr-review"];
|
|
142
|
+
assert(!reviewJobs[0].adversarialFindings, "the first review-round has no adversarial findings");
|
|
143
|
+
assert(reviewJobs[1].adversarialFindings === FINDINGS, "the re-dispatched review-round receives the findings");
|
|
144
|
+
// Parked on the Copilot review wait (the poller solicits only after progress-check parks it).
|
|
145
|
+
assert(completions(engine, "wait-review") === 0, "must be parked waiting for the review");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a clean adversarial pass proceeds straight to persist-round", async () => {
|
|
149
|
+
const { engine } = await boot();
|
|
150
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
151
|
+
assert(completions(engine, "review-round") === 1, runs(engine));
|
|
152
|
+
assert(completions(engine, "adversarial-review") === 1, runs(engine));
|
|
153
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Regression for Copilot review 5386944381 (round 5): the round-4 verdict reset on
|
|
157
|
+
// `adversarial-review` was UNCONDITIONAL, so it also fired on the clean/default arm
|
|
158
|
+
// (`f_advClean` -> `persist-round`). A first-round `waiting` verdict was therefore blanked before
|
|
159
|
+
// `pr.progress-check` ran; the real worker classifies a blank status as `addressed`, and because the
|
|
160
|
+
// head did not change it routed the initial pass to no-progress escalation instead of parking on
|
|
161
|
+
// `waiting_review` for Copilot. The reset must be findings-ONLY: a clean pass must PRESERVE the
|
|
162
|
+
// review-round verdict (status/summary/question) all the way to persist-round.
|
|
163
|
+
test("a clean adversarial pass preserves a first-round waiting verdict for progress-check", async () => {
|
|
164
|
+
const { engine, seen } = await boot({
|
|
165
|
+
responses: {
|
|
166
|
+
"senior:pr-review": { status: "waiting", summary: "no review yet" },
|
|
167
|
+
"senior:adversarial-review": CLEAN,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
171
|
+
assert(completions(engine, "adversarial-review") === 1, runs(engine));
|
|
172
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
173
|
+
// The clean pass must NOT blank the verdict: persist-round / progress-check see `waiting`, so the
|
|
174
|
+
// worker parks the PR on `waiting_review` (the only status the poller solicits a review for).
|
|
175
|
+
const recorded = seen["pr.persist-round"][0];
|
|
176
|
+
assert(
|
|
177
|
+
recorded.status === "waiting",
|
|
178
|
+
`a clean pass must preserve the waiting verdict; status reaching persist-round: ${JSON.stringify(recorded.status)}`,
|
|
179
|
+
);
|
|
180
|
+
const progress = seen["pr.progress-check"][0];
|
|
181
|
+
assert(
|
|
182
|
+
progress.status === "waiting",
|
|
183
|
+
`progress-check must see the waiting verdict, not a blanked one: ${JSON.stringify(progress.status)}`,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("the adversarial pass is bounded by advMax per round", async () => {
|
|
188
|
+
const { engine } = await boot({
|
|
189
|
+
responses: { "senior:adversarial-review": { adversarialFindings: FINDINGS, adversarialSummary: "always" } },
|
|
190
|
+
vars: { advMax: 2 },
|
|
191
|
+
});
|
|
192
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
193
|
+
assert(completions(engine, "adversarial-review") === 2, `cap of 2 passes: ${runs(engine)}`);
|
|
194
|
+
// 1 initial + 2 findings loops = 3 review-rounds, then the round proceeds despite findings.
|
|
195
|
+
assert(completions(engine, "review-round") === 3, runs(engine));
|
|
196
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("advMax=0 disables the adversarial pass", async () => {
|
|
200
|
+
const { engine } = await boot({ vars: { advMax: 0 } });
|
|
201
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
202
|
+
assert(completions(engine, "adversarial-review") === 0, runs(engine));
|
|
203
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("an instance seeded without advPass/advMax (pre-#844 shape) skips the pass, no incident", async () => {
|
|
207
|
+
const { engine } = await boot({ vars: { advPass: null, advMax: null } });
|
|
208
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
209
|
+
assert(completions(engine, "adversarial-review") === 0, runs(engine));
|
|
210
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// The FIRST push (#844): a PR enters the loop with no Copilot review yet, so round 1 returns
|
|
214
|
+
// `waiting`. That never-reviewed head is exactly what Copilot would review first, so the adversarial
|
|
215
|
+
// pass runs on it too — before progress-check parks the PR on `waiting_review` (which is what makes
|
|
216
|
+
// the poller solicit the initial Copilot review). This covers every PR entering convergence
|
|
217
|
+
// (feature runs, plan fan-out slices, human-submitted PRs) from the one canonical stage.
|
|
218
|
+
test("the first push (round 1, no review yet) gets an adversarial pass before Copilot is solicited", async () => {
|
|
219
|
+
const { engine, seen } = await boot({
|
|
220
|
+
responses: {
|
|
221
|
+
"senior:pr-review": [{ status: "waiting", summary: "no review yet" }, { status: "addressed", summary: "fixed finding" }],
|
|
222
|
+
"senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
226
|
+
assert(completions(engine, "adversarial-review") === 1, `first push must be adversarially reviewed: ${runs(engine)}`);
|
|
227
|
+
assert(completions(engine, "review-round") === 2, `findings must re-dispatch review-round: ${runs(engine)}`);
|
|
228
|
+
assert(seen["senior:pr-review"][1].adversarialFindings === FINDINGS, "the re-dispatch carries the findings");
|
|
229
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
230
|
+
assert(completions(engine, "capture-head") === 1, "no head re-capture within the round");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a later waiting round (round > 1, review still pending) does not re-run the adversarial pass", async () => {
|
|
234
|
+
const { engine } = await boot({
|
|
235
|
+
vars: { round: 2 },
|
|
236
|
+
responses: { "senior:pr-review": { status: "waiting", summary: "review pending" } },
|
|
237
|
+
});
|
|
238
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
239
|
+
assert(completions(engine, "adversarial-review") === 0, runs(engine));
|
|
240
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Issue #822 husk failure mode ON THE FINDINGS BACK-EDGE: the `f_advFindings` edge re-dispatches
|
|
244
|
+
// `review-round` straight from `gw-adv-result`, bypassing `capture-head` — so it must not inherit the
|
|
245
|
+
// PRIOR review-round's verdict. Here round 1 returns `waiting` (no review yet), the adversarial pass
|
|
246
|
+
// finds a finding, and the re-dispatched `review-round` HUSKS (completes with NO result vars). If the
|
|
247
|
+
// back-edge does not reset the round's output vars, the stale `status="waiting"` survives into
|
|
248
|
+
// gw-status / persist-round even though the findings were never handled. The back-edge must clear
|
|
249
|
+
// `status`/`summary`/`question` (a findings-only reset) while PRESERVING `roundEntryHead`, `advPass`
|
|
250
|
+
// and `adversarialFindings` (so the budget and the findings survive the re-dispatch).
|
|
251
|
+
test("a husked re-dispatch on the findings back-edge does not reuse the prior round's verdict", async () => {
|
|
252
|
+
const { engine, seen } = await boot({
|
|
253
|
+
responses: {
|
|
254
|
+
// Round 1 review-round: waiting (no review yet). Re-dispatched review-round: HUSK ({}).
|
|
255
|
+
"senior:pr-review": [{ status: "waiting", summary: "no review yet" }, {}],
|
|
256
|
+
"senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }],
|
|
257
|
+
},
|
|
258
|
+
vars: { advMax: 1 },
|
|
259
|
+
});
|
|
260
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
261
|
+
assert(completions(engine, "review-round") === 2, `findings re-dispatch review-round: ${runs(engine)}`);
|
|
262
|
+
assert(completions(engine, "adversarial-review") === 1, runs(engine));
|
|
263
|
+
assert(completions(engine, "persist-round") === 1, runs(engine));
|
|
264
|
+
// The findings survived the re-dispatch (the husked review-round received them)...
|
|
265
|
+
assert(seen["senior:pr-review"][1].adversarialFindings === FINDINGS, "the re-dispatch carries the findings");
|
|
266
|
+
// ...and the pass budget was consumed (advPass incremented, NOT reset to 0 by a capture-head re-entry).
|
|
267
|
+
assert(instanceVars(engine).advPass === 1, `advPass preserved across the back-edge: ${instanceVars(engine).advPass}`);
|
|
268
|
+
// ...but the stale `waiting` verdict did NOT reach persist-round: the back-edge reset it to blank,
|
|
269
|
+
// so the round is recorded on the safe default rather than the inherited `waiting`.
|
|
270
|
+
const recorded = seen["pr.persist-round"][0];
|
|
271
|
+
assert(
|
|
272
|
+
recorded.status == null || recorded.status === "",
|
|
273
|
+
`a husked re-dispatch must not record the stale verdict; status reaching persist-round: ${JSON.stringify(recorded.status)}`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a converged round does not run the adversarial pass", async () => {
|
|
278
|
+
const { engine } = await boot({ responses: { "senior:pr-review": { status: "converged", summary: "done" } } });
|
|
279
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).hasCompleted().hasNoIncident();
|
|
280
|
+
assert(completions(engine, "adversarial-review") === 0, runs(engine));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("the pass budget resets when the next review lands (new round)", async () => {
|
|
284
|
+
const { engine } = await boot({
|
|
285
|
+
responses: { "senior:adversarial-review": { adversarialFindings: FINDINGS, adversarialSummary: "always" } },
|
|
286
|
+
vars: { advMax: 1 },
|
|
287
|
+
});
|
|
288
|
+
assert(completions(engine, "adversarial-review") === 1, runs(engine));
|
|
289
|
+
assert(instanceVars(engine).advPass === 1, `advPass after round 1: ${instanceVars(engine).advPass}`);
|
|
290
|
+
await engine.publishMessage({ name: "readiness-ready", correlationKey: "o/r#1", variables: { ready: true } });
|
|
291
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
292
|
+
// Round 2 gets its own full budget of 1 pass.
|
|
293
|
+
assert(completions(engine, "adversarial-review") === 2, `round 2 must get a fresh pass: ${runs(engine)}`);
|
|
294
|
+
});
|
|
@@ -945,8 +945,14 @@ test("the round-cap guard is downstream of progress classification, so a husk au
|
|
|
945
945
|
const retry = flowElement("f_huskRetry");
|
|
946
946
|
assert(retry, "f_huskRetry flow missing");
|
|
947
947
|
assertStringIncludes(retry, 'targetRef="capture-head"');
|
|
948
|
-
//
|
|
948
|
+
// (#844: the addressed AND waiting arms detour through the bounded adversarial gate, whose arms all
|
|
949
|
+
// land on persist-round or loop to review-round — never gw-guard.)
|
|
949
950
|
for (const id of ["f_addressed", "f_waiting"]) {
|
|
951
|
+
const f = flowElement(id);
|
|
952
|
+
assert(f, `${id} flow missing`);
|
|
953
|
+
assertStringIncludes(f, 'targetRef="gw-adv-due"');
|
|
954
|
+
}
|
|
955
|
+
for (const id of ["f_advSkip", "f_advClean"]) {
|
|
950
956
|
const f = flowElement(id);
|
|
951
957
|
assert(f, `${id} flow missing`);
|
|
952
958
|
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
@@ -77,14 +77,35 @@ test("escalation is an explicit needs_input/blocked arm gated on a non-blank que
|
|
|
77
77
|
test("the default (addressed) arm carries no condition and re-enters round processing", () => {
|
|
78
78
|
const addressed = flowElement("f_addressed");
|
|
79
79
|
assert(addressed, "f_addressed flow missing");
|
|
80
|
-
|
|
80
|
+
// #844: the addressed arm first passes the (bounded) local adversarial-review gate, whose
|
|
81
|
+
// unconditional defaults — budget spent / findings clean — both land on round processing.
|
|
82
|
+
assertStringIncludes(addressed, 'targetRef="gw-adv-due"');
|
|
81
83
|
// A default flow must have NO conditionExpression.
|
|
82
84
|
assert(
|
|
83
85
|
!/conditionExpression/.test(addressed),
|
|
84
86
|
"f_addressed is the default flow and must not carry a conditionExpression",
|
|
85
87
|
);
|
|
88
|
+
assertAdversarialDefaultsReenterRound();
|
|
86
89
|
});
|
|
87
90
|
|
|
91
|
+
/** The adversarial-review detour (#844) on the addressed arm must always drain to round
|
|
92
|
+
* processing: both gateways' DEFAULT arms are unconditional and target persist-round, and the only
|
|
93
|
+
* conditional arm loops back into review-round (bounded by advPass/advMax) — never escalation. */
|
|
94
|
+
function assertAdversarialDefaultsReenterRound() {
|
|
95
|
+
for (const [gw, dflt] of [["gw-adv-due", "f_advSkip"], ["gw-adv-result", "f_advClean"]]) {
|
|
96
|
+
const g = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${gw}"[^>]*>`));
|
|
97
|
+
assert(g, `${gw} gateway missing`);
|
|
98
|
+
assertStringIncludes(g[0], `default="${dflt}"`);
|
|
99
|
+
const f = flowElement(dflt);
|
|
100
|
+
assert(f, `${dflt} flow missing`);
|
|
101
|
+
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
102
|
+
assert(!/conditionExpression/.test(f), `${dflt} is a default arm and must not carry a condition`);
|
|
103
|
+
}
|
|
104
|
+
const loop = flowElement("f_advFindings");
|
|
105
|
+
assert(loop, "f_advFindings flow missing");
|
|
106
|
+
assertStringIncludes(loop, 'targetRef="review-round"');
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
test("regression: an empty/unknown status no longer routes to persist-escalation", () => {
|
|
89
110
|
// Escalation is reachable ONLY via an explicit condition (the needs_input/blocked arm),
|
|
90
111
|
// never as a catch-all default. Enumerate EVERY sequenceFlow into `persist-escalation`
|
|
@@ -98,7 +119,8 @@ test("regression: an empty/unknown status no longer routes to persist-escalation
|
|
|
98
119
|
// The addressed default must land on round processing (persist-round → check-progress, which
|
|
99
120
|
// re-solicits the review after the round-cap gate downstream), not escalation.
|
|
100
121
|
const addressed = flowElement("f_addressed");
|
|
101
|
-
assert(addressed && /targetRef="
|
|
122
|
+
assert(addressed && /targetRef="gw-adv-due"/.test(addressed), "default arm must re-enter round processing");
|
|
123
|
+
assertAdversarialDefaultsReenterRound();
|
|
102
124
|
});
|
|
103
125
|
|
|
104
126
|
// The canonical router (app/roundResultDefault.ts) mirrors the gw-status routing above, with the
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, MAX_ADVERSARIAL_PASSES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollJobActivationImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
17
|
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
@@ -669,6 +669,168 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
669
669
|
});
|
|
670
670
|
|
|
671
671
|
|
|
672
|
+
// Red/green regression (nano-workforce#845 Copilot review): the convergence loop runs TWO external
|
|
673
|
+
// agent task types while the PR reads `converging` — `review-round` (`senior:pr-review`) and, since
|
|
674
|
+
// issue #844, `adversarial-review` (`senior:adversarial-review`). `pollJobActivation` originally
|
|
675
|
+
// searched only for `senior:pr-review`, so during an adversarial pass it found no matching job and
|
|
676
|
+
// cleared `active_worker`/`lease_until` — the Home/Overview grids reported "queued" while an agent
|
|
677
|
+
// was actively working. This drives the pass's reconciliation core against a stubbed
|
|
678
|
+
// `/v2/jobs/search`:
|
|
679
|
+
// 1. an activated `senior:adversarial-review` job surfaces its leasing worker (the defect),
|
|
680
|
+
// 2. the review-round → adversarial-review handoff keeps a live worker visible across the switch,
|
|
681
|
+
// 3. a non-agent job (an internal `pr.*` worker) is defensively excluded even if the wire `$in`
|
|
682
|
+
// filter is ignored,
|
|
683
|
+
// 4. a merely-created (unactivated) agent job — no `worker` yet — reads as "queued" (lease null).
|
|
684
|
+
function jobActivationFetch(byInstance: Record<string, unknown[]>, opts: { ignoreTypeFilter?: boolean } = {}) {
|
|
685
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
686
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
687
|
+
if (!u.endsWith("/jobs/search")) {
|
|
688
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
689
|
+
}
|
|
690
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
691
|
+
filter?: { processInstanceKey?: string; type?: string | { $in?: string[] } };
|
|
692
|
+
};
|
|
693
|
+
const all = (byInstance[body.filter?.processInstanceKey ?? ""] ?? []) as { type?: string }[];
|
|
694
|
+
// Honor the wire `type` filter (scalar `$eq` or `{ $in: [...] }`) the way the engine does, so the
|
|
695
|
+
// test reproduces the real defect: a single-type `senior:pr-review` filter returns ZERO items for
|
|
696
|
+
// an adversarial-pass job (the engine filters server-side), which is exactly what cleared the
|
|
697
|
+
// lease pre-fix. A stub that ignores `type` would pass against the old code and prove nothing.
|
|
698
|
+
// `ignoreTypeFilter` simulates an engine that IGNORES the `$in` filter, so the non-agent item
|
|
699
|
+
// reaches `pollJobActivationImpl` and the test exercises the defensive client-side `.includes`
|
|
700
|
+
// re-filter (service.ts) rather than the wire filter doing the work.
|
|
701
|
+
const t = body.filter?.type;
|
|
702
|
+
const wanted: string[] | null = typeof t === "string" ? [t] : Array.isArray(t?.$in) ? t.$in : null;
|
|
703
|
+
const items = opts.ignoreTypeFilter || wanted == null ? all : all.filter((j) => wanted.includes(j.type ?? ""));
|
|
704
|
+
return Promise.resolve(
|
|
705
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
706
|
+
);
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function jobActivationData(rows: unknown[]) {
|
|
711
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
712
|
+
pull_requests: { rows, key: "pr_key" },
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
716
|
+
open: () => memOpen(stores),
|
|
717
|
+
} as any;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
test("pollJobActivation surfaces an activated adversarial-review agent (not just senior:pr-review)", async () => {
|
|
721
|
+
const row = {
|
|
722
|
+
pr_key: "owner/repo#20",
|
|
723
|
+
status: "converging",
|
|
724
|
+
process_key: "PI-20",
|
|
725
|
+
active_worker: null as string | null,
|
|
726
|
+
lease_until: null as string | null,
|
|
727
|
+
updated_at: "t0",
|
|
728
|
+
};
|
|
729
|
+
const data = jobActivationData([row]);
|
|
730
|
+
const headers = { "content-type": "application/json" };
|
|
731
|
+
|
|
732
|
+
const prevFetch = globalThis.fetch;
|
|
733
|
+
// The PR is mid adversarial pass: the only open job is `senior:adversarial-review`, leased by a
|
|
734
|
+
// worker. Before the fix the pass filtered on `senior:pr-review` alone, found nothing, and left
|
|
735
|
+
// `active_worker` null — the grid showed "queued" while this agent was actively working.
|
|
736
|
+
globalThis.fetch = jobActivationFetch({
|
|
737
|
+
"PI-20": [
|
|
738
|
+
{ type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
|
|
739
|
+
],
|
|
740
|
+
}) as typeof fetch;
|
|
741
|
+
try {
|
|
742
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
743
|
+
} finally {
|
|
744
|
+
globalThis.fetch = prevFetch;
|
|
745
|
+
}
|
|
746
|
+
assertEquals(row.active_worker, "agent-adv");
|
|
747
|
+
assertEquals(row.lease_until, "2024-01-01T00:15:00Z");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("pollJobActivation keeps a live worker visible across the review-round ↔ adversarial handoff", async () => {
|
|
751
|
+
const row = {
|
|
752
|
+
pr_key: "owner/repo#21",
|
|
753
|
+
status: "converging",
|
|
754
|
+
process_key: "PI-21",
|
|
755
|
+
active_worker: null as string | null,
|
|
756
|
+
lease_until: null as string | null,
|
|
757
|
+
updated_at: "t0",
|
|
758
|
+
};
|
|
759
|
+
const data = jobActivationData([row]);
|
|
760
|
+
const headers = { "content-type": "application/json" };
|
|
761
|
+
|
|
762
|
+
const prevFetch = globalThis.fetch;
|
|
763
|
+
try {
|
|
764
|
+
// Leg 1: the review-round agent (`senior:pr-review`) is working.
|
|
765
|
+
globalThis.fetch = jobActivationFetch({
|
|
766
|
+
"PI-21": [
|
|
767
|
+
{ type: "senior:pr-review", worker: "agent-review", deadline: "2024-01-01T00:10:00Z", state: "CREATED" },
|
|
768
|
+
],
|
|
769
|
+
}) as typeof fetch;
|
|
770
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
771
|
+
assertEquals(row.active_worker, "agent-review");
|
|
772
|
+
|
|
773
|
+
// Leg 2: the loop handed off to the adversarial pass (`senior:adversarial-review`) — same
|
|
774
|
+
// `converging` status, different job type. The grid must keep showing "agent working", not
|
|
775
|
+
// flash "queued".
|
|
776
|
+
globalThis.fetch = jobActivationFetch({
|
|
777
|
+
"PI-21": [
|
|
778
|
+
{ type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:20:00Z", state: "CREATED" },
|
|
779
|
+
],
|
|
780
|
+
}) as typeof fetch;
|
|
781
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
782
|
+
assertEquals(row.active_worker, "agent-adv");
|
|
783
|
+
assertEquals(row.lease_until, "2024-01-01T00:20:00Z");
|
|
784
|
+
} finally {
|
|
785
|
+
globalThis.fetch = prevFetch;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("pollJobActivation excludes a non-agent job and reports an unactivated agent job as queued", async () => {
|
|
790
|
+
const row = {
|
|
791
|
+
pr_key: "owner/repo#22",
|
|
792
|
+
status: "converging",
|
|
793
|
+
process_key: "PI-22",
|
|
794
|
+
active_worker: "stale-worker" as string | null,
|
|
795
|
+
lease_until: "2024-01-01T00:05:00Z" as string | null,
|
|
796
|
+
updated_at: "t0",
|
|
797
|
+
};
|
|
798
|
+
const data = jobActivationData([row]);
|
|
799
|
+
const headers = { "content-type": "application/json" };
|
|
800
|
+
|
|
801
|
+
const prevFetch = globalThis.fetch;
|
|
802
|
+
try {
|
|
803
|
+
// An internal `pr.*` worker job is open and "leased" — but it is not a convergence agent, so it
|
|
804
|
+
// must NOT keep `active_worker` lit even if the engine ignores the wire `$in` type filter. Pass
|
|
805
|
+
// `ignoreTypeFilter` so the mock returns the non-agent item UNFILTERED (an engine that ignores
|
|
806
|
+
// `$in`): the defensive client-side `.includes(j.type)` re-filter in `pollJobActivationImpl` is
|
|
807
|
+
// then what excludes it — so the assertion genuinely guards that branch (a wire-filtered stub
|
|
808
|
+
// would pass even if the defensive check were deleted).
|
|
809
|
+
globalThis.fetch = jobActivationFetch(
|
|
810
|
+
{
|
|
811
|
+
"PI-22": [
|
|
812
|
+
{ type: "pr.capture-head", worker: "host-internal", deadline: "2024-01-01T00:30:00Z", state: "CREATED" },
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
{ ignoreTypeFilter: true },
|
|
816
|
+
) as typeof fetch;
|
|
817
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
818
|
+
assertEquals(row.active_worker, null);
|
|
819
|
+
assertEquals(row.lease_until, null);
|
|
820
|
+
|
|
821
|
+
// A merely-created (queued) agent job carries no worker yet → still "queued" (lease null).
|
|
822
|
+
globalThis.fetch = jobActivationFetch({
|
|
823
|
+
"PI-22": [{ type: "senior:adversarial-review", state: "CREATED" }],
|
|
824
|
+
}) as typeof fetch;
|
|
825
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
826
|
+
assertEquals(row.active_worker, null);
|
|
827
|
+
assertEquals(row.lease_until, null);
|
|
828
|
+
} finally {
|
|
829
|
+
globalThis.fetch = prevFetch;
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
|
|
672
834
|
// Red/green regression (nano-workforce#102 review): the engine can hand back a numeric
|
|
673
835
|
// `processInstanceKey`, but the OpenAPI `SubmitResult.processKey` contract is `string | null`, so
|
|
674
836
|
// under api `validateResponses:"dev"` a raw number fails response validation. Stringifying at the
|
|
@@ -876,6 +1038,21 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
|
|
|
876
1038
|
});
|
|
877
1039
|
});
|
|
878
1040
|
|
|
1041
|
+
test("submitPr seeds the #844 adversarial-review budget (advPass=0, advMax=MAX_ADVERSARIAL_PASSES)", async () => {
|
|
1042
|
+
await withGithubOff(async () => {
|
|
1043
|
+
const { data, engine, get } = captureVars();
|
|
1044
|
+
await submitPr(data, engine, {
|
|
1045
|
+
repo: "owner/repo",
|
|
1046
|
+
number: 14,
|
|
1047
|
+
url: "https://github.com/owner/repo/pull/14",
|
|
1048
|
+
prKey: "owner/repo#14",
|
|
1049
|
+
});
|
|
1050
|
+
const vars = get();
|
|
1051
|
+
assertEquals(vars?.advPass, 0);
|
|
1052
|
+
assertEquals(vars?.advMax, MAX_ADVERSARIAL_PASSES);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
879
1056
|
test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
|
|
880
1057
|
await withGithubOff(async () => {
|
|
881
1058
|
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|