@nanobpm/nano-workforce 0.203.0 → 0.204.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.
@@ -163,6 +163,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
163
163
 
164
164
  const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
165
165
  assert.equal(prs.length, 0, "raise-only did NOT enroll the PR into the convergence loop");
166
+
167
+ // Issue #907: the HOST posted exactly one idempotent "PR opened" comment on the claimed
168
+ // issue, and recorded the link on the run row so a repeated pass never double-posts.
169
+ // (Snapshot the issue's comments: the github state is shared across this file's tests.)
170
+ const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#101"));
171
+ assert.equal(comments.length, 1, "exactly one comment was posted on the claimed issue for PR #101");
172
+ assert.ok(comments[0].body.includes("Pull request opened: #101"), "the comment names the PR");
173
+ assert.ok(
174
+ comments[0].body.includes("<!-- nwf:pr-linked:#101 https://github.com/owner/repo/pull/101 -->"),
175
+ "the comment carries the URL-keyed dedupe marker",
176
+ );
177
+ assert.equal(
178
+ (run as unknown as { pr_linked_pr_key: string | null }).pr_linked_pr_key,
179
+ "owner/repo#101",
180
+ "the durable link record was written",
181
+ );
182
+ },
183
+ );
184
+ });
185
+
186
+ test("idempotence: re-running record-feature for the SAME delivered PR posts nothing more (issue #907)", async () => {
187
+ // A retried at-least-once `record-feature` job (or a second reconcile pass) must never
188
+ // double-post the "PR opened" comment: the durable `pr_linked_pr_key` claim short-circuits the
189
+ // repeat before any GitHub write.
190
+ await withApp(
191
+ { "senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "built it" }) },
192
+ { baseBranch: "epic/e2e" },
193
+ async ({ app, featureKey }) => {
194
+ const before = (githubState.issueComments.get(7) ?? []).length;
195
+ assert.equal(before >= 0, true);
196
+ // Drive the REAL record-feature handler a second time with the same delivered PR (the
197
+ // at-least-once repeat / second reconcile pass), against the SAME live app DB.
198
+ const { default: recordFeature } = await import("../workers/record-feature/worker.ts");
199
+ const log = { info: () => {}, warn: () => {}, error: () => {} };
200
+ await recordFeature(
201
+ { jobKey: "repeat-1", variables: { featureKey, status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
202
+ { data: app.db, log } as never,
203
+ );
204
+ assert.equal(
205
+ (githubState.issueComments.get(7) ?? []).length,
206
+ before,
207
+ "a repeated record pass posted NO second comment",
208
+ );
166
209
  },
167
210
  );
168
211
  });
@@ -272,7 +315,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
272
315
  calls += 1;
273
316
  return calls === 1
274
317
  ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
275
- : { status: "opened", pr: "owner/repo#103", summary: "resumed and opened" };
318
+ : { status: "opened", pr: "owner/repo#104", summary: "resumed and opened" };
276
319
  },
277
320
  },
278
321
  { baseBranch: "epic/e2e" },
@@ -298,6 +341,13 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
298
341
  );
299
342
  assert.ok(!flows.includes("ic_gw_answer->ic_end"), "the abandon (default) flow was NOT taken");
300
343
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
344
+
345
+ // Issue #907: the RETRY opened the PR — the host still posted exactly one "PR opened"
346
+ // comment on the claimed issue (the first, escalated pass delivered no PR, so nothing was
347
+ // posted before the answer). Uses a PR number (#104) no earlier scenario links, so a pre-existing marker in the shared github state cannot satisfy this.
348
+ const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#104"));
349
+ assert.equal(comments.length, 1, "exactly one comment was posted across the escalate+resume");
350
+ assert.ok(comments[0].body.includes("Pull request opened: #104"), "the comment names the retry-opened PR");
301
351
  },
302
352
  );
303
353
  });
@@ -388,6 +438,13 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
388
438
  !tasks.some((t) => t.elementId === "escalation"),
389
439
  "no human-escalation task was created for the adopted run",
390
440
  );
