@nanobpm/nano-workforce 0.207.0 → 0.208.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.
@@ -329,14 +329,13 @@ test("pr.merge #923: refuses to land when NO Copilot review exists (the #1443 fa
329
329
  app,
330
330
  )) as Record<string, unknown>;
331
331
 
332
- assertEquals(out.mergeStatus, "blocked");
333
- assertEquals(out.status, "blocked");
334
- assertEquals(typeof out.question, "string");
335
- assertEquals((out.question as string).includes("no head-current Copilot review"), true);
332
+ // Issue #939: an automatable remedy → `review-pending` (the model requests + waits), NEVER a
333
+ // human escalation payload on the first occurrence.
334
+ assertEquals(out, { mergeStatus: "review-pending" });
336
335
 
337
336
  // The refusal is audited, and NO land was attempted (no merge PUT, no enqueue comment).
338
337
  assertEquals(stores.merges.length, 1);
339
- assertEquals(stores.merges[0].outcome, "blocked");
338
+ assertEquals(stores.merges[0].outcome, "review-pending");
340
339
  assertEquals(stores.merges[0].method, "review-backstop");
341
340
  assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
342
341
  assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
@@ -356,7 +355,7 @@ test("pr.merge #923: refuses to land when the head MOVED past the latest review
356
355
  app,
357
356
  )) as Record<string, unknown>;
358
357
 
359
- assertEquals(out.mergeStatus, "blocked");
358
+ assertEquals(out, { mergeStatus: "review-pending" });
360
359
  assertEquals(stores.merges[0].method, "review-backstop");
361
360
  assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
362
361
  });
@@ -440,7 +439,7 @@ test("pr.merge #927: reproduces nano-bpm#1467 — review REQUESTED but not yet a
440
439
  { variables: { prKey: "acme/widgets#1467", repo: "acme/widgets", prNumber: 1467 } } as any,
441
440
  app,
442
441
  )) as Record<string, unknown>;
443
- assertEquals(out.mergeStatus, "blocked");
442
+ assertEquals(["blocked", "review-pending"].includes(String(out.mergeStatus)), true);
444
443
  assertEquals(stores.merges[0].method, "review-backstop");
445
444
  assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
446
445
  assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
@@ -457,8 +456,9 @@ test("pr.merge #923: a head-current review whose summary reports OPEN findings b
457
456
  readHeadSha: async () => "h1",
458
457
  });
459
458
  const out = (await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app)) as Record<string, unknown>;
460
- assertEquals(out.mergeStatus, "blocked");
461
- assertEquals((out.question as string).includes("open findings"), true);
459
+ // Issue #939: findings on a head-current review re-enter convergence, never a human.
460
+ assertEquals(out, { mergeStatus: "review-findings" });
461
+ assertEquals(stores.merges[0].outcome, "review-findings");
462
462
  assertEquals(stores.merges[0].method, "review-backstop");
463
463
  assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
464
464
  });
@@ -538,3 +538,24 @@ for (const [label, finalRead] of [
538
538
  );
539
539
  });
540
540
  }
541
+
542
+ // ── Defect-class guard (issue #939) ──────────────────────────────────────────
543
+ // No merge-gate refusal whose remedy is fully automatable (missing head-current review, a review
544
+ // made stale by a loop push, findings on a head-current review) may produce a human escalation
545
+ // payload (`status`/`question`) on its first occurrence.
546
+ for (const [label, review, head] of [
547
+ ["no review ever submitted", { body: "", commitId: null }, "h10"],
548
+ ["review stale after a loop push", { body: "lgtm", commitId: "old" }, "h10"],
549
+ ["head-current review with findings", { body: "### 🟡 Changes recommended\n<summary>1 open issue</summary>", commitId: "h10" }, "h10"],
550
+ ] as const) {
551
+ test(`pr.merge #939: an automatable refusal (${label}) never yields a human escalation payload`, async () => {
552
+ await withGithub(backstopRoutes, async () => {
553
+ const { app } = fakeApp();
554
+ const backstop = makeHandler({ readReview: async () => review, readHeadSha: async () => head });
555
+ const out = (await backstop({ variables: { prKey: "acme/widgets#40", repo: "acme/widgets", prNumber: 40 } } as any, app)) as Record<string, unknown>;
556
+ assertEquals(out.status, undefined);
557
+ assertEquals(out.question, undefined);
558
+ assertEquals(out.mergeStatus === "blocked", false);
559
+ });
560
+ });
561
+ }
@@ -42,7 +42,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
42
42
  type In = WorkerInputs["pr.merge"];
