@nanobpm/nano-workforce 0.26.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.
Files changed (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,70 @@
1
+ // Contract for the review-wait liveness policy — the ISO-8601 timeout validation handed to the
2
+ // process's timer catch, and the Copilot re-request nudge cooldown. Both are env-driven, so a
3
+ // blank/malformed operator value must fall back to a sane default rather than deploy an
4
+ // uninterpretable timer or a runaway nudge interval. Run with `deno test -A`.
5
+ import { assertEquals } from "jsr:@std/assert@1";
6
+ import {
7
+ clampNudgeMinutes,
8
+ DEFAULT_REVIEW_NUDGE_MINUTES,
9
+ DEFAULT_REVIEW_WAIT_TIMEOUT,
10
+ MAX_REVIEW_NUDGE_MINUTES,
11
+ reviewWaitTimeout,
12
+ } from "./reviewWait.ts";
13
+
14
+ Deno.test("reviewWaitTimeout: blank / absent / malformed → default", () => {
15
+ assertEquals(reviewWaitTimeout(undefined), DEFAULT_REVIEW_WAIT_TIMEOUT);
16
+ assertEquals(reviewWaitTimeout(""), DEFAULT_REVIEW_WAIT_TIMEOUT);
17
+ assertEquals(reviewWaitTimeout(" "), DEFAULT_REVIEW_WAIT_TIMEOUT);
18
+ assertEquals(reviewWaitTimeout("20m"), DEFAULT_REVIEW_WAIT_TIMEOUT); // missing leading P/T
19
+ assertEquals(reviewWaitTimeout("P"), DEFAULT_REVIEW_WAIT_TIMEOUT); // no component
20
+ assertEquals(reviewWaitTimeout("PT"), DEFAULT_REVIEW_WAIT_TIMEOUT); // T with no time component
21
+ assertEquals(reviewWaitTimeout("garbage"), DEFAULT_REVIEW_WAIT_TIMEOUT);
22
+ });
23
+
24
+ Deno.test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
25
+ assertEquals(reviewWaitTimeout("PT30M"), "PT30M");
26
+ assertEquals(reviewWaitTimeout("pt30m"), "PT30M");
27
+ assertEquals(reviewWaitTimeout(" PT1H30M "), "PT1H30M");
28
+ assertEquals(reviewWaitTimeout("PT45S"), "PT45S");
29
+ assertEquals(reviewWaitTimeout("P1D"), "P1D");
30
+ assertEquals(reviewWaitTimeout("P1DT2H"), "P1DT2H");
31
+ });
32
+
33
+ Deno.test("reviewWaitTimeout: a custom fallback is used when the value is invalid", () => {
34
+ assertEquals(reviewWaitTimeout("nope", "PT10M"), "PT10M");
35
+ assertEquals(reviewWaitTimeout(undefined, "PT10M"), "PT10M");
36
+ });
37
+
38
+ Deno.test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
39
+ assertEquals(clampNudgeMinutes(""), DEFAULT_REVIEW_NUDGE_MINUTES);
40
+ assertEquals(clampNudgeMinutes(" "), DEFAULT_REVIEW_NUDGE_MINUTES);
41
+ assertEquals(clampNudgeMinutes(undefined), DEFAULT_REVIEW_NUDGE_MINUTES);
42
+ assertEquals(clampNudgeMinutes(null), DEFAULT_REVIEW_NUDGE_MINUTES);
43
+ assertEquals(clampNudgeMinutes("abc"), DEFAULT_REVIEW_NUDGE_MINUTES);
44
+ assertEquals(clampNudgeMinutes(NaN), DEFAULT_REVIEW_NUDGE_MINUTES);
45
+ });
46
+
47
+ Deno.test("clampNudgeMinutes: a valid positive value (string or number) is honoured", () => {
48
+ assertEquals(clampNudgeMinutes("10"), 10);
49
+ assertEquals(clampNudgeMinutes(15), 15);
50
+ assertEquals(clampNudgeMinutes(" 7 "), 7);
51
+ });
52
+
53
+ Deno.test("clampNudgeMinutes: fractional values are truncated", () => {
54
+ assertEquals(clampNudgeMinutes("5.9"), 5);
55
+ assertEquals(clampNudgeMinutes(3.2), 3);
56
+ });
57
+
58
+ Deno.test("clampNudgeMinutes: zero and negatives fall back (a 0 cooldown would hammer the API)", () => {
59
+ assertEquals(clampNudgeMinutes(0), DEFAULT_REVIEW_NUDGE_MINUTES);
60
+ assertEquals(clampNudgeMinutes("-4"), DEFAULT_REVIEW_NUDGE_MINUTES);
61
+ });
62
+
63
+ Deno.test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
64
+ assertEquals(clampNudgeMinutes(100_000), MAX_REVIEW_NUDGE_MINUTES);
65
+ assertEquals(clampNudgeMinutes(String(MAX_REVIEW_NUDGE_MINUTES + 1)), MAX_REVIEW_NUDGE_MINUTES);
66
+ });
67
+
68
+ Deno.test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
69
+ assertEquals(clampNudgeMinutes("", MAX_REVIEW_NUDGE_MINUTES + 50), MAX_REVIEW_NUDGE_MINUTES);
70
+ });
@@ -0,0 +1,59 @@
1
+ // Review-wait liveness policy, kept as a pure module (no env, no I/O) so it is trivially
2
+ // testable — mirrors app/rounds.ts. `service.ts` builds the fleet-wide REVIEW_WAIT_TIMEOUT and
3
+ // REVIEW_NUDGE_MS on top of these, and the process's timer catch is seeded with the validated
4
+ // ISO-8601 duration at submit.
5
+ //
6
+ // Two knobs govern the review-wait watchdog:
7
+ // • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer
8
+ // catch (the far side of the event-based-gateway race against `review-ready`). If no fresh
9
+ // review arrives within it, the loop escalates to a human instead of hanging forever.
10
+ // • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for
11
+ // one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API.
12
+
13
+ /** Default review-wait timeout (ISO-8601 duration): how long the loop waits for a fresh review
14
+ * before the timer arm of the event-based gateway fires and it escalates to a human. */
15
+ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
16
+
17
+ // A pragmatic ISO-8601 duration matcher: requires a leading `P`, at least one component, and a
18
+ // `T` before any time components (with at least one time component after it). Good enough to
19
+ // reject an obviously-malformed env value before it is baked into a timer expression the engine
20
+ // would fail to interpret; not a full grammar (we don't need fractional seconds here).
21
+ const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
22
+
23
+ /** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value
24
+ * is absent, blank, or malformed — a bad env value must never deploy an uninterpretable timer
25
+ * expression into the process. Normalises to upper case (`pt20m` → `PT20M`). */
26
+ export function reviewWaitTimeout(
27
+ raw: string | undefined,
28
+ def: string = DEFAULT_REVIEW_WAIT_TIMEOUT,
29
+ ): string {
30
+ const s = (raw ?? "").trim().toUpperCase();
31
+ return s !== "" && ISO_DURATION.test(s) ? s : def;
32
+ }
33
+
34
+ /** Default cooldown (minutes) between automatic Copilot re-request nudges for one waiting PR.
35
+ * Kept comfortably below the review-wait timeout default so several nudges are attempted before
36
+ * the loop escalates. */
37
+ export const DEFAULT_REVIEW_NUDGE_MINUTES = 5;
38
+
39
+ /** Upper bound on the nudge cooldown — a runaway value could park a genuinely-stalled PR for days
40
+ * with no re-request. Mirrors the ceiling discipline in rounds.ts. */
41
+ export const MAX_REVIEW_NUDGE_MINUTES = 24 * 60;
42
+
43
+ /** Coerce the operator-supplied nudge cooldown (env `NANO_PR_REVIEW_NUDGE_MINUTES`, minutes) into
44
+ * a sane positive integer. Absent / blank / non-numeric / zero / negative / NaN fall back to
45
+ * `fallback`; values above the ceiling are clamped down (and so is an oversized `fallback`, so it
46
+ * can never bypass the ceiling). */
47
+ export function clampNudgeMinutes(
48
+ value: unknown,
49
+ fallback: number = DEFAULT_REVIEW_NUDGE_MINUTES,
50
+ ): number {
51
+ const safeFallback = Number.isFinite(fallback)
52
+ ? Math.min(Math.max(Math.trunc(fallback), 1), MAX_REVIEW_NUDGE_MINUTES)
53
+ : DEFAULT_REVIEW_NUDGE_MINUTES;
54
+ const n = typeof value === "number" ? value : Number(String(value ?? "").trim());
55
+ if (!Number.isFinite(n)) return safeFallback;
56
+ const i = Math.trunc(n);
57
+ if (i < 1) return safeFallback;
58
+ return Math.min(i, MAX_REVIEW_NUDGE_MINUTES);
59
+ }
@@ -0,0 +1,74 @@
1
+ // Contract for clampRounds — the per-submit round-cap coercion. The submit form renders every
2
+ // field as a text input (the runtime hardcodes type="text"), so the override arrives as a string
3
+ // and a blank field must fall back to the fleet default. Run with `deno test`.
4
+ import { assertEquals } from "jsr:@std/assert@1";
5
+ import { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
6
+
7
+ Deno.test("blank / absent / non-numeric → fallback", () => {
8
+ assertEquals(clampRounds("", 20), 20);
9
+ assertEquals(clampRounds(" ", 20), 20);
10
+ assertEquals(clampRounds(undefined, 20), 20);
11
+ assertEquals(clampRounds(null, 20), 20);
12
+ assertEquals(clampRounds("abc", 20), 20);
13
+ assertEquals(clampRounds(NaN, 20), 20);
14
+ });
15
+
16
+ Deno.test("a valid positive value (string or number) is honoured", () => {
17
+ assertEquals(clampRounds("5", 20), 5);
18
+ assertEquals(clampRounds(30, 20), 30);
19
+ assertEquals(clampRounds(" 12 ", 20), 12);
20
+ });
21
+
22
+ Deno.test("fractional values are truncated to a whole round", () => {
23
+ assertEquals(clampRounds("7.9", 20), 7);
24
+ assertEquals(clampRounds(3.2, 20), 3);
25
+ });
26
+
27
+ Deno.test("zero and negatives fall back (a cap < 1 would escalate before round 1)", () => {
28
+ assertEquals(clampRounds(0, 20), 20);
29
+ assertEquals(clampRounds("-4", 20), 20);
30
+ });
31
+
32
+ Deno.test("above the ceiling is clamped, not rejected", () => {
33
+ assertEquals(clampRounds(1000, 20), MAX_ROUNDS_CEILING);
34
+ assertEquals(clampRounds(String(MAX_ROUNDS_CEILING + 1), 20), MAX_ROUNDS_CEILING);
35
+ });
36
+
37
+ Deno.test("an oversized fallback is itself clamped to the ceiling", () => {
38
+ // A blank/invalid input falls back — but the fallback must not bypass the ceiling.
39
+ assertEquals(clampRounds("", MAX_ROUNDS_CEILING + 50), MAX_ROUNDS_CEILING);
40
+ assertEquals(clampRounds(" ", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
41
+ assertEquals(clampRounds(0, MAX_ROUNDS_CEILING + 999), MAX_ROUNDS_CEILING);
42
+ assertEquals(clampRounds("abc", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
43
+ });
44
+
45
+ // clampCiFixBudget — the merge stage's CI-fix attempt budget (NANO_PR_MAX_CI_FIX_ROUNDS). Unlike
46
+ // clampRounds it ALLOWS 0, because 0 is a meaningful setting: disable auto-fix so a blocked PR
47
+ // escalates to a human immediately.
48
+ Deno.test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
49
+ assertEquals(clampCiFixBudget("", 3), 3);
50
+ assertEquals(clampCiFixBudget(" ", 3), 3);
51
+ assertEquals(clampCiFixBudget(undefined, 3), 3);
52
+ assertEquals(clampCiFixBudget(null, 3), 3);
53
+ assertEquals(clampCiFixBudget("abc", 3), 3);
54
+ assertEquals(clampCiFixBudget(NaN, 3), 3);
55
+ assertEquals(clampCiFixBudget("-1", 3), 3);
56
+ });
57
+
58
+ Deno.test("ci-fix budget: 0 is honoured (disables auto-fix), unlike clampRounds", () => {
59
+ assertEquals(clampCiFixBudget(0, 3), 0);
60
+ assertEquals(clampCiFixBudget("0", 3), 0);
61
+ // Contrast: the round cap treats 0 as invalid and falls back.
62
+ assertEquals(clampRounds(0, 3), 3);
63
+ });
64
+
65
+ Deno.test("ci-fix budget: a valid positive value is honoured and truncated", () => {
66
+ assertEquals(clampCiFixBudget("5", 3), 5);
67
+ assertEquals(clampCiFixBudget(2, 3), 2);
68
+ assertEquals(clampCiFixBudget("4.8", 3), 4);
69
+ });
70
+
71
+ Deno.test("ci-fix budget: above the ceiling is clamped; oversized fallback too", () => {
72
+ assertEquals(clampCiFixBudget(1000, 3), MAX_CI_FIX_CEILING);
73
+ assertEquals(clampCiFixBudget("", MAX_CI_FIX_CEILING + 5), MAX_CI_FIX_CEILING);
74
+ });
package/app/rounds.ts ADDED
@@ -0,0 +1,48 @@
1
+ // The per-submit round-cap coercion, kept as a pure module (no env, no I/O) so it is trivially
2
+ // testable — mirrors app/waves.ts. `service.ts` builds the fleet default MAX_ROUNDS on top of it,
3
+ // and the submit form / webhook / start action each pass a caller-supplied override through it.
4
+
5
+ /** Upper bound on the review-round cap. A cap of 0 would escalate before the first round; an
6
+ * unbounded cap could run the agent (and its cost) indefinitely, so overrides are clamped here. */
7
+ export const MAX_ROUNDS_CEILING = 100;
8
+
9
+ /** Coerce an arbitrary caller-supplied round cap into a sane positive integer, falling back to
10
+ * `fallback` when the value is absent, blank, non-numeric, zero/negative, or NaN. The submit form
11
+ * sends strings (the runtime renders every field as text), so this accepts `string | number |
12
+ * unknown`. Values above MAX_ROUNDS_CEILING are clamped down rather than rejected — and the same
13
+ * clamp is applied to `fallback`, so an oversized fallback can never bypass the safety ceiling. */
14
+ export function clampRounds(value: unknown, fallback: number): number {
15
+ const safeFallback = Number.isFinite(fallback)
16
+ ? Math.min(Math.max(Math.trunc(fallback), 1), MAX_ROUNDS_CEILING)
17
+ : 1;
18
+ const n = typeof value === "number" ? value : Number(String(value ?? "").trim());
19
+ if (!Number.isFinite(n)) return safeFallback;
20
+ const i = Math.trunc(n);
21
+ if (i < 1) return safeFallback;
22
+ return Math.min(i, MAX_ROUNDS_CEILING);
23
+ }
24
+
25
+ /** Ceiling on CI-fix attempts, mirroring MAX_ROUNDS_CEILING — an unbounded budget could dispatch
26
+ * the `senior:fix-ci` agent (and its cost) indefinitely against a stubbornly-red PR. */
27
+ export const MAX_CI_FIX_CEILING = 20;
28
+
29
+ /** Coerce the operator-supplied CI-fix budget (env `NANO_PR_MAX_CI_FIX_ROUNDS`) into a sane
30
+ * non-negative integer. Unlike {@link clampRounds} this ALLOWS 0 — a budget of 0 disables
31
+ * auto-fix so a blocked PR escalates to a human immediately. Absent / blank / non-numeric /
32
+ * negative / NaN fall back to `fallback`; values above the ceiling are clamped down (and so is an
33
+ * oversized `fallback`, so it can never bypass the ceiling). */
34
+ export function clampCiFixBudget(value: unknown, fallback: number): number {
35
+ const safeFallback = Number.isFinite(fallback)
36
+ ? Math.min(Math.max(Math.trunc(fallback), 0), MAX_CI_FIX_CEILING)
37
+ : 0;
38
+ // Blank / absent → fallback. This guard is essential BEFORE coercion: Number("") is 0, so a
39
+ // blank env var must not be mistaken for an explicit 0 (which legitimately disables auto-fix).
40
+ if (value === null || value === undefined) return safeFallback;
41
+ const trimmed = typeof value === "number" ? value : String(value).trim();
42
+ if (trimmed === "") return safeFallback;
43
+ const n = typeof trimmed === "number" ? trimmed : Number(trimmed);
44
+ if (!Number.isFinite(n)) return safeFallback;
45
+ const i = Math.trunc(n);
46
+ if (i < 0) return safeFallback;
47
+ return Math.min(i, MAX_CI_FIX_CEILING);
48
+ }
@@ -0,0 +1,101 @@
1
+ // Red/green regression for re-submit clearing stale open escalations (Magikcraft/nano-bpm
2
+ // #597/#599). When a cancelled/converged PR is re-submitted, `submitPr` re-opens it for a fresh
3
+ // convergence run. Any escalation left `open` by the prior run — plus the denormalised
4
+ // `open_escalation_*` pointer on the PR row — must be cleared, or the answer form resurfaces a
5
+ // dead "(no question provided)" question on the re-opened PR (the same stale-row class the plan
6
+ // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
+ // GitHub transport forced off so it is hermetic.
8
+ import { assertEquals } from "jsr:@std/assert@1";
9
+ import { submitPr } from "./service.ts";
10
+
11
+ // deno-lint-ignore no-explicit-any
12
+ function memTable(rows: any[], key: string) {
13
+ return {
14
+ // deno-lint-ignore no-explicit-any
15
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
16
+ // deno-lint-ignore no-explicit-any
17
+ find: (q: any) =>
18
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
19
+ // deno-lint-ignore no-explicit-any
20
+ insert: (r: any) => {
21
+ rows.push(r);
22
+ return Promise.resolve(r);
23
+ },
24
+ // deno-lint-ignore no-explicit-any
25
+ update: (k: any, patch: any) => {
26
+ const r = rows.find((x) => x[key] === k);
27
+ if (r) Object.assign(r, patch);
28
+ return Promise.resolve(r);
29
+ },
30
+ // deno-lint-ignore no-explicit-any
31
+ delete: (k: any) => {
32
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
33
+ return Promise.resolve();
34
+ },
35
+ };
36
+ }
37
+
38
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
39
+ const prevMode = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
40
+ const prevTok = Deno.env.get("GITHUB_TOKEN");
41
+ Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token"); // no token below -> fetchPrMeta returns null
42
+ Deno.env.delete("GITHUB_TOKEN");
43
+ return run().finally(() => {
44
+ if (prevMode !== undefined) Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
45
+ else Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
46
+ if (prevTok !== undefined) Deno.env.set("GITHUB_TOKEN", prevTok);
47
+ });
48
+ }
49
+
50
+ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
51
+ await withGithubOff(async () => {
52
+ const PR_KEY = "owner/repo#42";
53
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
54
+ pull_requests: {
55
+ rows: [{
56
+ pr_key: PR_KEY,
57
+ repo: "owner/repo",
58
+ number: 42,
59
+ url: "https://github.com/owner/repo/pull/42",
60
+ title: "old title",
61
+ status: "abandoned", // terminal -> re-open path
62
+ current_round: 3,
63
+ open_escalation_id: 5,
64
+ open_escalation_question: "(no question provided)",
65
+ }],
66
+ key: "pr_key",
67
+ },
68
+ escalations: {
69
+ rows: [{ id: 5, pr_key: PR_KEY, round_no: 3, kind: "question", question: "(no question provided)", status: "open" }],
70
+ key: "id",
71
+ },
72
+ pr_dependencies: { rows: [], key: "pr_key" },
73
+ };
74
+ const data = {
75
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
76
+ // deno-lint-ignore no-explicit-any
77
+ } as any;
78
+ const engine = {
79
+ createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
80
+ // deno-lint-ignore no-explicit-any
81
+ } as any;
82
+
83
+ await submitPr(data, engine, {
84
+ repo: "owner/repo",
85
+ number: 42,
86
+ url: "https://github.com/owner/repo/pull/42",
87
+ prKey: PR_KEY,
88
+ });
89
+
90
+ // The prior run's open escalation is retired (not left "open" to resurface a dead form) …
91
+ const esc = stores.escalations.rows[0] as Record<string, unknown>;
92
+ assertEquals(esc.status, "stale");
93
+ // … and the PR row is re-opened with the denormalised escalation pointer cleared.
94
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
95
+ assertEquals(pr.status, "converging");
96
+ assertEquals(pr.current_round, 1);
97
+ assertEquals(pr.open_escalation_id, null);
98
+ assertEquals(pr.open_escalation_question, null);
99
+ assertEquals(pr.process_key, "PI-9");
100
+ });
101
+ });