@nanobpm/nano-workforce 0.203.1 → 0.204.1
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/app/feature.ts +9 -0
- package/app/github.ts +14 -6
- package/app/issuePrLink.test.ts +703 -0
- package/app/issuePrLink.ts +312 -0
- package/app/service.ts +3 -0
- package/db/migrations/125_feature_runs_pr_linked.sql +78 -0
- package/e2e/feature-run.e2e.ts +58 -1
- package/e2e/plan-fanout.e2e.ts +24 -1
- package/e2e/support/github-admit.ts +33 -0
- package/package.json +2 -2
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +1 -0
- package/workers/record-feature/worker.test.ts +256 -0
- package/workers/record-feature/worker.ts +27 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
// Unit coverage for the canonical "PR opened" issue-link decision (issue #907).
|
|
2
|
+
//
|
|
3
|
+
// When a single-issue feature run delivers its PR, the HOST posts one idempotent
|
|
4
|
+
// `🤖 Pull request opened: #n — …` comment on the claimed issue, so the issue thread links to the
|
|
5
|
+
// delivered PR even when the agent (or a retry, or the reconcile-adopt path) opened it without
|
|
6
|
+
// saying so. This module is the ONE writer (derivation over duplication): `pr.record-feature`
|
|
7
|
+
// records the owed link and attempts it once on the delivery point; the poller's
|
|
8
|
+
// `retryOutstandingIssuePrLinks` pass re-attempts every outstanding link.
|
|
9
|
+
//
|
|
10
|
+
// The durable state machine (migration 125) is pinned here:
|
|
11
|
+
// • `pr_link_want_pr_key` — the owed PR (written by `requestIssuePrLink`),
|
|
12
|
+
// • `pr_linked_pr_key` — the CONFIRMED link (written only after a successful POST or a marker
|
|
13
|
+
// match — a pending lease is NEVER mistaken for a confirmed link), and
|
|
14
|
+
// • `pr_link_lease_at` — an EXPIRING in-flight lease, CAS-claimed before any GitHub I/O so two
|
|
15
|
+
// racing passes never both post; a failure leaves it to expire (the retry backoff),
|
|
16
|
+
// plus the hidden `<!-- nwf:pr-linked:#n <canonical-pr-url> -->` marker scan over ALL pages of the
|
|
17
|
+
// issue's comments, which heals a crash between a successful POST and the confirm write. The marker
|
|
18
|
+
// carries the canonical PR URL (not just the number): PR numbers are repository-local, so a
|
|
19
|
+
// number-only marker would let a same-number PR in a DIFFERENT repo satisfy the scan and confirm a
|
|
20
|
+
// link that was never announced (issue #907 review).
|
|
21
|
+
//
|
|
22
|
+
// The GitHub reads/writes are hermetically stubbed via the pinned `token` transport + a fetch
|
|
23
|
+
// double (mirrors workers/reconcile-implement/worker.test.ts) — no `gh` subprocess, no network.
|
|
24
|
+
import { test } from "node:test";
|
|
25
|
+
import { assert, assertEquals } from "#test-assert";
|
|
26
|
+
import {
|
|
27
|
+
attemptIssuePrLink,
|
|
28
|
+
ISSUE_PR_LINK_IO_TIMEOUT_MS,
|
|
29
|
+
ISSUE_PR_LINK_LEASE_MS,
|
|
30
|
+
ISSUE_PR_LINK_MARKER_PREFIX,
|
|
31
|
+
prOpenedCommentBody,
|
|
32
|
+
requestIssuePrLink,
|
|
33
|
+
retryOutstandingIssuePrLinks,
|
|
34
|
+
} from "./issuePrLink.ts";
|
|
35
|
+
|
|
36
|
+
// ── Fetch stub ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
interface PostedComment {
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A hermetic GitHub API double: serves the seeded comment bodies on GET …/issues/{n}/comments
|
|
43
|
+
* (paginated — `pages` maps a 1-based page to that page's bodies, defaulting to one page holding
|
|
44
|
+
* `existing`), serves a PR title on GET …/pulls/{n}, and records comment POSTs (optionally failing
|
|
45
|
+
* them, or THROWING to simulate a connection failure). Records the URL path of every call. */
|
|
46
|
+
function stubGithub(
|
|
47
|
+
existing: { body: string }[],
|
|
48
|
+
opts: {
|
|
49
|
+
failList?: boolean;
|
|
50
|
+
failPost?: boolean;
|
|
51
|
+
throwPost?: boolean;
|
|
52
|
+
/** Hang every comment POST until its abort signal fires (a POST stalled past its deadline). */
|
|
53
|
+
stallPost?: boolean;
|
|
54
|
+
/** Hang the PR-title GET until its abort signal fires (a title lookup stalled past its deadline). */
|
|
55
|
+
stallTitle?: boolean;
|
|
56
|
+
pages?: Record<number, { body: string }[]>;
|
|
57
|
+
prTitles?: Record<string, string>;
|
|
58
|
+
/** Fired once during the FIRST comment-list GET — simulates a concurrent pass acting while the
|
|
59
|
+
* worker is stalled on the scan (e.g. the poller re-claiming an expired lease and posting). */
|
|
60
|
+
onFirstList?: () => void;
|
|
61
|
+
} = {},
|
|
62
|
+
): { posted: PostedComment[]; calls: string[]; restore: () => void } {
|
|
63
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
64
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
65
|
+
const prevFetch = globalThis.fetch;
|
|
66
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
67
|
+
process.env.GITHUB_TOKEN = "t";
|
|
68
|
+
const posted: PostedComment[] = [];
|
|
69
|
+
const calls: string[] = [];
|
|
70
|
+
let listed = false;
|
|
71
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
72
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
73
|
+
const u = new URL(String(url));
|
|
74
|
+
calls.push(`${method} ${u.pathname}${u.search}`);
|
|
75
|
+
if (method === "GET" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
|
|
76
|
+
if (!listed) {
|
|
77
|
+
listed = true;
|
|
78
|
+
opts.onFirstList?.();
|
|
79
|
+
}
|
|
80
|
+
if (opts.failList) return new Response("boom", { status: 500 });
|
|
81
|
+
const page = Number(u.searchParams.get("page") ?? "1");
|
|
82
|
+
const bodies = opts.pages ? (opts.pages[page] ?? []) : page === 1 ? existing : [];
|
|
83
|
+
return new Response(JSON.stringify(bodies), { status: 200 });
|
|
84
|
+
}
|
|
85
|
+
if (method === "GET" && /\/pulls\/\d+$/.test(u.pathname)) {
|
|
86
|
+
if (opts.stallTitle) {
|
|
87
|
+
const signal = init?.signal;
|
|
88
|
+
await new Promise<never>((_, reject) => {
|
|
89
|
+
if (!signal) return; // unbounded caller: hangs forever (the test then times out — RED)
|
|
90
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const title = opts.prTitles?.[u.pathname] ?? "the PR";
|
|
94
|
+
return new Response(JSON.stringify({ title }), { status: 200 });
|
|
95
|
+
}
|
|
96
|
+
if (method === "POST" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
|
|
97
|
+
if (opts.throwPost) throw new TypeError("fetch failed: connection reset");
|
|
98
|
+
if (opts.stallPost) {
|
|
99
|
+
const signal = init?.signal;
|
|
100
|
+
await new Promise<never>((_, reject) => {
|
|
101
|
+
if (!signal) return; // unbounded caller: hangs forever (the test then times out — RED)
|
|
102
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (opts.failPost) return new Response("boom", { status: 500 });
|
|
106
|
+
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON in a test double
|
|
107
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string };
|
|
108
|
+
posted.push({ body: String(body.body ?? "") });
|
|
109
|
+
return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
110
|
+
}
|
|
111
|
+
return new Response("unexpected", { status: 500 });
|
|
112
|
+
}) as typeof fetch;
|
|
113
|
+
return {
|
|
114
|
+
posted,
|
|
115
|
+
calls,
|
|
116
|
+
restore: () => {
|
|
117
|
+
globalThis.fetch = prevFetch;
|
|
118
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
119
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
120
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
121
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── DB double (feature_runs row + the three state-machine columns) ────────────
|
|
127
|
+
|
|
128
|
+
interface FeatureRowDouble {
|
|
129
|
+
feature_key: string;
|
|
130
|
+
repo?: string;
|
|
131
|
+
issue_number?: number;
|
|
132
|
+
pr_link_want_pr_key?: string | null;
|
|
133
|
+
pr_linked_pr_key?: string | null;
|
|
134
|
+
pr_link_lease_at?: string | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The minimal DataLayer double the link step touches: `table("feature_runs").get` plus the
|
|
138
|
+
* `open().exec`/`open().query` SQL the state machine runs. The execs are applied to the in-memory
|
|
139
|
+
* row(s) with the same guards the real CAS carries, so the tests observe exactly what SQLite
|
|
140
|
+
* would do. `claimSucceeds: false` forces the lease CAS to report changed=0 (a live lease held by
|
|
141
|
+
* another pass); `claimThrows` simulates a DB failure. */
|
|
142
|
+
function fakeData(
|
|
143
|
+
seedRows: FeatureRowDouble[],
|
|
144
|
+
opts: { claimSucceeds?: boolean; claimThrows?: boolean; scanThrows?: boolean } = {},
|
|
145
|
+
): { data: never; rows: FeatureRowDouble[]; leaseAttempts: number } {
|
|
146
|
+
const rows = seedRows;
|
|
147
|
+
let leaseAttempts = 0;
|
|
148
|
+
const data = {
|
|
149
|
+
table: (name: string, _key: string) => {
|
|
150
|
+
assertEquals(name, "feature_runs");
|
|
151
|
+
return {
|
|
152
|
+
get: (k: string) => Promise.resolve(rows.find((r) => r.feature_key === k) ?? null),
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
open: () => ({
|
|
156
|
+
exec: (sql: string, params: unknown[]) => {
|
|
157
|
+
if (opts.claimThrows) throw new Error("db down");
|
|
158
|
+
// requestIssuePrLink's "owed" write: SET pr_link_want_pr_key = ? WHERE feature_key = ?
|
|
159
|
+
if (/UPDATE "feature_runs" SET "pr_link_want_pr_key" = \?/.test(sql)) {
|
|
160
|
+
const row = rows.find((r) => r.feature_key === params[1]);
|
|
161
|
+
if (!row) return { changed: 0 };
|
|
162
|
+
row.pr_link_want_pr_key = String(params[0]);
|
|
163
|
+
return { changed: 1 };
|
|
164
|
+
}
|
|
165
|
+
// The pre-post lease re-validation: SET pr_link_lease_at = ? (a FRESH ts) WHERE
|
|
166
|
+
// feature_key = ? AND pr_link_lease_at = ? (the ts this pass claimed). Only the CURRENT
|
|
167
|
+
// lease holder flips — a stale holder whose lease was re-claimed by the poller (changed=0)
|
|
168
|
+
// is rejected BEFORE it can post a duplicate. This MUST be matched BEFORE the initial-claim
|
|
169
|
+
// pattern below: both start `SET "pr_link_lease_at" = ?`, so the more-specific self-guard
|
|
170
|
+
// (`AND "pr_link_lease_at" = ?`) is tested first.
|
|
171
|
+
if (/UPDATE "feature_runs" SET "pr_link_lease_at" = \? WHERE "feature_key" = \? AND "pr_link_lease_at" = \?/.test(sql)) {
|
|
172
|
+
const [freshTs, featureKey, claimedTs] = params.map(String);
|
|
173
|
+
const row = rows.find((r) => r.feature_key === featureKey && r.pr_link_lease_at === claimedTs);
|
|
174
|
+
if (!row) return { changed: 0 };
|
|
175
|
+
row.pr_link_lease_at = freshTs;
|
|
176
|
+
return { changed: 1 };
|
|
177
|
+
}
|
|
178
|
+
// The lease CAS: SET pr_link_lease_at = ? WHERE feature_key = ? AND want = ? AND
|
|
179
|
+
// (linked IS NULL OR linked != want) AND (lease IS NULL OR lease < expired). The
|
|
180
|
+
// `claimSucceeds: false` double simulates a lost race by RE-ARMING the row with a fresh
|
|
181
|
+
// live lease and reporting changed=0 (what a concurrent winner's claim looks like).
|
|
182
|
+
if (/UPDATE "feature_runs" SET "pr_link_lease_at" = \?/.test(sql)) {
|
|
183
|
+
leaseAttempts += 1;
|
|
184
|
+
const [ts, featureKey, want, want2, expired] = params.map(String);
|
|
185
|
+
assertEquals(want, want2, "the CAS guards the owed key against both params");
|
|
186
|
+
const row = rows.find((r) => r.feature_key === featureKey);
|
|
187
|
+
const guarded =
|
|
188
|
+
row &&
|
|
189
|
+
row.pr_link_want_pr_key === want &&
|
|
190
|
+
(row.pr_linked_pr_key == null || row.pr_linked_pr_key !== want) &&
|
|
191
|
+
(row.pr_link_lease_at == null || row.pr_link_lease_at < expired);
|
|
192
|
+
if (opts.claimSucceeds === false) {
|
|
193
|
+
if (row) row.pr_link_lease_at = ts; // the "other pass" now holds a live lease
|
|
194
|
+
return { changed: 0 };
|
|
195
|
+
}
|
|
196
|
+
const changed = guarded ? 1 : 0;
|
|
197
|
+
if (changed > 0 && row) row.pr_link_lease_at = ts;
|
|
198
|
+
return { changed };
|
|
199
|
+
}
|
|
200
|
+
// The confirm: SET pr_linked_pr_key = ?, pr_link_lease_at = NULL WHERE feature_key = ?
|
|
201
|
+
// AND pr_link_lease_at = ? (only the lease holder confirms).
|
|
202
|
+
if (/UPDATE "feature_runs" SET "pr_linked_pr_key" = \?, "pr_link_lease_at" = NULL/.test(sql)) {
|
|
203
|
+
const [want, featureKey, lease] = params.map(String);
|
|
204
|
+
const row = rows.find((r) => r.feature_key === featureKey && r.pr_link_lease_at === lease);
|
|
205
|
+
if (!row) return { changed: 0 };
|
|
206
|
+
row.pr_linked_pr_key = want;
|
|
207
|
+
row.pr_link_lease_at = null;
|
|
208
|
+
return { changed: 1 };
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`unexpected SQL in test double: ${sql}`);
|
|
211
|
+
},
|
|
212
|
+
query: (sql: string, params: unknown[]) => {
|
|
213
|
+
if (opts.scanThrows) throw new Error("db down");
|
|
214
|
+
assert(
|
|
215
|
+
/FROM "feature_runs" WHERE "pr_link_want_pr_key" IS NOT NULL/.test(sql),
|
|
216
|
+
`the retry pass scans only owed rows (got: ${sql})`,
|
|
217
|
+
);
|
|
218
|
+
const expired = String(params[0]);
|
|
219
|
+
return Promise.resolve(
|
|
220
|
+
rows
|
|
221
|
+
.filter(
|
|
222
|
+
(r) =>
|
|
223
|
+
r.pr_link_want_pr_key != null &&
|
|
224
|
+
(r.pr_linked_pr_key == null || r.pr_linked_pr_key !== r.pr_link_want_pr_key) &&
|
|
225
|
+
(r.pr_link_lease_at == null || r.pr_link_lease_at < expired),
|
|
226
|
+
)
|
|
227
|
+
.map((r) => ({ feature_key: r.feature_key })),
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
data: data as never,
|
|
234
|
+
rows,
|
|
235
|
+
get leaseAttempts() {
|
|
236
|
+
return leaseAttempts;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const KEY = "owner/repo#7"; // the feature run (claimed issue owner/repo#7)
|
|
242
|
+
const PR = "owner/repo#101"; // the delivered PR key
|
|
243
|
+
const PR_URL = "https://github.com/owner/repo/pull/101";
|
|
244
|
+
|
|
245
|
+
/** Seed a run row owed a link for PR (as requestIssuePrLink would have written). */
|
|
246
|
+
function owedRow(extra: Partial<FeatureRowDouble> = {}): FeatureRowDouble {
|
|
247
|
+
return {
|
|
248
|
+
feature_key: KEY,
|
|
249
|
+
repo: "owner/repo",
|
|
250
|
+
issue_number: 7,
|
|
251
|
+
pr_link_want_pr_key: PR,
|
|
252
|
+
pr_linked_pr_key: null,
|
|
253
|
+
pr_link_lease_at: null,
|
|
254
|
+
...extra,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Comment body ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
test("the comment body carries the PR number, title, URL and the hidden dedupe marker", () => {
|
|
261
|
+
const body = prOpenedCommentBody({ number: 101, title: "Add the thing", url: PR_URL });
|
|
262
|
+
assert(body.includes("Pull request opened: #101"), "names the PR number");
|
|
263
|
+
assert(body.includes("Add the thing"), "names the PR title");
|
|
264
|
+
assert(body.includes(PR_URL), "links the PR URL");
|
|
265
|
+
assert(
|
|
266
|
+
body.includes(`${ISSUE_PR_LINK_MARKER_PREFIX}101 ${PR_URL} -->`),
|
|
267
|
+
"carries the hidden per-PR dedupe marker keyed on the canonical PR URL",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("the comment body tolerates a missing title", () => {
|
|
272
|
+
const body = prOpenedCommentBody({ number: 101, title: null, url: PR_URL });
|
|
273
|
+
assert(body.includes("Pull request opened: #101"), "names the PR number");
|
|
274
|
+
assert(body.includes(PR_URL), "links the PR URL");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── Gating (requestIssuePrLink) ───────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
test("an epic slice (claimIssue not true) posts NOTHING and touches no state", async () => {
|
|
280
|
+
const gh = stubGithub([]);
|
|
281
|
+
const { data, rows, leaseAttempts } = fakeData([{ feature_key: KEY, repo: "owner/repo", issue_number: 7 }]);
|
|
282
|
+
try {
|
|
283
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: PR, claimIssue: undefined }, data);
|
|
284
|
+
assertEquals(out, "skipped");
|
|
285
|
+
assertEquals(gh.posted.length, 0, "no comment was posted");
|
|
286
|
+
assertEquals(leaseAttempts, 0, "no lease was attempted");
|
|
287
|
+
assertEquals(rows[0].pr_link_want_pr_key, undefined, "no owed link was recorded");
|
|
288
|
+
} finally {
|
|
289
|
+
gh.restore();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a blocked/skipped outcome (no PR) posts nothing and records no owed link", async () => {
|
|
294
|
+
const gh = stubGithub([]);
|
|
295
|
+
const { data, rows, leaseAttempts } = fakeData([{ feature_key: KEY, repo: "owner/repo", issue_number: 7 }]);
|
|
296
|
+
try {
|
|
297
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: null, claimIssue: true }, data);
|
|
298
|
+
assertEquals(out, "skipped");
|
|
299
|
+
assertEquals(gh.posted.length, 0);
|
|
300
|
+
assertEquals(leaseAttempts, 0);
|
|
301
|
+
assertEquals(rows[0].pr_link_want_pr_key, undefined, "no owed link was recorded");
|
|
302
|
+
} finally {
|
|
303
|
+
gh.restore();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("an unparseable PR key is a guarded no-op", async () => {
|
|
308
|
+
const gh = stubGithub([]);
|
|
309
|
+
const { data, rows } = fakeData([{ feature_key: KEY, repo: "owner/repo", issue_number: 7 }]);
|
|
310
|
+
try {
|
|
311
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: "not-a-pr-key", claimIssue: true }, data);
|
|
312
|
+
assertEquals(out, "skipped");
|
|
313
|
+
assertEquals(gh.posted.length, 0);
|
|
314
|
+
assertEquals(rows[0].pr_link_want_pr_key, undefined);
|
|
315
|
+
} finally {
|
|
316
|
+
gh.restore();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("a missing feature_runs row records nothing and posts nothing", async () => {
|
|
321
|
+
const gh = stubGithub([]);
|
|
322
|
+
const { data } = fakeData([]);
|
|
323
|
+
try {
|
|
324
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: PR, claimIssue: true }, data);
|
|
325
|
+
assertEquals(out, "skipped");
|
|
326
|
+
assertEquals(gh.posted.length, 0);
|
|
327
|
+
} finally {
|
|
328
|
+
gh.restore();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ── First delivery: posts exactly once and confirms ───────────────────────────
|
|
333
|
+
|
|
334
|
+
test("first delivery: records the owed link, claims the lease, finds no marker, posts and confirms", async () => {
|
|
335
|
+
const gh = stubGithub([]);
|
|
336
|
+
const { data, rows } = fakeData([{ feature_key: KEY, repo: "owner/repo", issue_number: 7 }]);
|
|
337
|
+
try {
|
|
338
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: PR, claimIssue: true }, data);
|
|
339
|
+
assertEquals(out, "posted");
|
|
340
|
+
assertEquals(rows[0].pr_link_want_pr_key, PR, "the owed link was recorded");
|
|
341
|
+
assertEquals(gh.posted.length, 1, "exactly one comment was posted");
|
|
342
|
+
assertEquals(gh.posted[0].body, prOpenedCommentBody({ number: 101, title: "the PR", url: PR_URL }));
|
|
343
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the link was CONFIRMED only after the successful POST");
|
|
344
|
+
assertEquals(rows[0].pr_link_lease_at, null, "the lease was released by the confirm");
|
|
345
|
+
} finally {
|
|
346
|
+
gh.restore();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── Idempotence ───────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
test("a repeated pass with the SAME pr already confirmed is a no-op (no GitHub read, no post)", async () => {
|
|
353
|
+
const gh = stubGithub([], { failList: true, failPost: true });
|
|
354
|
+
const { data, leaseAttempts } = fakeData([owedRow({ pr_linked_pr_key: PR })]);
|
|
355
|
+
try {
|
|
356
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: PR, claimIssue: true }, data);
|
|
357
|
+
assertEquals(out, "already-recorded");
|
|
358
|
+
assertEquals(leaseAttempts, 0, "no lease was attempted");
|
|
359
|
+
assertEquals(gh.posted.length, 0, "no comment was posted");
|
|
360
|
+
assertEquals(gh.calls.length, 0, "no GitHub I/O at all");
|
|
361
|
+
} finally {
|
|
362
|
+
gh.restore();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a lost confirm (comment posted, confirm never persisted) is healed by the marker scan — no double post", async () => {
|
|
367
|
+
// The marker for THIS PR is already on the issue (a previous pass posted it and then crashed
|
|
368
|
+
// before its confirm landed): the scan sees it and confirms WITHOUT re-posting.
|
|
369
|
+
const gh = stubGithub([{ body: `🤖 Pull request opened: #101\n\n${ISSUE_PR_LINK_MARKER_PREFIX}101 ${PR_URL} -->` }]);
|
|
370
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
371
|
+
try {
|
|
372
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
373
|
+
assertEquals(out, "already-marked");
|
|
374
|
+
assertEquals(gh.posted.length, 0, "the marker suppressed a duplicate comment");
|
|
375
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the healed link was confirmed");
|
|
376
|
+
assertEquals(rows[0].pr_link_lease_at, null, "the lease was released");
|
|
377
|
+
} finally {
|
|
378
|
+
gh.restore();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("a same-number marker from a DIFFERENT repo does NOT confirm the link — the comment is posted", async () => {
|
|
383
|
+
// PR numbers are repository-local: `owner/code#101` and `owner/repo#101` share the number 101 but
|
|
384
|
+
// are different PRs. A number-only marker would let the foreign `owner/code#101` comment satisfy
|
|
385
|
+
// the scan for the owed `owner/repo#101` and confirm a link that was never announced. The marker
|
|
386
|
+
// keys on the canonical PR URL, so the foreign marker does NOT match and the real PR is posted.
|
|
387
|
+
const foreign = `🤖 Pull request opened: #101\n\n${ISSUE_PR_LINK_MARKER_PREFIX}101 https://github.com/owner/code/pull/101 -->`;
|
|
388
|
+
const gh = stubGithub([{ body: foreign }]);
|
|
389
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
390
|
+
try {
|
|
391
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
392
|
+
assertEquals(out, "posted", "the foreign same-number marker did NOT confirm the link");
|
|
393
|
+
assertEquals(gh.posted.length, 1, "the owed PR was announced (the foreign marker was ignored)");
|
|
394
|
+
assert(gh.posted[0].body.includes(PR_URL), "the posted comment names THIS repo's PR URL");
|
|
395
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the link was confirmed only after the real POST");
|
|
396
|
+
} finally {
|
|
397
|
+
gh.restore();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a bare number-only marker (legacy/foreign) does NOT confirm the link without the canonical URL", async () => {
|
|
402
|
+
// A marker that names the number but carries no canonical URL (or a different one) must not
|
|
403
|
+
// satisfy the scan: only the exact `<!-- nwf:pr-linked:#<n> <url> -->` for THIS PR confirms.
|
|
404
|
+
const gh = stubGithub([{ body: `🤖 Pull request opened: #101\n\n${ISSUE_PR_LINK_MARKER_PREFIX}101 -->` }]);
|
|
405
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
406
|
+
try {
|
|
407
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
408
|
+
assertEquals(out, "posted", "the URL-less marker did NOT confirm the link");
|
|
409
|
+
assertEquals(gh.posted.length, 1, "the owed PR was announced");
|
|
410
|
+
assertEquals(rows[0].pr_linked_pr_key, PR);
|
|
411
|
+
} finally {
|
|
412
|
+
gh.restore();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("the marker scan paginates through EVERY page before deciding the marker is absent", async () => {
|
|
417
|
+
// 100 bodies on page 1 (a full page) with the marker only on page 2: a first-page-only scan
|
|
418
|
+
// would double-post; the paginated scan finds it and confirms without posting.
|
|
419
|
+
const page1 = Array.from({ length: 100 }, (_, i) => ({ body: `comment ${i}` }));
|
|
420
|
+
const page2 = [{ body: `🤖 Pull request opened: #101\n\n${ISSUE_PR_LINK_MARKER_PREFIX}101 ${PR_URL} -->` }];
|
|
421
|
+
const gh = stubGithub([], { pages: { 1: page1, 2: page2 } });
|
|
422
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
423
|
+
try {
|
|
424
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
425
|
+
assertEquals(out, "already-marked");
|
|
426
|
+
assertEquals(gh.posted.length, 0, "the later-page marker suppressed a duplicate comment");
|
|
427
|
+
assert(gh.calls.some((c) => c.includes("page=2")), "page 2 was scanned");
|
|
428
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the healed link was confirmed");
|
|
429
|
+
} finally {
|
|
430
|
+
gh.restore();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a concurrent lease holder (CAS changed=0) does NOT post — the holder's pass owns the comment", async () => {
|
|
435
|
+
const gh = stubGithub([]);
|
|
436
|
+
const { data } = fakeData([owedRow()], { claimSucceeds: false });
|
|
437
|
+
try {
|
|
438
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
439
|
+
assertEquals(out, "claim-lost");
|
|
440
|
+
assertEquals(gh.posted.length, 0, "no comment was posted by the losing pass");
|
|
441
|
+
} finally {
|
|
442
|
+
gh.restore();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a live (unexpired) lease blocks a second attempt — the CAS guard rejects it, no GitHub I/O", async () => {
|
|
447
|
+
const gh = stubGithub([], { failList: true, failPost: true });
|
|
448
|
+
const now = new Date("2026-01-01T00:10:00.000Z");
|
|
449
|
+
const leaseAt = new Date(now.getTime() - 60_000).toISOString(); // 1 minute ago — well inside the lease
|
|
450
|
+
const { data, rows } = fakeData([owedRow({ pr_link_lease_at: leaseAt })]);
|
|
451
|
+
try {
|
|
452
|
+
const out = await attemptIssuePrLink(KEY, data, "t", () => now);
|
|
453
|
+
assertEquals(out, "claim-lost");
|
|
454
|
+
assertEquals(rows[0].pr_link_lease_at, leaseAt, "the live lease was untouched (the CAS guard rejected the claim)");
|
|
455
|
+
assertEquals(gh.calls.length, 0, "no GitHub I/O while another pass holds the lease");
|
|
456
|
+
} finally {
|
|
457
|
+
gh.restore();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a stale lease holder that resumes after the poller re-claimed does NOT post a duplicate", async () => {
|
|
462
|
+
// The worker claims the lease, then stalls on the marker scan PAST the lease expiry. While it is
|
|
463
|
+
// stalled the poller re-claims the (now-expired) lease and posts. The worker then resumes with its
|
|
464
|
+
// earlier empty scan and would post a duplicate — the pre-post lease re-validation rejects it
|
|
465
|
+
// (its claimed ts no longer matches) so it backs off as claim-lost WITHOUT posting.
|
|
466
|
+
const now = new Date("2026-01-01T01:00:00.000Z");
|
|
467
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
468
|
+
// During the worker's FIRST list, simulate the poller winning: re-claim the lease under a
|
|
469
|
+
// DIFFERENT (later) timestamp, so the worker's claimed ts is stale.
|
|
470
|
+
let pollerTs: string;
|
|
471
|
+
const gh = stubGithub([], {
|
|
472
|
+
onFirstList: () => {
|
|
473
|
+
pollerTs = new Date(now.getTime() + 1000).toISOString();
|
|
474
|
+
rows[0].pr_link_lease_at = pollerTs; // the poller's claim overwrote the worker's lease
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
try {
|
|
478
|
+
const out = await attemptIssuePrLink(KEY, data, "t", () => now);
|
|
479
|
+
assertEquals(out, "claim-lost", "the stale holder was rejected before posting");
|
|
480
|
+
assertEquals(gh.posted.length, 0, "NO duplicate comment was posted by the resuming worker");
|
|
481
|
+
assertEquals(rows[0].pr_link_lease_at, pollerTs!, "the poller's lease still stands (the worker did not clobber it)");
|
|
482
|
+
assertEquals(rows[0].pr_linked_pr_key, null, "the worker did not confirm (the poller owns the attempt)");
|
|
483
|
+
} finally {
|
|
484
|
+
gh.restore();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("a pass that still holds its lease through the write re-validates and posts (the happy path is unaffected)", async () => {
|
|
489
|
+
// No concurrent steal: the pre-post re-validation succeeds (the worker still holds its claimed
|
|
490
|
+
// lease), so the comment posts and confirms normally.
|
|
491
|
+
const gh = stubGithub([]);
|
|
492
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
493
|
+
try {
|
|
494
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
495
|
+
assertEquals(out, "posted");
|
|
496
|
+
assertEquals(gh.posted.length, 1, "the comment was posted");
|
|
497
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the link was confirmed");
|
|
498
|
+
assertEquals(rows[0].pr_link_lease_at, null, "the lease was released by the confirm");
|
|
499
|
+
} finally {
|
|
500
|
+
gh.restore();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a comment POST stalled past its deadline is aborted as post-failed — the write never outlives the lease", { timeout: 5000 }, async () => {
|
|
505
|
+
assert(ISSUE_PR_LINK_IO_TIMEOUT_MS < ISSUE_PR_LINK_LEASE_MS, "the I/O deadline is strictly inside the lease");
|
|
506
|
+
const gh = stubGithub([], { stallPost: true });
|
|
507
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
508
|
+
try {
|
|
509
|
+
const out = await attemptIssuePrLink(KEY, data, "t", () => new Date(), 20);
|
|
510
|
+
assertEquals(out, "post-failed", "the hung POST was cut off at the deadline");
|
|
511
|
+
assertEquals(rows[0].pr_linked_pr_key, null, "an aborted POST is never confirmed");
|
|
512
|
+
assert(rows[0].pr_link_lease_at != null, "the lease stands as the retry backoff");
|
|
513
|
+
} finally {
|
|
514
|
+
gh.restore();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("a PR-title lookup stalled past its deadline is cut off — the comment still posts with no title", { timeout: 5000 }, async () => {
|
|
519
|
+
const gh = stubGithub([], { stallTitle: true });
|
|
520
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
521
|
+
try {
|
|
522
|
+
const out = await attemptIssuePrLink(KEY, data, "t", () => new Date(), 20);
|
|
523
|
+
assertEquals(out, "posted", "a hung title read never wedges the attempt");
|
|
524
|
+
assertEquals(gh.posted.length, 1);
|
|
525
|
+
assertEquals(rows[0].pr_linked_pr_key, PR);
|
|
526
|
+
} finally {
|
|
527
|
+
gh.restore();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── Failure semantics: the lease is the backoff, the poller retries ───────────
|
|
532
|
+
|
|
533
|
+
test("a failed comment POST leaves the owed link UNCONFIRMED and the lease to expire — never throws", async () => {
|
|
534
|
+
const gh = stubGithub([], { failPost: true });
|
|
535
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
536
|
+
try {
|
|
537
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
538
|
+
assertEquals(out, "post-failed");
|
|
539
|
+
assertEquals(rows[0].pr_linked_pr_key, null, "a failed POST is NEVER mistaken for a confirmed link");
|
|
540
|
+
assert(rows[0].pr_link_lease_at != null, "the in-flight lease stands as the retry backoff");
|
|
541
|
+
} finally {
|
|
542
|
+
gh.restore();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("a THROWN comment POST (connection failure) is normalised to post-failed — the lease still expires for a retry", async () => {
|
|
547
|
+
const gh = stubGithub([], { throwPost: true });
|
|
548
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
549
|
+
try {
|
|
550
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
551
|
+
assertEquals(out, "post-failed");
|
|
552
|
+
assertEquals(rows[0].pr_linked_pr_key, null, "a thrown POST never confirms the link");
|
|
553
|
+
assert(rows[0].pr_link_lease_at != null, "the lease stands as the retry backoff");
|
|
554
|
+
} finally {
|
|
555
|
+
gh.restore();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("a failed comment LISTING posts nothing blind, confirms nothing, and leaves the lease to expire", async () => {
|
|
560
|
+
const gh = stubGithub([], { failList: true });
|
|
561
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
562
|
+
try {
|
|
563
|
+
const out = await attemptIssuePrLink(KEY, data);
|
|
564
|
+
assertEquals(out, "list-failed");
|
|
565
|
+
assertEquals(gh.posted.length, 0, "no comment was posted blind");
|
|
566
|
+
assertEquals(rows[0].pr_linked_pr_key, null, "an unreadable scan never confirms the link");
|
|
567
|
+
assert(rows[0].pr_link_lease_at != null, "the lease stands as the retry backoff");
|
|
568
|
+
} finally {
|
|
569
|
+
gh.restore();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("a crashed attempt's EXPIRED lease is re-claimable — the poller retry posts and confirms", async () => {
|
|
574
|
+
const gh = stubGithub([]);
|
|
575
|
+
const now = new Date("2026-01-01T01:00:00.000Z");
|
|
576
|
+
const staleLease = new Date(now.getTime() - ISSUE_PR_LINK_LEASE_MS - 1000).toISOString();
|
|
577
|
+
const { data, rows } = fakeData([owedRow({ pr_link_lease_at: staleLease })]);
|
|
578
|
+
try {
|
|
579
|
+
const out = await attemptIssuePrLink(KEY, data, "t", () => now);
|
|
580
|
+
assertEquals(out, "posted");
|
|
581
|
+
assertEquals(gh.posted.length, 1, "the retry posted the comment");
|
|
582
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the retry confirmed the link");
|
|
583
|
+
assertEquals(rows[0].pr_link_lease_at, null, "the lease was released by the confirm");
|
|
584
|
+
} finally {
|
|
585
|
+
gh.restore();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("a missing row, a row with nothing owed, or a thrown claim is a guarded no-op", async () => {
|
|
590
|
+
const gh = stubGithub([]);
|
|
591
|
+
const noRow = fakeData([]);
|
|
592
|
+
const nothingOwed = fakeData([{ feature_key: KEY, repo: "owner/repo", issue_number: 7 }]);
|
|
593
|
+
const throwing = fakeData([owedRow()], { claimThrows: true });
|
|
594
|
+
try {
|
|
595
|
+
assertEquals(await attemptIssuePrLink(KEY, noRow.data), "skipped");
|
|
596
|
+
assertEquals(await attemptIssuePrLink(KEY, nothingOwed.data), "skipped");
|
|
597
|
+
assertEquals(await attemptIssuePrLink(KEY, throwing.data), "skipped");
|
|
598
|
+
assertEquals(gh.posted.length, 0, "nothing was posted");
|
|
599
|
+
} finally {
|
|
600
|
+
gh.restore();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ── Targeting: the comment goes to the CLAIMED ISSUE, the title to the PR's repo ──
|
|
605
|
+
|
|
606
|
+
test("a cross-repo PR comments on the CLAIMED issue (row repo + issue_number), not the PR's repo", async () => {
|
|
607
|
+
const gh = stubGithub([], { prTitles: { "/repos/owner/code/pulls/101": "the cross-repo PR" } });
|
|
608
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
609
|
+
try {
|
|
610
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: "owner/code#101", claimIssue: true }, data);
|
|
611
|
+
assertEquals(out, "posted");
|
|
612
|
+
assertEquals(rows[0].pr_link_want_pr_key, "owner/code#101", "the owed link names the cross-repo PR");
|
|
613
|
+
assert(
|
|
614
|
+
gh.calls.some((c) => c.startsWith("POST /repos/owner/repo/issues/7/comments")),
|
|
615
|
+
`the comment landed on the claimed issue owner/repo#7 (calls: ${gh.calls.join(", ")})`,
|
|
616
|
+
);
|
|
617
|
+
assert(
|
|
618
|
+
gh.calls.some((c) => c.startsWith("GET /repos/owner/code/pulls/101")),
|
|
619
|
+
"the title was read from the PR's OWN repository",
|
|
620
|
+
);
|
|
621
|
+
assert(!gh.calls.some((c) => c.includes("/repos/owner/code/issues/")), "the PR repo's issues were never touched");
|
|
622
|
+
assert(gh.posted[0].body.includes("https://github.com/owner/code/pull/101"), "the comment links the cross-repo PR");
|
|
623
|
+
assert(gh.posted[0].body.includes("the cross-repo PR"), "the comment carries the cross-repo title");
|
|
624
|
+
} finally {
|
|
625
|
+
gh.restore();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// ── A PR CHANGE re-links (a superseded PR is re-announced exactly once) ───────
|
|
630
|
+
|
|
631
|
+
test("a DIFFERENT PR than the confirmed one is owed and announced again (the delivered PR changed)", async () => {
|
|
632
|
+
const gh = stubGithub([]);
|
|
633
|
+
const { data, rows } = fakeData([owedRow({ pr_link_want_pr_key: "owner/repo#99", pr_linked_pr_key: "owner/repo#99" })]);
|
|
634
|
+
try {
|
|
635
|
+
const out = await requestIssuePrLink({ featureKey: KEY, prKey: "owner/repo#102", claimIssue: true }, data);
|
|
636
|
+
assertEquals(out, "posted");
|
|
637
|
+
assertEquals(gh.posted.length, 1, "the replacement PR was announced");
|
|
638
|
+
assert(gh.posted[0].body.includes("#102"), "the comment names the NEW PR");
|
|
639
|
+
assertEquals(rows[0].pr_link_want_pr_key, "owner/repo#102", "the owed link advanced to the new PR");
|
|
640
|
+
assertEquals(rows[0].pr_linked_pr_key, "owner/repo#102", "the confirmed link advanced to the new PR");
|
|
641
|
+
} finally {
|
|
642
|
+
gh.restore();
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ── The poller retry pass ─────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
test("the poller retries only OUTSTANDING owed links — confirmed, unowed, and live-lease rows are untouched", async () => {
|
|
649
|
+
const gh = stubGithub([]);
|
|
650
|
+
const now = new Date("2026-01-01T01:00:00.000Z");
|
|
651
|
+
const liveLease = new Date(now.getTime() - 60_000).toISOString();
|
|
652
|
+
const { data, rows } = fakeData([
|
|
653
|
+
owedRow({ feature_key: "owner/repo#1" }), // outstanding, no lease → retried
|
|
654
|
+
owedRow({ feature_key: "owner/repo#2", pr_linked_pr_key: PR }), // confirmed → skipped
|
|
655
|
+
{ feature_key: "owner/repo#3", repo: "owner/repo", issue_number: 3 }, // nothing owed → skipped
|
|
656
|
+
owedRow({ feature_key: "owner/repo#4", pr_link_lease_at: liveLease }), // live lease → skipped
|
|
657
|
+
]);
|
|
658
|
+
try {
|
|
659
|
+
const out = await retryOutstandingIssuePrLinks(data, "t", () => now);
|
|
660
|
+
assertEquals(out, { "owner/repo#1": "posted" }, "only the outstanding unleased row was attempted");
|
|
661
|
+
assertEquals(gh.posted.length, 1, "exactly one comment was posted");
|
|
662
|
+
assertEquals(rows[1].pr_link_lease_at, null, "the confirmed row was untouched");
|
|
663
|
+
assertEquals(rows[3].pr_link_lease_at, liveLease, "the live lease was untouched");
|
|
664
|
+
} finally {
|
|
665
|
+
gh.restore();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("the poller retry heals a post-failed attempt once its lease expires", async () => {
|
|
670
|
+
// First attempt: the POST fails → post-failed, unconfirmed, lease standing.
|
|
671
|
+
const failing = stubGithub([], { failPost: true });
|
|
672
|
+
const now = new Date("2026-01-01T01:00:00.000Z");
|
|
673
|
+
const { data, rows } = fakeData([owedRow()]);
|
|
674
|
+
try {
|
|
675
|
+
assertEquals(await attemptIssuePrLink(KEY, data, "t", () => now), "post-failed");
|
|
676
|
+
assertEquals(rows[0].pr_linked_pr_key, null);
|
|
677
|
+
} finally {
|
|
678
|
+
failing.restore();
|
|
679
|
+
}
|
|
680
|
+
// A later poll pass (after the lease expired): GitHub has recovered → the retry posts + confirms.
|
|
681
|
+
const recovered = stubGithub([]);
|
|
682
|
+
const later = new Date(now.getTime() + ISSUE_PR_LINK_LEASE_MS + 1000);
|
|
683
|
+
try {
|
|
684
|
+
const out = await retryOutstandingIssuePrLinks(data, "t", () => later);
|
|
685
|
+
assertEquals(out, { [KEY]: "posted" }, "the retry pass re-attempted the outstanding link");
|
|
686
|
+
assertEquals(recovered.posted.length, 1, "the retried comment landed");
|
|
687
|
+
assertEquals(rows[0].pr_linked_pr_key, PR, "the retried link was confirmed");
|
|
688
|
+
} finally {
|
|
689
|
+
recovered.restore();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("the poller pass never throws — a DB failure resolves to an empty outcome map", async () => {
|
|
694
|
+
const gh = stubGithub([]);
|
|
695
|
+
const throwing = fakeData([owedRow()], { scanThrows: true });
|
|
696
|
+
try {
|
|
697
|
+
const out = await retryOutstandingIssuePrLinks(throwing.data);
|
|
698
|
+
assertEquals(out, {}, "a thrown scan resolves to no outcomes");
|
|
699
|
+
assertEquals(gh.posted.length, 0);
|
|
700
|
+
} finally {
|
|
701
|
+
gh.restore();
|
|
702
|
+
}
|
|
703
|
+
});
|