43
43
 
44
44
  interface Out extends Record<string, unknown> {
45
- mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned";
45
+ mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned" | "review-pending" | "review-findings";
46
46
  status?: string;
47
47
  question?: string;
48
48
  }
@@ -142,11 +142,26 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
142
142
  // FAIL CLOSED (issue #927): a gate whose job is "never land unreviewed" must not land on an
143
143
  // UNVERIFIABLE read. nano-bpm#1467 merged 49 s before its first Copilot review because a `null`
144
144
  // review/head read (or an empty head) skipped this check entirely. An unreadable review or head now
145
- // blocks (escalating to a human, who replies to retry) exactly like a missing/stale review.
145
+ // blocks (escalating to a human, who replies to retry); a VERIFIED missing/stale review instead
146
+ // takes the automated request-and-wait path below (issue #939).
146
147
  const headTrimmed = (backstopHead ?? "").trim();
147
148
  const unverifiable = backstopReview === null || headTrimmed === "";
148
- if (unverifiable || isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
149
- const detail = unverifiable ? BLOCK_UNVERIFIABLE_REVIEW : BLOCK_NO_HEAD_CURRENT_REVIEW;
149
+ // AUTOMATABLE REMEDY (issue #939): a VERIFIED read showing no head-current review (the loop's own
150
+ // rebase/fix push moved the head, or no review exists yet) is not a human decision — the only
151
+ // remedy is "request a Copilot review and wait for it". Return `review-pending` so the model
152
+ // requests the review itself and waits on a bounded timer, escalating only when it never arrives.
153
+ if (!unverifiable && isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
154
+ await app.data.table("merges", "id").insert({
155
+ pr_key: prKey,
156
+ outcome: "review-pending",
157
+ method: "review-backstop",
158
+ detail: BLOCK_NO_HEAD_CURRENT_REVIEW,
159
+ at: now,
160
+ });
161
+ return { mergeStatus: "review-pending" };
162
+ }
163
+ if (unverifiable) {
164
+ const detail = BLOCK_UNVERIFIABLE_REVIEW;
150
165
  await app.data.table("merges", "id").insert({
151
166
  pr_key: prKey,
152
167
  outcome: "blocked",
@@ -174,18 +189,14 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
174
189
  const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
175
190
  await app.data.table("merges", "id").insert({
176
191
  pr_key: prKey,
177
- outcome: "blocked",
192
+ outcome: "review-findings",
178
193
  method: "review-backstop",
179
194
  detail,
180
195
  at: now,
181
196
  });
182
- return {
183
- mergeStatus: "blocked",
184
- status: "blocked",
185
- question:
186
- `Merge refused: ${detail}. Address the review's findings on the branch (or reply with ` +
187
- `guidance on which to push back on), let the convergence loop gate on a clean review, then reply to retry.`,
188
- };
197
+ // Findings on a head-current review belong to the convergence path (fix → push → review), not
198
+ // to a human (issue #939): the model hands the PR back to the convergence loop.
199
+ return { mergeStatus: "review-findings" };
189
200
  }
190
201
  // The head the backstop verified — pinned through the land call so a push between this check
191
202
  // and the merge can never land a later, unreviewed head.
@@ -0,0 +1,110 @@
1
+ // pr.reconverge (issue #939): findings on a head-current review hand the PR back to the ONE canonical
2
+ // convergence entry (`submitPr`), declaring the merge-loop instance as the hand-off source.
3
+ import { test } from "node:test";
4
+ import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
5
+ import { noopLog } from "../../test/log.ts";
6
+ import { makeHandler, type FetchPrHead, type Submit } from "./worker.ts";
7
+
8
+ test("re-submits through submitPr with auto-merge on and handoffFrom = this merge-loop instance", async () => {
9
+ const merges: Record<string, unknown>[] = [];
10
+ const app = {
11
+ data: {
12
+ table: (name: string) => ({
13
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 1 } : undefined),
14
+ insert: async (r: Record<string, unknown>) => (merges.push(r), 1),
15
+ }),
16
+ },
17
+ engine: {},
18
+ log: noopLog(),
19
+ } as any;
20
+ let args: unknown[] = [];
21
+ const submit = (async (...a: unknown[]) => {
22
+ args = a;
23
+ return { prKey: "acme/w#7" };
24
+ }) as unknown as Submit;
25
+ await makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
26
+ assertEquals((args[2] as { prKey: string }).prKey, "acme/w#7");
27
+ assertEquals(args[7], false, "no re-gate on human approval");
28
+ assertEquals(args[8], true, "steward opt-in carried from the row");
29
+ assertEquals(args[9], true, "auto-merge stays on");
30
+ assertEquals(args[10], "4242", "hand-off source is this merge-loop instance");
31
+ assertEquals(merges[0].outcome, "reconverge");
32
+ });
33
+
34
+ // Issue #941 review: `submitPr` can return `processKey: null` after rewriting the row to
35
+ // `converging` with no owner (a create-instance failure window). Completing the job successfully
36
+ // then ends the merge loop at `MergeReconverged`, leaving the PR with NO live convergence or merge
37
+ // instance to advance it. The worker must throw on a null process key so the job stays retryable
38
+ // instead of stranding the PR.
39
+ test("throws on a null processKey so the hand-off stays retryable (#941)", async () => {
40
+ const app = {
41
+ data: {
42
+ table: (name: string) => ({
43
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
44
+ insert: async () => 1,
45
+ }),
46
+ },
47
+ engine: {},
48
+ log: noopLog(),
49
+ } as any;
50
+ const submit = (async () => ({ prKey: "acme/w#7", processKey: null })) as unknown as Submit;
51
+ const err = await assertRejects(
52
+ () => makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
53
+ Error,
54
+ "null processKey",
55
+ );
56
+ assertStringIncludes(err.message, "acme/w#7");
57
+ });
58
+
59
+ // Issue #941 round-4 review (Copilot "previously missed", high): the world checkpoint is written
60
+ // ONLY by the convergence loop's `pr.persist-round` — a merge-stage rebase/fix-ci push advances the
61
+ // PR head WITHOUT updating the checkpoint. On a durable-resume fleet a reconverge hand-off that
62
+ // re-enters `submitPr` on the checkpoint therefore provisions the fresh convergence agent at the
63
+ // PRE-MERGE tree, not the head whose review raised the findings. The hand-off must carry the
64
+ // CURRENT PR head SHA as `submitPr`'s restore override.
65
+ test("hands off at the CURRENT PR head, not the stale convergence checkpoint (#941 round 4)", async () => {
66
+ const app = {
67
+ data: {
68
+ table: (name: string) => ({
69
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
70
+ insert: async () => 1,
71
+ }),
72
+ },
73
+ engine: {},
74
+ log: noopLog(),
75
+ } as any;
76
+ let args: unknown[] = [];
77
+ const submit = (async (...a: unknown[]) => {
78
+ args = a;
79
+ return { prKey: "acme/w#7", processKey: "999" };
80
+ }) as unknown as Submit;
81
+ const head = (async () => ({
82
+ headRef: "feat/x",
83
+ headSha: "b".repeat(40),
84
+ baseRef: "main",
85
+ headRepo: "acme/w",
86
+ })) as unknown as FetchPrHead;
87
+ await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
88
+ assertEquals(args[11], "b".repeat(40), "the restore override is the live PR head SHA");
89
+ });
90
+
91
+ test("a head read that resolves no SHA degrades to the checkpoint restore (no override)", async () => {
92
+ const app = {
93
+ data: {
94
+ table: (name: string) => ({
95
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
96
+ insert: async () => 1,
97
+ }),
98
+ },
99
+ engine: {},
100
+ log: noopLog(),
101
+ } as any;
102
+ let args: unknown[] = [];
103
+ const submit = (async (...a: unknown[]) => {
104
+ args = a;
105
+ return { prKey: "acme/w#7", processKey: "999" };
106
+ }) as unknown as Submit;
107
+ const head = (async () => null) as unknown as FetchPrHead;
108
+ await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
109
+ assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
110
+ });
@@ -0,0 +1,81 @@
1
+ // pr.reconverge — hand a PR back from the merge loop to the convergence loop (issue #939).
2
+ //
3
+ // Reached when `pr.merge`'s backstop finds a HEAD-CURRENT Copilot review whose own summary reports
4
+ // open findings (`mergeStatus: "review-findings"`) — typically the review the merge loop itself
5
+ // requested after a rebase / fix push. Actionable findings belong to the convergence path
6
+ // (fix → push → review), not to a human, so this re-submits the PR through the ONE canonical entry
7
+ // (`submitPr`), declaring this merge-loop instance as the hand-off source so the live row it owns is
8
+ // not misread as "already running". The merge loop then ends; the fresh convergence run starts a new
9
+ // merge loop on convergence, exactly as a first pass does. No parallel review loop is introduced.
10
+ import type { AppJobHandler } from "@nanobpm/urban";
11
+ import { fetchPrHead } from "../../app/github.ts";
12
+ import { parsePr } from "../../app/prParse.ts";
13
+ import { MAX_ROUNDS, submitPr } from "../../app/service.ts";
14
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
15
+
16
+ // Input typed off the model data envelope (`ReconvergeIn` in merge-loop.bpmn) — ADR 0040.
17
+ type In = WorkerInputs["pr.reconverge"];
18
+
19
+ export type Submit = typeof submitPr;
20
+ export type FetchPrHead = typeof fetchPrHead;
21
+
22
+ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead): AppJobHandler<In> {
23
+ return async (job, app) => {
24
+ const { prKey } = job.variables;
25
+ const parsed = parsePr(prKey);
26
+ if (!parsed) throw new Error(`reconverge: unparseable prKey ${JSON.stringify(prKey)}`);
27
+ const row = await app.data.table("pull_requests", "pr_key").get(prKey);
28
+ // biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
29
+ const optIn = (row as { auto_resolve_escalations?: number } | undefined)?.auto_resolve_escalations === 1;
30
+ await app.data.table("merges", "id").insert({
31
+ pr_key: prKey,
32
+ outcome: "reconverge",
33
+ method: "review-backstop",
34
+ detail: "head-current Copilot review reports open findings — re-entering convergence",
35
+ at: new Date().toISOString(),
36
+ });
37
+ // Head-current restore (#941 round-4 review): the durable world checkpoint is written only by
38
+ // the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push — the very push
39
+ // whose head-current review sent us here — advances the PR head WITHOUT updating the checkpoint.
40
+ // Re-entering `submitPr` on the checkpoint would provision the fresh convergence agent at the
41
+ // PRE-MERGE tree, risking stale fixes or a rejected (non-fast-forward) push. Read the LIVE head
42
+ // SHA and pass it as the restore override so the hand-off resumes at the head the review saw.
43
+ // Best-effort: an unreadable head degrades to `null` (no override → the checkpoint restore, the
44
+ // pre-fix behaviour) rather than blocking the hand-off on a GitHub read.
45
+ let headSha: string | null = null;
46
+ try {
47
+ headSha = (await fetchHead(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""))?.headSha ?? null;
48
+ } catch (err) {
49
+ app.log.warn("reconverge: head read failed — falling back to the checkpoint restore", { prKey, err: String(err) });
50
+ }
51
+ // The PR was already merging (merge intent on, any human approval already given), so the fresh
52
+ // run keeps auto-merge and does not re-gate on approval.
53
+ const result = await submit(
54
+ app.data,
55
+ app.engine,
56
+ parsed,
57
+ [],
58
+ MAX_ROUNDS,
59
+ false,
60
+ null,
61
+ false,
62
+ optIn,
63
+ true,
64
+ job.processInstanceKey != null ? String(job.processInstanceKey) : null,
65
+ headSha,
66
+ );
67
+ // `submitPr` can return `processKey: null` after rewriting the row to `converging` with no owner
68
+ // (the create-instance failure window). Completing HERE would end the merge loop at
69
+ // `MergeReconverged` with the PR left on a keyless `converging` row — no live convergence or
70
+ // merge instance to advance it. Throw instead so the job fails and stays RETRYABLE: the retry
71
+ // re-enters `submitPr`, which treats the keyless non-terminal row as resubmittable and re-enrolls
72
+ // it (the #704/#497 phantom-row recovery path).
73
+ if (result != null && typeof result === "object" && "processKey" in result && result.processKey == null) {
74
+ throw new Error(`reconverge: submitPr returned a null processKey for ${prKey} — no convergence instance was started; failing so the hand-off is retried`);
75
+ }
76
+ return {};
77
+ };
78
+ }
79
+
80
+ const handler = makeHandler(submitPr);
81
+ export default handler;
@@ -0,0 +1,54 @@
1
+ // pr.request-review (issue #939): the merge loop requests a head-current Copilot review itself, and
2
+ // re-triggers a silently-ignored request (withdraw + re-add) on a re-request.
3
+ import { test } from "node:test";
4
+ import { assertEquals } from "#test-assert";
5
+ import { noopLog } from "../../test/log.ts";
6
+ import { makeHandler, type ReviewRequestOps } from "./worker.ts";
7
+
8
+ function fakeApp() {
9
+ const merges: Record<string, unknown>[] = [];
10
+ return {
11
+ merges,
12
+ app: {
13
+ data: { table: () => ({ insert: (r: Record<string, unknown>) => (merges.push(r), Promise.resolve(1)) }) },
14
+ log: noopLog(),
15
+ } as any,
16
+ };
17
+ }
18
+
19
+ function ops(over: Partial<ReviewRequestOps>, calls: string[]): ReviewRequestOps {
20
+ return {
21
+ hasPending: async () => (calls.push("pending?"), false),
22
+ remove: async () => calls.push("remove"),
23
+ request: async () => (calls.push("request"), "requested"),
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ const job = (round: number) => ({ variables: { prKey: "acme/w#1", repo: "acme/w", prNumber: 1, reviewRequestRound: round } }) as any;
29
+
30
+ test("first request POSTs the Copilot reviewer and audits it", async () => {
31
+ const calls: string[] = [];
32
+ const { app, merges } = fakeApp();
33
+ const out = await makeHandler(ops({}, calls))(job(0), app);
34
+ assertEquals(out, { reviewRequest: "requested" });
35
+ assertEquals(calls, ["request"]);
36
+ assertEquals(merges[0].outcome, "review-requested");
37
+ });
38
+
39
+ test("a re-request with Copilot still pending (silently ignored) withdraws then re-adds", async () => {
40
+ const calls: string[] = [];
41
+ const { app } = fakeApp();
42
+ await makeHandler(ops({ hasPending: async () => (calls.push("pending?"), true) }, calls))(job(1), app);
43
+ assertEquals(calls, ["pending?", "remove", "request"]);
44
+ });
45
+
46
+ test("Copilot not assignable reports unavailable; a transport error reports failed (never throws)", async () => {
47
+ const { app } = fakeApp();
48
+ assertEquals(await makeHandler(ops({ request: async () => "unavailable" }, []))(job(0), app), { reviewRequest: "unavailable" });
49
+ assertEquals(
50
+ await makeHandler(ops({ request: async () => { throw new Error("502"); } }, []))(job(0), app),
51
+ { reviewRequest: "failed" },
52
+ );
53
+ assertEquals(await makeHandler(ops({ request: async () => null }, []))(job(0), app), { reviewRequest: "failed" });
54
+ });
@@ -0,0 +1,68 @@
1
+ // pr.request-review — the merge loop requests a HEAD-CURRENT Copilot review itself (issue #939).
2
+ //
3
+ // Reached when `pr.merge`'s backstop finds no head-current review (`mergeStatus: "review-pending"`):
4
+ // the loop's own rebase / merge-from-base / fix push moved the head, so the last review is stale.
5
+ // The only remedy is mechanical — request a review and wait — so the loop does it instead of
6
+ // escalating to a human. The model then waits on a bounded timer (`reviewRequestWait`) and re-arms
7
+ // the merge poller; `pr.merge` re-runs the backstop on the new head. Only after `reviewRequestMax`
8
+ // requests with no review (or when Copilot cannot be requested at all) does it escalate.
9
+ //
10
+ // "Silently ignored" requests: Copilot can stay listed as a pending reviewer yet never review, and a
11
+ // second POST is then a no-op. On a re-request (round > 0) with Copilot still pending, withdraw the
12
+ // request first and re-add it — the equivalent that re-triggers the review.
13
+ import type { AppJobHandler } from "@nanobpm/urban";
14
+ import { hasPendingCopilotReviewer, removeCopilotReviewer, requestCopilotReview } from "../../app/github.ts";
15
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
16
+
17
+ // Input typed off the model data envelope (`RequestReviewIn` in merge-loop.bpmn) — ADR 0040.
18
+ type In = WorkerInputs["pr.request-review"];
19
+
20
+ interface Out extends Record<string, unknown> {
21
+ /** `requested` — a request is in flight; `unavailable` — Copilot is not an assignable reviewer on
22
+ * this repo (waiting is pointless → escalate); `failed` — no usable transport / transport error
23
+ * (the bounded wait still re-checks, so a transient failure is retried by the budget). */
24
+ reviewRequest: "requested" | "unavailable" | "failed";
25
+ }
26
+
27
+ export interface ReviewRequestOps {
28
+ hasPending: (repo: string, prNumber: number) => Promise<boolean | null>;
29
+ remove: (repo: string, prNumber: number) => Promise<unknown>;
30
+ request: (repo: string, prNumber: number) => Promise<"requested" | "unavailable" | null>;
31
+ }
32
+
33
+ const token = () => process.env.GITHUB_TOKEN ?? "";
34
+ const defaultOps: ReviewRequestOps = {
35
+ hasPending: (repo, n) => hasPendingCopilotReviewer(repo, n, token()),
36
+ remove: (repo, n) => removeCopilotReviewer(repo, n, token()),
37
+ request: (repo, n) => requestCopilotReview(repo, n, token()),
38
+ };
39
+
40
+ export function makeHandler(ops: ReviewRequestOps): AppJobHandler<In, Out> {
41
+ return async (job, app) => {
42
+ const { prKey, repo, prNumber, reviewRequestRound } = job.variables;
43
+ const round = typeof reviewRequestRound === "number" ? reviewRequestRound : 0;
44
+ let result: Out["reviewRequest"];
45
+ try {
46
+ // A re-request whose previous ask is still pending was silently ignored → withdraw, re-add.
47
+ if (round > 0 && (await ops.hasPending(repo, prNumber)) === true) {
48
+ await ops.remove(repo, prNumber);
49
+ }
50
+ const res = await ops.request(repo, prNumber);
51
+ result = res === "requested" ? "requested" : res === "unavailable" ? "unavailable" : "failed";
52
+ } catch (err) {
53
+ app.log.warn(`request-review: ${prKey} Copilot review request failed: ${err}`);
54
+ result = "failed";
55
+ }
56
+ await app.data.table("merges", "id").insert({
57
+ pr_key: prKey,
58
+ outcome: "review-requested",
59
+ method: "review-request",
60
+ detail: `head-current Copilot review request #${round + 1}: ${result}`,
61
+ at: new Date().toISOString(),
62
+ });
63
+ return { reviewRequest: result };
64
+ };
65
+ }
66
+
67
+ const handler = makeHandler(defaultOps);
68
+ export default handler;