@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
package/app/github.ts ADDED
@@ -0,0 +1,647 @@
1
+ // GitHub review fetch for the review-ready poller (SPEC §10).
2
+ //
3
+ // Two transports, selected by `NANO_PR_GITHUB_TRANSPORT` (auto | gh | token):
4
+ // • gh — shell out to the host `gh` CLI. It uses the user's own GitHub login, so the
5
+ // poller reaches every repository the user can reach — including private repos
6
+ // that no PAT is (or can be) issued for. This is the default on a workstation.
7
+ // • token — HTTP `fetch` to api.github.com with `GITHUB_TOKEN`. Used in headless/CI where
8
+ // no interactive `gh` login exists.
9
+ // • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
10
+ //
11
+ // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
12
+ // Cross-runtime: runs under Node (`node:child_process`) and Deno (`Deno.Command`).
13
+
14
+ /** A GitHub pull-request review, narrowed to the fields the poller needs. */
15
+ export interface GhReview {
16
+ id: number;
17
+ state: string;
18
+ submitted_at?: string;
19
+ }
20
+
21
+ export type GithubTransport = "gh" | "token" | "auto";
22
+
23
+ /** Resolve the configured transport, defaulting to `auto`. */
24
+ export function githubTransport(): GithubTransport {
25
+ const t = (process.env.NANO_PR_GITHUB_TRANSPORT ?? "auto").trim().toLowerCase();
26
+ return t === "gh" || t === "token" ? t : "auto";
27
+ }
28
+
29
+ interface DenoCommandCtor {
30
+ new (
31
+ command: string,
32
+ options: { args: string[]; stdout: "piped"; stderr: "piped" },
33
+ ): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
34
+ }
35
+
36
+ /** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
37
+ * `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
38
+ * non-zero exit with stderr as the message. */
39
+ async function runGh(args: string[]): Promise<string> {
40
+ const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
41
+ if (g.Deno?.Command) {
42
+ const { code, stdout, stderr } = await new g.Deno.Command("gh", {
43
+ args,
44
+ stdout: "piped",
45
+ stderr: "piped",
46
+ }).output();
47
+ if (code !== 0) {
48
+ throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
49
+ }
50
+ return new TextDecoder().decode(stdout);
51
+ }
52
+ const { execFile } = await import("node:child_process");
53
+ return await new Promise<string>((resolve, reject) => {
54
+ execFile(
55
+ "gh",
56
+ args,
57
+ { maxBuffer: 16 * 1024 * 1024 },
58
+ (err, stdout, stderr) => {
59
+ if (err) reject(new Error(String(stderr || "").trim() || err.message));
60
+ else resolve(String(stdout));
61
+ },
62
+ );
63
+ });
64
+ }
65
+
66
+ let ghAvailable: Promise<boolean> | undefined;
67
+ /** Whether the host `gh` CLI is present (memoized — probed at most once per process). */
68
+ function isGhAvailable(): Promise<boolean> {
69
+ return (ghAvailable ??= runGh(["--version"]).then(() => true, () => false));
70
+ }
71
+
72
+ /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
73
+ * the caller can log-and-continue; returns `null` when no transport is usable (idle). */
74
+ export async function fetchPrReviews(
75
+ repo: string,
76
+ number: number | string,
77
+ token: string,
78
+ ): Promise<GhReview[] | null> {
79
+ const mode = githubTransport();
80
+ const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
81
+ const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
82
+ if (useGh) {
83
+ const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
84
+ return JSON.parse(out) as GhReview[];
85
+ }
86
+ if (!token) return null; // token mode with no token → poller idles
87
+ const r = await fetch(`https://api.github.com/${path}`, {
88
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
89
+ });
90
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
91
+ return (await r.json()) as GhReview[];
92
+ }
93
+
94
+ // ── Copilot re-request (review-wait liveness) ───────────────────────────────
95
+ // A PR parked in `waiting_review` blocks on a *fresh* Copilot review. Copilot won't
96
+ // spontaneously re-review a round with no new commit, and routinely dismisses a re-request, so
97
+ // the poller must actively solicit the next round's review. Reliable re-request is the REST
98
+ // reviewers endpoint with the exact `[bot]` login below — the bare `Copilot` login and the
99
+ // GraphQL `requestReviews` mutation both silently no-op (GraphQL resolves Users only).
100
+
101
+ /** The exact reviewer login GitHub's REST reviewers endpoint accepts for the automated Copilot
102
+ * reviewer. NOT the bare `Copilot` display login (which no-ops) and NOT the `copilot-swe-agent`
103
+ * coding bot. */
104
+ export const COPILOT_REVIEWER = "copilot-pull-request-reviewer[bot]";
105
+
106
+ /** The `requested_reviewers` GET surfaces the pending Copilot reviewer under its *display* login
107
+ * `Copilot`, whereas the POST requires the `[bot]` login above — so a pending check must match
108
+ * either spelling. */
109
+ function isCopilot(login: string | undefined): boolean {
110
+ return login === "Copilot" || login === COPILOT_REVIEWER;
111
+ }
112
+
113
+ /** Whether Copilot is currently a *pending* (requested-but-not-yet-submitted) reviewer on the PR.
114
+ * The poller uses this to avoid re-requesting a review that is already in flight. `null` when no
115
+ * transport is usable (poller idles); throws on a genuine transport failure. */
116
+ export async function hasPendingCopilotReviewer(
117
+ repo: string,
118
+ number: number | string,
119
+ token: string,
120
+ ): Promise<boolean | null> {
121
+ const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
122
+ let users: { login?: string }[];
123
+ if (await useGh()) {
124
+ const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
125
+ users = (JSON.parse(out) as { users?: { login?: string }[] }).users ?? [];
126
+ } else {
127
+ if (!token) return null;
128
+ const r = await fetch(`https://api.github.com/${path}`, {
129
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
130
+ });
131
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
132
+ users = ((await r.json()) as { users?: { login?: string }[] }).users ?? [];
133
+ }
134
+ return users.some((u) => isCopilot(u.login));
135
+ }
136
+
137
+ /** Request a fresh Copilot review on the PR (REST reviewers endpoint, exact `[bot]` login), so
138
+ * the process's `review-ready` catch can eventually fire. Returns `"requested"` on success,
139
+ * `"unavailable"` when Copilot is not an assignable reviewer on that repo (HTTP 422 — e.g.
140
+ * Copilot review not enabled there), or `null` when no transport is usable. Never throws for the
141
+ * 422 "not assignable" case; only a genuine transport failure propagates. */
142
+ export async function requestCopilotReview(
143
+ repo: string,
144
+ number: number | string,
145
+ token: string,
146
+ ): Promise<"requested" | "unavailable" | null> {
147
+ const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
148
+ if (await useGh()) {
149
+ try {
150
+ await runGh(["api", path, "-X", "POST", "-f", `reviewers[]=${COPILOT_REVIEWER}`]);
151
+ return "requested";
152
+ } catch (err) {
153
+ const msg = err instanceof Error ? err.message : String(err);
154
+ // gh surfaces the 422 as its HTTP status and/or the "Unprocessable"/"not be requested"
155
+ // body; treat any of those as "Copilot isn't assignable here" rather than a hard failure.
156
+ if (/\b422\b|unprocessable|cannot be requested|not.*(assignable|be requested)/i.test(msg)) {
157
+ return "unavailable";
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+ if (!token) return null;
163
+ const r = await fetch(`https://api.github.com/${path}`, {
164
+ method: "POST",
165
+ headers: {
166
+ authorization: `Bearer ${token}`,
167
+ accept: "application/vnd.github+json",
168
+ "content-type": "application/json",
169
+ },
170
+ body: JSON.stringify({ reviewers: [COPILOT_REVIEWER] }),
171
+ });
172
+ if (r.ok) return "requested";
173
+ if (r.status === 422) return "unavailable";
174
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
175
+ }
176
+
177
+ // ── Merge stage (SPEC §11) ──────────────────────────────────────────────────
178
+ // The same two-transport model (gh | token) backs the merge stage: read a PR's merge state to
179
+ // decide when it is landable, and perform the merge (directly or via the repo's merge queue).
180
+
181
+ /** Whether to use the `gh` CLI for this pass, honouring `NANO_PR_GITHUB_TRANSPORT`. */
182
+ async function useGh(): Promise<boolean> {
183
+ const mode = githubTransport();
184
+ return mode === "gh" || (mode === "auto" && (await isGhAvailable()));
185
+ }
186
+
187
+ /** PR metadata we read once at submit: the title (to label the row) and the body (to scan for a
188
+ * `Depends-on:` line). `null` when no transport is usable. */
189
+ export interface PrMeta {
190
+ title: string | null;
191
+ body: string;
192
+ }
193
+
194
+ export async function fetchPrMeta(
195
+ repo: string,
196
+ number: number | string,
197
+ token: string,
198
+ ): Promise<PrMeta | null> {
199
+ if (await useGh()) {
200
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
201
+ const j = JSON.parse(out) as { title?: string; body?: string };
202
+ return { title: j.title ?? null, body: j.body ?? "" };
203
+ }
204
+ if (!token) return null;
205
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
206
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
207
+ });
208
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
209
+ const j = (await r.json()) as { title?: string; body?: string };
210
+ return { title: j.title ?? null, body: j.body ?? "" };
211
+ }
212
+
213
+ /** A PR's merge state, narrowed to what the merge poller needs to classify landability.
214
+ * `mergeStateStatus` uses GitHub's vocabulary (CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE |
215
+ * DRAFT | HAS_HOOKS | UNKNOWN). `failingChecks` is `-1` when the transport can't enumerate
216
+ * checks (token mode) so the classifier stays conservative. `failingCheckNames` lists those
217
+ * failing gates (empty in token mode) so the CI-fix agent knows what to make green. */
218
+ export interface PrState {
219
+ merged: boolean;
220
+ mergeStateStatus: string;
221
+ failingChecks: number;
222
+ failingCheckNames: string[];
223
+ /** Total head check runs of any state (pending/failed/passed). `0` = no run exists at all (the
224
+ * frugal-CI stuck state the fresh-head-run remedy targets); `-1` when the transport can't
225
+ * enumerate checks (token mode). */
226
+ totalChecks: number;
227
+ /** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
228
+ isDraft: boolean;
229
+ /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
230
+ headRefOid: string | null;
231
+ }
232
+
233
+ /** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
234
+ * vocabulary the classifier speaks, so both transports feed one code path. */
235
+ function normalizeMergeState(s: string): string {
236
+ return (s || "unknown").toUpperCase();
237
+ }
238
+
239
+ interface RollupEntry {
240
+ status?: string;
241
+ conclusion?: string;
242
+ state?: string;
243
+ name?: string;
244
+ context?: string;
245
+ workflowName?: string;
246
+ }
247
+ /** Names of the checks whose result is a hard failure (as opposed to pending/success). Covers
248
+ * both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext
249
+ * shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which
250
+ * gates to make green; `failingChecks` (the count) is derived from this list. */
251
+ function failingCheckNames(rollup: RollupEntry[]): string[] {
252
+ const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
253
+ const names: string[] = [];
254
+ for (const c of rollup) {
255
+ const v = (c.conclusion || c.state || "").toUpperCase();
256
+ if (bad.has(v)) names.push(c.name || c.context || c.workflowName || "check");
257
+ }
258
+ return names;
259
+ }
260
+
261
+ export async function fetchPrState(
262
+ repo: string,
263
+ number: number | string,
264
+ token: string,
265
+ ): Promise<PrState | null> {
266
+ if (await useGh()) {
267
+ const out = await runGh([
268
+ "pr",
269
+ "view",
270
+ String(number),
271
+ "--repo",
272
+ repo,
273
+ "--json",
274
+ "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
275
+ ]);
276
+ const j = JSON.parse(out) as {
277
+ state?: string;
278
+ mergedAt?: string | null;
279
+ mergeStateStatus?: string;
280
+ statusCheckRollup?: RollupEntry[];
281
+ isDraft?: boolean;
282
+ headRefOid?: string | null;
283
+ };
284
+ const rollup = j.statusCheckRollup ?? [];
285
+ const names = failingCheckNames(rollup);
286
+ return {
287
+ merged: j.state === "MERGED" || !!j.mergedAt,
288
+ mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(),
289
+ failingChecks: names.length,
290
+ failingCheckNames: names,
291
+ totalChecks: rollup.length,
292
+ isDraft: !!j.isDraft,
293
+ headRefOid: j.headRefOid ?? null,
294
+ };
295
+ }
296
+ if (!token) return null;
297
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
298
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
299
+ });
300
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
301
+ const j = (await r.json()) as {
302
+ merged?: boolean;
303
+ merged_at?: string | null;
304
+ mergeable_state?: string;
305
+ draft?: boolean;
306
+ head?: { sha?: string | null };
307
+ };
308
+ return {
309
+ // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
310
+ // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
311
+ merged: !!j.merged || !!j.merged_at,
312
+ mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"),
313
+ failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
314
+ failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
315
+ totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
316
+ isDraft: !!j.draft,
317
+ headRefOid: j.head?.sha ?? null,
318
+ };
319
+ }
320
+
321
+ /** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly;
322
+ * the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no
323
+ * transport is usable (idle), an empty array for a PR with no files. */
324
+ export async function fetchPrFiles(
325
+ repo: string,
326
+ number: number | string,
327
+ token: string,
328
+ ): Promise<string[] | null> {
329
+ if (await useGh()) {
330
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "files"]);
331
+ const j = JSON.parse(out) as { files?: { path?: string }[] };
332
+ return (j.files ?? []).map((f) => f.path ?? "").filter((p) => p !== "");
333
+ }
334
+ if (!token) return null;
335
+ const paths: string[] = [];
336
+ // Cap the paging so a freak huge PR can't spin the scan; 5×100 files is far past any real slice.
337
+ const MAX_PAGES = 5;
338
+ for (let page = 1; page <= MAX_PAGES; page++) {
339
+ const r = await fetch(
340
+ `https://api.github.com/repos/${repo}/pulls/${number}/files?per_page=100&page=${page}`,
341
+ { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
342
+ );
343
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
344
+ const batch = (await r.json()) as { filename?: string }[];
345
+ for (const f of batch) if (f.filename) paths.push(f.filename);
346
+ // A short final page means we've read every file — the list is complete.
347
+ if (batch.length < 100) return paths;
348
+ // A full page on the last allowed page is only truncated if GitHub says there's more. Trust the
349
+ // `Link` header's `rel="next"` rather than page size, so an exact multiple of 100 (e.g. exactly
350
+ // 500 files, no next page) is returned as complete instead of throwing a false positive. When
351
+ // the cap genuinely truncates, throw so the caller can log-and-skip rather than recording
352
+ // exclusions from an incomplete (under-approximated) file set that could miss real overlaps.
353
+ if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
354
+ throw new Error(
355
+ `github pr files truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-file paging cap`,
356
+ );
357
+ }
358
+ }
359
+ return paths;
360
+ }
361
+
362
+ /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. */
363
+ export async function fetchPrHead(
364
+ repo: string,
365
+ number: number | string,
366
+ token: string,
367
+ ): Promise<{ headRef: string | null; headSha: string | null } | null> {
368
+ if (await useGh()) {
369
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
370
+ const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
371
+ return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
372
+ }
373
+ if (!token) return null;
374
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
375
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
376
+ });
377
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
378
+ const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
379
+ return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
380
+ }
381
+
382
+ /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
383
+ * transport is usable (idle). Used by the dead-end-base guard (#60) so we never land a PR into a
384
+ * base that has itself already merged to the default branch. */
385
+ export async function fetchPrBase(
386
+ repo: string,
387
+ number: number | string,
388
+ token: string,
389
+ ): Promise<string | null> {
390
+ if (await useGh()) {
391
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "baseRefName"]);
392
+ const j = JSON.parse(out) as { baseRefName?: string };
393
+ return j.baseRefName ?? null;
394
+ }
395
+ if (!token) return null;
396
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
397
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
398
+ });
399
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
400
+ const j = (await r.json()) as { base?: { ref?: string } };
401
+ return j.base?.ref ?? null;
402
+ }
403
+
404
+ const defaultBranchCache = new Map<string, { at: number; name: string | null }>();
405
+ const DEFAULT_BRANCH_TTL_MS = 5 * 60_000;
406
+
407
+ /** The repo's default branch (e.g. `main`), memoized per repo for 5 min. A PR that targets the
408
+ * default branch can never be a dead-end, so the guard short-circuits on it. `null` when no
409
+ * transport is usable. */
410
+ export async function fetchDefaultBranch(repo: string, token: string): Promise<string | null> {
411
+ const hit = defaultBranchCache.get(repo);
412
+ if (hit && Date.now() - hit.at < DEFAULT_BRANCH_TTL_MS) return hit.name;
413
+ let name: string | null = null;
414
+ if (await useGh()) {
415
+ const out = await runGh(["repo", "view", repo, "--json", "defaultBranchRef"]);
416
+ const j = JSON.parse(out) as { defaultBranchRef?: { name?: string } };
417
+ name = j.defaultBranchRef?.name ?? null;
418
+ } else if (token) {
419
+ const r = await fetch(`https://api.github.com/repos/${repo}`, {
420
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
421
+ });
422
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
423
+ const j = (await r.json()) as { default_branch?: string };
424
+ name = j.default_branch ?? null;
425
+ } else {
426
+ return null; // no transport → leave the cache untouched so a later call can resolve it
427
+ }
428
+ defaultBranchCache.set(repo, { at: Date.now(), name });
429
+ return name;
430
+ }
431
+
432
+ /** Whether a branch has already *landed* — i.e. it is the head of a `MERGED` PR. Returns:
433
+ * • `landed` — a merged PR exists from this branch → the branch is a dead-end target
434
+ * • `open` — an open PR exists from it (still alive)
435
+ * • `unknown` — no PR references it, or no transport (ambiguous → never treated as dead-end)
436
+ * The guard blocks a merge only on a positive `landed` signal, so a valid stacked merge is never
437
+ * wrongly held. */
438
+ export async function baseBranchLanded(
439
+ repo: string,
440
+ branch: string,
441
+ token: string,
442
+ ): Promise<"landed" | "open" | "unknown"> {
443
+ if (await useGh()) {
444
+ const out = await runGh([
445
+ "pr",
446
+ "list",
447
+ "--repo",
448
+ repo,
449
+ "--head",
450
+ branch,
451
+ "--state",
452
+ "all",
453
+ "--json",
454
+ "state",
455
+ "--limit",
456
+ "20",
457
+ ]);
458
+ const arr = JSON.parse(out) as { state?: string }[];
459
+ if (arr.some((p) => (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
460
+ if (arr.some((p) => (p.state ?? "").toUpperCase() === "OPEN")) return "open";
461
+ return "unknown";
462
+ }
463
+ if (!token) return "unknown";
464
+ const owner = repo.split("/")[0];
465
+ const r = await fetch(
466
+ `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=20`,
467
+ { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
468
+ );
469
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
470
+ const arr = (await r.json()) as { state?: string; merged_at?: string | null }[];
471
+ if (arr.some((p) => p.merged_at || (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
472
+ if (arr.some((p) => (p.state ?? "").toLowerCase() === "open")) return "open";
473
+ return "unknown";
474
+ }
475
+
476
+ /** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is
477
+ * still running checks / awaiting review). The poller only advances the process on a settled
478
+ * verdict; `waiting` means re-poll later. */
479
+ export type Mergeability = "ready" | "waiting" | "conflict" | "blocked";
480
+
481
+ export function classifyMergeability(s: PrState): Mergeability {
482
+ switch (s.mergeStateStatus) {
483
+ case "CLEAN":
484
+ case "HAS_HOOKS":
485
+ case "UNSTABLE": // only non-required checks failing — still mergeable
486
+ case "BEHIND": // out of date; a queue rebases, a direct merge is still allowed
487
+ return "ready";
488
+ case "DIRTY":
489
+ return "conflict";
490
+ case "BLOCKED":
491
+ // A required check failed -> a human must act. Pending checks / awaiting review -> wait.
492
+ // When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait.
493
+ return s.failingChecks > 0 ? "blocked" : "waiting";
494
+ case "DRAFT":
495
+ default: // UNKNOWN / "" — GitHub is still computing mergeability
496
+ return "waiting";
497
+ }
498
+ }
499
+
500
+ export type MergeMethod = "squash" | "merge" | "rebase";
501
+ export interface MergeOptions {
502
+ method: MergeMethod;
503
+ admin: boolean;
504
+ }
505
+ export interface MergeResult {
506
+ outcome: "merged" | "queued" | "blocked";
507
+ detail: string;
508
+ }
509
+
510
+ /** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
511
+ * queue — the poller then watches for it to land), or `blocked` (GitHub refused — a human must
512
+ * resolve it, then reply to retry). `null` when no transport is usable. Never throws for a
513
+ * refused merge; only a genuine transport failure propagates. */
514
+ export async function mergePr(
515
+ repo: string,
516
+ number: number | string,
517
+ token: string,
518
+ opts: MergeOptions,
519
+ ): Promise<MergeResult | null> {
520
+ const methodFlag = `--${opts.method}`;
521
+ if (await useGh()) {
522
+ const args = ["pr", "merge", String(number), "--repo", repo, methodFlag];
523
+ if (opts.admin) args.push("--admin");
524
+ try {
525
+ const out = await runGh(args);
526
+ // gh prints "… will be added to the merge queue" when the branch requires one.
527
+ if (/merge queue/i.test(out)) return { outcome: "queued", detail: out.trim() };
528
+ return { outcome: "merged", detail: out.trim() || "merged" };
529
+ } catch (err) {
530
+ const msg = err instanceof Error ? err.message : String(err);
531
+ // A merge-queue-required branch surfaces as an error on older gh; treat as queued when the
532
+ // message says so, otherwise it is a genuine block (conflict, failing gate, perms).
533
+ if (/added to the merge queue|enqueued/i.test(msg)) return { outcome: "queued", detail: msg };
534
+ return { outcome: "blocked", detail: msg };
535
+ }
536
+ }
537
+ if (!token) return null;
538
+ const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}/merge`, {
539
+ method: "PUT",
540
+ headers: {
541
+ authorization: `Bearer ${token}`,
542
+ accept: "application/vnd.github+json",
543
+ "content-type": "application/json",
544
+ },
545
+ body: JSON.stringify({ merge_method: opts.method }),
546
+ });
547
+ if (r.ok) {
548
+ // A 2xx from the REST merge endpoint does not guarantee the PR has *landed*: the body's
549
+ // `merged` flag is authoritative, and a merge-queue-required branch is enrolled (not merged)
550
+ // in this pass. Trust `merged` when true; otherwise verify the PR's actual state and report
551
+ // `queued` when it hasn't landed yet, so the merge-loop waits for `merge-landed` rather than
552
+ // marking it merged prematurely.
553
+ const body = (await r.json().catch(() => ({}))) as { merged?: boolean };
554
+ if (body.merged) return { outcome: "merged", detail: "merged" };
555
+ const st = await fetchPrState(repo, number, token).catch(() => null);
556
+ if (st?.merged) return { outcome: "merged", detail: "merged" };
557
+ return { outcome: "queued", detail: "merge accepted; PR not yet landed (awaiting merge queue)" };
558
+ }
559
+ const detail = `github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim();
560
+ return { outcome: "blocked", detail };
561
+ }
562
+
563
+ // ── Merge-protocol execution helpers (issue #43) ────────────────────────────
564
+ // Two capabilities the frugal-CI + on-demand-queue landing protocol needs, on top of the plain
565
+ // `gh pr merge` above: (a) read an arbitrary file from the target repo to discover its published
566
+ // merge protocol, and (b) produce a fresh head `pull_request` run + enqueue via a comment.
567
+
568
+ /** Read a text file from the *target* repo (default branch) via the configured transport, or
569
+ * `null` when it doesn't exist / no transport is usable. Used to discover a repo's published
570
+ * merge-protocol descriptor (see app/mergeProtocol.ts). Never throws on a 404 — a repo without
571
+ * the file simply has no descriptor. */
572
+ export async function fetchRepoFile(
573
+ repo: string,
574
+ path: string,
575
+ token: string,
576
+ ): Promise<string | null> {
577
+ const apiPath = `repos/${repo}/contents/${path}`;
578
+ if (await useGh()) {
579
+ try {
580
+ return await runGh(["api", apiPath, "-H", "Accept: application/vnd.github.raw"]);
581
+ } catch {
582
+ return null; // 404 / not found → no descriptor
583
+ }
584
+ }
585
+ if (!token) return null;
586
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
587
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github.raw" },
588
+ });
589
+ if (!r.ok) return null;
590
+ return await r.text();
591
+ }
592
+
593
+ /** Produce a fresh head `pull_request` run so branch protection has a run to count. `ready` marks
594
+ * a draft ready (`gh pr ready`); `reopen` closes then reopens the PR (the `reopened` event fires a
595
+ * fresh run). gh transport only — headless token mode can't reliably drive these, so it no-ops
596
+ * (the poller then simply keeps waiting, i.e. today's behaviour). Best-effort: resolves even on
597
+ * failure so a transient error never wedges the merge-loop. */
598
+ export async function ensureFreshHeadRun(
599
+ repo: string,
600
+ number: number | string,
601
+ action: "ready" | "reopen",
602
+ ): Promise<boolean> {
603
+ if (!(await useGh())) return false;
604
+ const n = String(number);
605
+ try {
606
+ if (action === "ready") {
607
+ await runGh(["pr", "ready", n, "--repo", repo]);
608
+ } else {
609
+ await runGh(["pr", "close", n, "--repo", repo]);
610
+ await runGh(["pr", "reopen", n, "--repo", repo]);
611
+ }
612
+ return true;
613
+ } catch {
614
+ return false;
615
+ }
616
+ }
617
+
618
+ /** Post a comment on the PR (e.g. `@mergifyio queue`) to enqueue it in the repo's merge queue.
619
+ * gh transport shells out; token mode posts an issue comment via REST. Returns whether the
620
+ * comment was accepted. */
621
+ export async function enqueueViaComment(
622
+ repo: string,
623
+ number: number | string,
624
+ token: string,
625
+ comment: string,
626
+ ): Promise<boolean> {
627
+ const n = String(number);
628
+ if (await useGh()) {
629
+ try {
630
+ await runGh(["pr", "comment", n, "--repo", repo, "--body", comment]);
631
+ return true;
632
+ } catch {
633
+ return false;
634
+ }
635
+ }
636
+ if (!token) return false;
637
+ const r = await fetch(`https://api.github.com/repos/${repo}/issues/${n}/comments`, {
638
+ method: "POST",
639
+ headers: {
640
+ authorization: `Bearer ${token}`,
641
+ accept: "application/vnd.github+json",
642
+ "content-type": "application/json",
643
+ },
644
+ body: JSON.stringify({ body: comment }),
645
+ });
646
+ return r.ok;
647
+ }