@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.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- 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
|
+
}
|