441
+
442
+ // Issue #907: the ADOPTED PR was announced on the claimed issue exactly once — the
443
+ // reconcile path delivers the PR without the agent reporting it, and the host-side link
444
+ // covers it all the same. (Filtered to THIS PR's marker — the github state is shared.)
445
+ const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#801"));
446
+ assert.equal(comments.length, 1, "exactly one comment was posted for the adopted PR");
447
+ assert.ok(comments[0].body.includes("Pull request opened: #801"), "the comment names the adopted PR");
391
448
  },
392
449
  );
393
450
  } finally {
@@ -57,6 +57,8 @@ type Stub = (job: EngineJob) => Record<string, unknown> | void;
57
57
  describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → userTask + form)", () => {
58
58
  const savedEnv = new Map<string, string | undefined>();
59
59
  let restoreGithub: (() => void) | undefined;
60
+ // The shared hermetic GitHub state (issue #907 asserts NO epic-slice comment lands on the parent).
61
+ const githubState = admitGithubState("owner/repo", "main");
60
62
 
61
63
  before(() => {
62
64
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
@@ -65,7 +67,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
65
67
  }
66
68
  // ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
67
69
  // admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
68
- restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
70
+ restoreGithub = installAdmitGithub(githubState);
69
71
  });
70
72
 
71
73
  after(() => {
@@ -452,6 +454,27 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
452
454
  );
453
455
  });
454
456
 
457
+ test("an epic slice that opens a PR posts NO comment on the parent epic (issue #907)", async () => {
458
+ // The "PR opened" issue link is gated on `claimIssue`, which only a single-issue feature run
459
+ // seeds (app/feature.ts). An epic slice's `issue` is the SHARED parent epic, which must never be
460
+ // claimed or commented per-slice — so a slice that opens its PR leaves the epic's thread empty.
461
+ const before = (githubState.issueComments.get(1) ?? []).length;
462
+ await withApp(
463
+ {
464
+ "senior:plan": singleTaskPlan,
465
+ "senior:plan-review": approveReview,
466
+ "senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "opened the slice" }),
467
+ },
468
+ async () => {
469
+ assert.equal(
470
+ (githubState.issueComments.get(1) ?? []).length,
471
+ before,
472
+ "the parent epic gained NO comment from the slice's delivered PR",
473
+ );
474
+ },
475
+ );
476
+ });
477
+
455
478
  // Capability edge (issue #289): a task that declares cross-repo `needs` must PARK at the
456
479
  // `wait-caps-resolved` barrier (never take the no-needs shortcut), and publishing `caps-resolved`
457
480
  // correlated on the per-task barrier key `<planKey>:<taskId>` must release it into `implement-task`
@@ -20,6 +20,9 @@ export interface AdmitGithubState {
20
20
  * head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
21
21
  * so suites that don't opt in keep exactly today's escalate behaviour. */
22
22
  openPrs: Map<string, { number: number; base?: string }>;
23
+ /** Issue comments keyed by issue number (issue #907): the record-feature "PR opened" link lists
24
+ * existing comments (marker dedupe) and POSTs new ones here. Seeded empty. */
25
+ issueComments: Map<number, { body: string }[]>;
23
26
  }
24
27
 
