@nanobpm/nano-workforce 0.203.1 → 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.
@@ -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