25
28
  /** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
@@ -35,6 +38,7 @@ export function admitGithubState(
35
38
  creates: [],
36
39
  resets: [],
37
40
  openPrs: new Map(),
41
+ issueComments: new Map(),
38
42
  };
39
43
  }
40
44
 
@@ -95,6 +99,35 @@ function admitFetch(state: AdmitGithubState) {
95
99
  ]),
96
100
  );
97
101
  }
102
+ // GET /repos/{repo}/pulls/{n} → a PR's meta (fetchPrMeta — the "PR opened" link's best-effort
103
+ // title read, issue #907). Served from the openPrs entry when one names this number.
104
+ const pullMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/);
105
+ if (method === "GET" && pullMatch) {
106
+ const number = Number(pullMatch[2]);
107
+ for (const [branch, hit] of state.openPrs) {
108
+ if (hit.number === number) {
109
+ return Promise.resolve(
110
+ json({ title: `PR #${number}`, body: "", head: { ref: branch }, base: { ref: hit.base ?? state.defaultBranch } }),
111
+ );
112
+ }
113
+ }
114
+ return Promise.resolve(json({ title: `PR #${number}`, body: "", head: { ref: null }, base: { ref: state.defaultBranch } }));
115
+ }
116
+ // GET /repos/{repo}/issues/{n}/comments → the issue's comments (the "PR opened" marker dedupe
117
+ // scan, issue #907). POST appends a comment.
118
+ const commentsMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/issues\/(\d+)\/comments$/);
119
+ if (commentsMatch) {
120
+ const issueNumber = Number(commentsMatch[2]);
121
+ if (method === "GET") return Promise.resolve(json(state.issueComments.get(issueNumber) ?? []));
122
+ if (method === "POST") {
123
+ // biome-ignore lint/plugin: runtime contract boundary for parsed JSON
124
+ const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string };
125
+ const list = state.issueComments.get(issueNumber) ?? [];
126
+ list.push({ body: String(body.body ?? "") });
127
+ state.issueComments.set(issueNumber, list);
128
+ return Promise.resolve(json({ id: list.length }, 201));
129
+ }
130
+ }
98
131
  // Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
99
132
  return Promise.resolve(new Response("Not Found", { status: 404 }));
100
133
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.203.0",
3
+ "version": "0.204.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -8,6 +8,7 @@
8
8
  <nano:extend name="status" type="string" optional="true" />
9
9
  <nano:extend name="pr" type="string" optional="true" />
10
10
  <nano:extend name="summary" type="string" optional="true" />
11
+ <nano:extend name="claimIssue" type="boolean" optional="true" />
11
12
  </nano:shape>
12
13
  <nano:shape id="RecordFeatureImplementingIn" name="Record feature implementing — input">
13
14
  <nano:extend name="featureKey" type="string" optional="true" />
@@ -29,6 +29,7 @@
29
29
  <zeebe:ioMapping>
30
30
  <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (is defined(baseBranchBrief) and baseBranchBrief != null) then baseBranchBrief else &#34;&#34;) + (if (is defined(blackboardBrief) and blackboardBrief != null) then blackboardBrief else &#34;&#34;) + (if (is defined(resolvedDepsBrief) and resolvedDepsBrief != null) then resolvedDepsBrief else &#34;&#34;) + (if (is defined(resolvedArtifacts) = false or resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**Bound upstream readiness.** Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions) + (if (is defined(retryNudge) and retryNudge != null) then retryNudge else &#34;&#34;)" target="appendPrompt" />
31
31
  <zeebe:input source="=if (is defined(transcriptUrlBase)) then transcriptUrlBase else null" target="transcriptUrlBase" />
32
+ <zeebe:input source="=if (is defined(claimIssue)) then claimIssue else null" target="claimIssue" />
32
33
  <zeebe:output source="=status" target="status" />
33
34
  <zeebe:output source="=question" target="question" />
34
35
  <zeebe:output source="=if (is defined(summary)) then summary else null" target="summary" />
@@ -11,27 +11,15 @@ implements → converges** them across a fleet of coding agents, and runs
11
11
  **agent-authored delivery graphs** (heterogeneous cross-repo, human-in-the-loop
12
12
  DAGs — ADR 0005).
13
13
 
14
- **This skill is a thin bootstrap by design.** It does not describe the endpoints.
15
- Every running nwf instance is self-describing — over **MCP** where your client
16
- supports it, and over its **live operator guide** everywhere else. Your job is to
17
- reach that live surface and follow it, never to work from a cached copy that drifts
18
- across versions and instances.
19
-
20
- There are two paths. **Prefer MCP (§A).** If your client has no MCP support, use the
21
- fetch-the-live-guide fallback (§B). Both talk to the same app; MCP is a projection of
22
- the same OpenAPI contract, not a different system (ADR 0067).
14
+ **This skill is a thin bootstrap.** Every running instance describes itself, over **MCP**
15
+ (§A, preferred) or through its **live operator guide** (§B). Reach that live surface and
16
+ follow it rather than a cached copy that drifts between versions.
23
17
 
24
18
  ## A. Preferred — drive over MCP
25
19
 
26
- The Urban runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every
27
- instance, with **zero app-side MCP code**: the app's operations are projected into
28
- tools from its OpenAPI spec, alongside a framework-owned engine-debug tool family
29
- (process instances, wait states, variables, incidents) and the app's projection
30
- reads. The operator **guide** (the same guide as §B) is itself one of those projected
31
- tools — `GET /app/api/agent` becomes the `getAgentInstructions` read tool — so the
32
- workflow knowledge (orient first, preview before dispatch, escalations are for humans)
33
- is discoverable over the same channel as the drive tools; the runtime additionally
34
- serves its derived **system brief** as an MCP resource plus an orientation prompt.
20
+ Every instance serves a Streamable-HTTP MCP endpoint at **`/app/mcp`**. Its tools are the
21
+ app's operations (projected from its OpenAPI spec) plus framework engine-debug tools. The
22
+ operator guide itself is the `getAgentInstructions` tool.
35
23
 
36
24
  **Register one MCP server entry per instance.** Naming the instance
37
25
  (`"drive workforce-merlin"`) makes the wrong-instance mistake structurally
@@ -64,14 +52,9 @@ Then: start a session, confirm the `workforce-*` tools appear, and ask the agent
64
52
  use a **named** instance (`"Using workforce-local, show what's in flight and any open
65
53
  escalations"`). It should call the status operation tool, not curl.
66
54
 
67
- **Guard posture.** When `NANO_PR_WEBHOOK_SECRET` is **unset**, both reads (status,
68
- instances, incidents, projections, the operator guide) and mutations (submit work,
69
- answer escalations, cancel/retry/resolve) work from loopback with no credential. When it
70
- **is set**, that secret is required as an `x-hook-secret` header on **both reads and
71
- mutations** — read endpoints like `getAgentInstructions`/`getVersion` also return `401`
72
- without it — put it in the server entry's `headers`, never in chat. **Operator-only doors stay operator-only:** the delivery-graph
73
- dispatch/dismiss lifecycle (the human clicking Dispatch *is* the approval, ADR 0005)
74
- is `x-mcp`-excluded and is **not** a tool — dispatch stays a human action in the
55
+ **Guard posture.** If `NANO_PR_WEBHOOK_SECRET` is set, every read and mutation needs it as an
56
+ `x-hook-secret` header (otherwise `401`). Put it in the server entry's `headers`, never in
57
+ chat. Delivery-graph dispatch is deliberately **not** a tool: it stays a human action in the
75
58
  cockpit.
76
59
 
77
60
  The full server-entry recipes (multiple instances, Basic-Auth-fronted instances,
@@ -129,45 +112,17 @@ curl -sS ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"}
129
112
  `GET /app/api/agent` (`getAgentInstructions`) returns
130
113
  `{ format, appVersion, generatedAt, baseUrl, engineBase, instructions }` — the
131
114
  authoritative, version-matched operator guide, every example keyed to this
132
- instance. Read it in full and follow it for orientation, submitting work, answering
133
- escalations, and debugging. **Re-fetch it at the start of every session.** (This is
134
- the same prose the `getAgentInstructions` MCP tool serves in §A.)
135
-
136
- ### B.3 Orient before acting
137
-
138
- ```bash
139
- curl -sS "$BASE/version" | jq # app/urban version, git sha, uptime
140
- curl -sS "$BASE/status" | jq # every PR/instance in flight + open escalations
141
- ```
142
-
143
- `/status` is the primary situational-awareness endpoint — check it before you submit
144
- or unstick anything.
145
-
146
- ### B.4 What you can drive (all detailed in the live guide)
147
-
148
- - **Submit a PR** for review convergence — `POST $BASE/actions/start/convergence-loop`.
149
- - **Submit an issue/epic** for plan → implement → converge — `POST $BASE/actions/start/plan-fanout`.
150
- - **Submit a delivery graph** (ADR 0005) — propose → preview → approve → **dispatch**.
151
- Compile/preview is a pure, side-effect-free path; **dispatch is an operator action
152
- in the cockpit**, not an agent door.
153
- - **Answer an escalation** — `POST $BASE/actions/complete-user-task`, or the agent
154
- hook `POST $BASE/hooks/agent-complete` (`agentCompleteEscalation`).
155
- - **Debug** — relate an in-flight PR to its engine process instance via `processKey`
156
- from `/status`, then inspect and unstick it against the engine REST base
157
- (`engineBase` from the guide).
115
+ instance. Read it and follow it: orientation, submitting work, answering escalations,
116
+ debugging. **Re-fetch it every session.** For one section, use
117
+ `GET $BASE/agent/guide?section=<id>` (no `section` = table of contents).
158
118
 
159
119
  ## Principles
160
120
 
161
- - **Discover, don't declare.** Prefer the live surface (MCP tools including the
162
- operator-guide tool, or the live guide and `/status`) over any assumption baked into
163
- this file. If this skill and the live surface disagree, the live surface wins.
164
- - **Confirm the target instance.** Never run a side-effecting call against an assumed
165
- base URL. With MCP, name the server entry; with the fallback, know — and when
166
- ambiguous, ask — which instance you're driving.
167
- - **Preview before dispatch.** For delivery graphs and any bulk action, use the pure
168
- preview/validate path first and show the user the plan before the side-effecting
169
- start. Dispatch itself is the operator's call.
170
- - **Idempotency.** Submissions carry dedupe keys; re-submitting the same work must not
171
- double-dispatch. Honour the keys the guide documents.
172
- - **Escalations are for humans.** When the workforce parks on a human node, surface it
173
- with options; don't silently auto-answer design/product decisions.
121
+ - **The live surface wins.** If this skill and the instance disagree, follow the instance.
122
+ - **Confirm the target instance** before any side-effecting call (name the MCP server entry,
123
+ or ask when the base URL is ambiguous).
124
+ - **Preview before dispatch.** Show the user the plan before any bulk or side-effecting start.
125
+ Dispatching a delivery graph is the operator's call.
126
+ - **Submissions are idempotent.** Re-submitting the same work must not double-dispatch.
127
+ - **Escalations are for humans.** Surface them with options; don't silently answer design or
128
+ product decisions.
@@ -0,0 +1,256 @@
1
+ // Unit coverage for pr.record-feature's "PR opened" issue link (issue #907).
2
+ //
3
+ // record-feature is the ONE point every PR-delivering path of a single-issue feature run passes
4
+ // through — the agent's own `opened`, a retry's `opened`, and the reconcile step's ADOPTED `opened`
5
+ // all land here — so it is the canonical host-side writer of the idempotent
6
+ // `🤖 Pull request opened: #n` comment on the claimed issue. The decision itself lives in
7
+ // app/issuePrLink.ts (exhaustively covered in app/issuePrLink.test.ts); these tests pin the
8
+ // WIRING: the worker records the owed link + attempts it only for a claimed (single-issue) run
9
+ // that delivered a keyed PR, the comment targets the CLAIMED issue (the run row's repo +
10
+ // issue_number, not the PR's repository), and a failed comment never fails the job.
11
+ import { test } from "node:test";
12
+ import { assert, assertEquals } from "#test-assert";
13
+ import { noopLog } from "../../test/log.ts";
14
+ import handler from "./worker.ts";
15
+
16
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-feature-implementing.worker.test
17
+ function fakeApp(rows: Record<string, unknown>[]): { app: any; linkWrites: string[] } {
18
+ const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
19
+ const linkWrites: string[] = [];
20
+ return {
21
+ app: {
22
+ stores,
23
+ data: {
24
+ table(name: string, key: string) {
25
+ const store = (stores[name] ??= []);
26
+ return {
27
+ // biome-ignore lint/suspicious/noExplicitAny: test double
28
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
29
+ // biome-ignore lint/suspicious/noExplicitAny: test double
30
+ update: (k: any, patch: any) => {
31
+ const row = store.find((r) => r[key] === k);
32
+ if (row) Object.assign(row, patch);
33
+ return Promise.resolve(row);
34
+ },
35
+ };
36
+ },
37
+ open() {
38
+ return {
39
+ exec(sql: string, params: unknown[]) {
40
+ linkWrites.push(sql);
41
+ // Mirror the issue-link state machine's SQL on the in-memory row so the link record
42
+ // is observable: the owed write, the lease CAS, and the confirm.
43
+ const row = stores.feature_runs[0];
44
+ if (!row) return { changed: 0 };
45
+ if (/SET "pr_link_want_pr_key" = \?/.test(sql)) {
46
+ row.pr_link_want_pr_key = String(params[0]);
47
+ return { changed: 1 };
48
+ }
49
+ if (/SET "pr_link_lease_at" = \?/.test(sql)) {
50
+ row.pr_link_lease_at = String(params[0]);
51
+ return { changed: 1 };
52
+ }
53
+ if (/SET "pr_linked_pr_key" = \?, "pr_link_lease_at" = NULL/.test(sql)) {
54
+ row.pr_linked_pr_key = String(params[0]);
55
+ row.pr_link_lease_at = null;
56
+ return { changed: 1 };
57
+ }
58
+ return { changed: 0 };
59
+ },
60
+ };
61
+ },
62
+ },
63
+ log: noopLog(),
64
+ },
65
+ linkWrites,
66
+ };
67
+ }
68
+
69
+ /** Hermetic GitHub stub (token transport + fetch double) capturing posted comment bodies and the
70
+ * path of every call (so a test can pin WHICH repo/issue a comment or title read targeted). */
71
+ function stubGithubComments(existing: { body: string }[] = [], opts: { failPost?: boolean } = {}) {
72
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
73
+ const prevToken = process.env.GITHUB_TOKEN;
74
+ const prevFetch = globalThis.fetch;
75
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
76
+ process.env.GITHUB_TOKEN = "t";
77
+ const posted: string[] = [];
78
+ const calls: string[] = [];
79
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
80
+ const method = (init?.method ?? "GET").toUpperCase();
81
+ const u = new URL(String(url));
82
+ calls.push(`${method} ${u.pathname}`);
83
+ if (method === "GET" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
84
+ return new Response(JSON.stringify(existing), { status: 200 });
85
+ }
86
+ if (method === "GET" && /\/issues\/\d+$/.test(u.pathname)) {
87
+ return new Response(JSON.stringify({ title: "the issue" }), { status: 200 });
88
+ }
89
+ if (method === "GET" && /\/pulls\/\d+$/.test(u.pathname)) {
90
+ return new Response(JSON.stringify({ title: "the PR" }), { status: 200 });
91
+ }
92
+ if (method === "POST" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
93
+ if (opts.failPost) return new Response("boom", { status: 500 });
94
+ // biome-ignore lint/plugin: runtime contract boundary for parsed JSON in a test double
95
+ posted.push(String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""));
96
+ return new Response(JSON.stringify({ id: 1 }), { status: 201 });
97
+ }
98
+ return new Response("Not Found", { status: 404 });
99
+ }) as typeof fetch;
100
+ return {
101
+ posted,
102
+ calls,
103
+ restore: () => {
104
+ globalThis.fetch = prevFetch;
105
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
106
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
107
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
108
+ else process.env.GITHUB_TOKEN = prevToken;
109
+ },
110
+ };
111
+ }
112
+
113
+ const ROW = { feature_key: "owner/repo#7", repo: "owner/repo", issue_number: 7, status: "running" };
114
+
115
+ test("an opened run on a claimed issue posts the PR-opened comment and records the link", async () => {
116
+ const gh = stubGithubComments();
117
+ const rows = [{ ...ROW }];
118
+ const { app } = fakeApp(rows);
119
+ try {
120
+ const out = await handler(
121
+ { jobKey: "j1", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
122
+ app,
123
+ );
124
+ assertEquals(out.featureStatus, "opened");
125
+ assertEquals(out.prKey, "owner/repo#101");
126
+ assertEquals(gh.posted.length, 1, "the claimed issue got the PR-opened comment");
127
+ assert(gh.posted[0].includes("Pull request opened: #101"), "the comment names the PR");
128
+ assert(
129
+ gh.posted[0].includes("<!-- nwf:pr-linked:#101 https://github.com/owner/repo/pull/101 -->"),
130
+ "the comment carries the URL-keyed dedupe marker",
131
+ );
132
+ assertEquals(rows[0].pr_link_want_pr_key, "owner/repo#101", "the owed link was recorded");
133
+ assertEquals(rows[0].pr_linked_pr_key, "owner/repo#101", "the durable link was confirmed");
134
+ } finally {
135
+ gh.restore();
136
+ }
137
+ });
138
+
139
+ test("an ADOPTED opened (reconcile path) posts the comment too — the worker cannot tell the difference", async () => {
140
+ const gh = stubGithubComments();
141
+ const rows = [{ ...ROW }];
142
+ const { app } = fakeApp(rows);
143
+ try {
144
+ // The reconcile step re-emitted status=opened + pr for a PR the harness never reported; the
145
+ // record worker treats it exactly like an agent-reported opened.
146
+ await handler(
147
+ { jobKey: "j2", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#801", claimIssue: true } } as never,
148
+ app,
149
+ );
150
+ assertEquals(gh.posted.length, 1, "the adopted PR was announced on the issue");
151
+ assert(gh.posted[0].includes("#801"), "the comment names the adopted PR");
152
+ } finally {
153
+ gh.restore();
154
+ }
155
+ });
156
+
157
+ test("a CROSS-REPO PR comments on the CLAIMED issue (the run row's repo + issue_number), not the PR's repo", async () => {
158
+ // Regression for the review finding: the old worker derived the comment target from the PR's
159
+ // repository + the issue NUMBER, so a run for owner/issues#7 delivering owner/code#101 commented
160
+ // on owner/code#7 (or 404'd). The target must come from the run row; the PR repo is used only
161
+ // for the title lookup.
162
+ const gh = stubGithubComments();
163
+ const rows = [{ feature_key: "owner/issues#7", repo: "owner/issues", issue_number: 7, status: "running" }];
164
+ const { app } = fakeApp(rows);
165
+ try {
166
+ const out = await handler(
167
+ { jobKey: "j2x", variables: { featureKey: "owner/issues#7", status: "opened", pr: "owner/code#101", claimIssue: true } } as never,
168
+ app,
169
+ );
170
+ assertEquals(out.prKey, "owner/code#101");
171
+ assertEquals(gh.posted.length, 1, "the claimed issue got the PR-opened comment");
172
+ assert(
173
+ gh.calls.some((c) => c === "POST /repos/owner/issues/issues/7/comments"),
174
+ `the comment landed on the claimed issue owner/issues#7 (calls: ${gh.calls.join(", ")})`,
175
+ );
176
+ assert(
177
+ gh.calls.some((c) => c === "GET /repos/owner/code/pulls/101"),
178
+ "the PR title was read from the PR's OWN repository",
179
+ );
180
+ assert(
181
+ !gh.calls.some((c) => c.includes("/repos/owner/code/issues/")),
182
+ "the PR repository's issues were never targeted",
183
+ );
184
+ assert(gh.posted[0].includes("https://github.com/owner/code/pull/101"), "the comment links the cross-repo PR");
185
+ } finally {
186
+ gh.restore();
187
+ }
188
+ });
189
+
190
+ test("a repeated record pass for the SAME PR posts nothing more (idempotent)", async () => {
191
+ const gh = stubGithubComments();
192
+ const rows = [{ ...ROW, pr_link_want_pr_key: "owner/repo#101", pr_linked_pr_key: "owner/repo#101" }];
193
+ const { app, linkWrites } = fakeApp(rows);
194
+ try {
195
+ await handler(
196
+ { jobKey: "j3", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
197
+ app,
198
+ );
199
+ assertEquals(gh.posted.length, 0, "no duplicate comment");
200
+ assert(
201
+ !linkWrites.some((sql) => /SET "pr_link_lease_at"/.test(sql)),
202
+ "the confirmed link short-circuited before the lease CAS (and before any GitHub read)",
203
+ );
204
+ } finally {
205
+ gh.restore();
206
+ }
207
+ });
208
+
209
+ test("an epic slice (claimIssue unset) posts NOTHING on its parent epic", async () => {
210
+ const gh = stubGithubComments();
211
+ const rows = [{ ...ROW }];
212
+ const { app, linkWrites } = fakeApp(rows);
213
+ try {
214
+ await handler(
215
+ { jobKey: "j4", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101" } } as never,
216
+ app,
217
+ );
218
+ assertEquals(gh.posted.length, 0, "no comment was posted");
219
+ assertEquals(linkWrites.length, 0, "no link state was written");
220
+ } finally {
221
+ gh.restore();
222
+ }
223
+ });
224
+
225
+ test("a blocked run posts nothing", async () => {
226
+ const gh = stubGithubComments();
227
+ const rows = [{ ...ROW }];
228
+ const { app } = fakeApp(rows);
229
+ try {
230
+ const out = await handler(
231
+ { jobKey: "j5", variables: { featureKey: "owner/repo#7", status: "blocked", claimIssue: true } } as never,
232
+ app,
233
+ );
234
+ assertEquals(out.featureStatus, "blocked");
235
+ assertEquals(gh.posted.length, 0);
236
+ } finally {
237
+ gh.restore();
238
+ }
239
+ });
240
+
241
+ test("a failed comment POST never fails the job — the run still records opened", async () => {
242
+ const gh = stubGithubComments([], { failPost: true });
243
+ const rows = [{ ...ROW }];
244
+ const { app } = fakeApp(rows);
245
+ try {
246
+ const out = await handler(
247
+ { jobKey: "j6", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
248
+ app,
249
+ );
250
+ assertEquals(out.featureStatus, "opened", "the job completed normally");
251
+ assertEquals(rows[0].status, "opened", "the run row was still recorded");
252
+ assertEquals(rows[0].pr_key, "owner/repo#101");
253
+ } finally {
254
+ gh.restore();
255
+ }
256
+ });
@@ -11,8 +11,18 @@
11
11
  //
12
12
  // Enrollment itself lives in the separate `pr.converge-feature` worker (gated by the `converge?`
13
13
  // branch) so the hand-off is legible in the process, not buried in a persistence step.
14
+ //
15
+ // This is ALSO the one point every PR-delivering path passes through — the agent's own `opened`,
16
+ // a retry's `opened`, and the implement-cell reconcile step's ADOPTED `opened` all land here — so
17
+ // it is the canonical host-side writer of the idempotent "PR opened" comment on the claimed issue
18
+ // (issue #907, app/issuePrLink.ts): a claimed single-issue run (`claimIssue`) that delivered a
19
+ // keyed PR gets exactly one `🤖 Pull request opened: #n` comment on its issue, however the PR was
20
+ // delivered. Epic slices never set `claimIssue`, so they never comment on the parent epic. The
21
+ // comment is best-effort — a failure never fails the run; the owed link is persisted and the
22
+ // poller's retry pass (app/service.ts → retryOutstandingIssuePrLinks) re-attempts it.
14
23
  import type { AppJobHandler } from "@nanobpm/urban";
15
24
  import { type FeatureRunStatus, featureRuns } from "../../app/feature.ts";
25
+ import { requestIssuePrLink } from "../../app/issuePrLink.ts";
16
26
  import { parsePr } from "../../app/service.ts";
17
27
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
18
28
 
@@ -58,6 +68,23 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
58
68
  });
59
69
  app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
60
70
 
71
+ // Issue #907: announce the delivered PR on the claimed issue — ONE idempotent comment, posted by
72
+ // the HOST so every delivery path (agent-opened, retry-opened, reconcile-adopted) covers it.
73
+ // Only a claimed single-issue run (`claimIssue`) with a keyed PR qualifies; epic slices never set
74
+ // `claimIssue`, so they never comment on the parent epic. The ISSUE target comes from the run row
75
+ // (`repo` + `issue_number`), never from the PR's repository, so a cross-repo PR still lands on the
76
+ // claimed issue. Best-effort: a failed attempt leaves the owed link for the poller to retry and
77
+ // never fails the run.
78
+ const link = await requestIssuePrLink(
79
+ {
80
+ featureKey,
81
+ prKey,
82
+ claimIssue: job.variables.claimIssue,
83
+ },
84
+ app.data,
85
+ );
86
+ if (link !== "skipped") app.log.info("record-feature: issue PR link", { featureKey, prKey, link });
87
+
61
88
  return { featureStatus, prKey };
62
89
  };
63
90