@nathapp/nax 0.80.1 → 0.81.0-canary.2

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.
@@ -1,462 +0,0 @@
1
- /**
2
- * nax-finish PR title and body — pure deterministic builder, plus the loader
3
- * that assembles a `FinishPrContext` from finish-audit artifacts on disk.
4
- *
5
- * The finish flow opens a PR via `openOrPromotePr` and used to ship a
6
- * hardcoded `nax-finish: <feature>` title and a one-sentence body, throwing
7
- * away every artifact the run produced on the way. This module restores that
8
- * context as a deterministic markdown body, assembled by string joins over the
9
- * fields in `FinishPrContext`. Every *section* is reproducible from artifacts
10
- * that exist before `open_pr` runs, so the body stays greppable in PR history.
11
- *
12
- * Two fields are the exception, and both arrive later, from the narrative node
13
- * that runs after the PR is already open: `narrative` and `title`. Each has a
14
- * deterministic fallback (`resolveNarrative`, `resolveTitle`) so `open_pr` never
15
- * waits on a model — see `steps/pr-narrative.ts`.
16
- *
17
- * Reimplemented here (rather than imported from `src/`) because `flows/`
18
- * ships to a different runtime — `acpx flow run` runs it in acpx's own Node
19
- * process where nax's `src/` and its `@/*` alias are not available.
20
- */
21
- import { readFile } from "node:fs/promises";
22
- import { dirname, isAbsolute, join } from "node:path";
23
- import { runArgv } from "../exec";
24
- import { readSpecSummary, resolveNarrative } from "../narrative";
25
- import { findPrTemplate } from "../pr-template";
26
- import { type BodySection, type TemplateMode, mergeTemplate } from "../pr-template-merge";
27
- import { resolveTitle } from "../pr-title";
28
- import type { Finding, FindingDisposition, FinishInput, FinishRound, RunFn } from "../types";
29
- import type { Forge } from "./forge";
30
- import { readRounds } from "./result";
31
-
32
- const SECONDS_PER_MINUTE = 60;
33
- const MS_PER_SECOND = 1000;
34
-
35
- /** Six hex + one — the abbreviated form used everywhere in PR bodies and logs. */
36
- const SHORT_SHA_LEN = 7;
37
-
38
- /** One row in the Stories table. */
39
- export interface FinishPrStory {
40
- id: string;
41
- title: string;
42
- acCount: number;
43
- }
44
-
45
- /** Everything `open_pr` renders, sourced from finish-audit artifacts. */
46
- export interface FinishPrContext {
47
- feature: string;
48
- stories: FinishPrStory[];
49
- outOfScope: string[];
50
- acceptance?: string;
51
- regression?: string;
52
- gatesRan: string[];
53
- diffstat?: string;
54
- /**
55
- * `--shortstat` for the nax artifacts held out of `diffstat`. Absent when the
56
- * branch touched none, so a repo that gitignores them renders nothing.
57
- */
58
- artifactSummary?: string;
59
- /** Repository PR/MR template, verbatim. Absent when none resolves. */
60
- template?: string;
61
- /** How `template` is honoured. Absent → `merge`. See `pr-template-merge.ts`. */
62
- templateMode?: TemplateMode;
63
- /** Repo overrides for the template-heading → section-key table. */
64
- templateSectionMap?: Record<string, string>;
65
- /** Resolved "What changed" prose. Absent when neither source produced text. */
66
- narrative?: string;
67
- /**
68
- * Resolved conventional-commit PR title. Always set — `resolveTitle` falls
69
- * back to `feat: <feature>` when the narrative node produced nothing usable.
70
- */
71
- title: string;
72
- rounds: FinishRound[];
73
- run: {
74
- durationMs?: number;
75
- storiesPassed?: number;
76
- storiesTotal?: number;
77
- };
78
- }
79
-
80
- export const _prBodyDeps: {
81
- run: RunFn;
82
- readText: (path: string) => Promise<string | null>;
83
- warn: (message: string, details: { path: string; error: unknown }) => void;
84
- } = {
85
- run: runArgv,
86
- // ENOENT is the routine case (status.json/prd.json not yet written) and must
87
- // stay silent — mirrors `_qualityDeps.readText` in `steps/quality.ts`. Only a
88
- // genuine I/O failure (permission denied, corrupted mount) should warn.
89
- readText: async (path) => {
90
- try {
91
- return await readFile(path, "utf8");
92
- } catch (err) {
93
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
94
- throw err;
95
- }
96
- },
97
- warn: (message, details) => process.emitWarning(message, { detail: `${details.path}: ${String(details.error)}` }),
98
- };
99
-
100
- interface PrdArtifact {
101
- userStories?: { id: string; title: string; acceptanceCriteria?: unknown[] }[];
102
- outOfScope?: string[];
103
- }
104
-
105
- interface StatusArtifact {
106
- postRun?: { acceptance?: { status?: string }; regression?: { status?: string } };
107
- durationMs?: number;
108
- progress?: { passed?: number; total?: number };
109
- }
110
-
111
- async function readJson(path: string): Promise<unknown> {
112
- let text: string | null;
113
- try {
114
- text = await _prBodyDeps.readText(path);
115
- } catch (error) {
116
- _prBodyDeps.warn("[finish-pr] Failed to read PR context artifact", { path, error });
117
- return undefined;
118
- }
119
- if (text === null) return undefined;
120
- try {
121
- return JSON.parse(text);
122
- } catch (error) {
123
- _prBodyDeps.warn("[finish-pr] Failed to parse PR context artifact", { path, error });
124
- return undefined;
125
- }
126
- }
127
-
128
- function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
129
- if (!Array.isArray(prd?.userStories)) return [];
130
- // A hand-edited or older-schema PRD can carry a story with a missing/non-string
131
- // `id`/`title` — drop only that row rather than letting `escapeTableCell` throw
132
- // and take down the entire PR body (caught upstream by `open_pr`'s fallback).
133
- return prd.userStories
134
- .filter((story) => typeof story.id === "string" && typeof story.title === "string")
135
- .map((story) => ({
136
- id: story.id,
137
- title: story.title,
138
- acCount: Array.isArray(story.acceptanceCriteria) ? story.acceptanceCriteria.length : 0,
139
- }));
140
- }
141
-
142
- /**
143
- * Pathspec matching nax's own run artifacts, at any depth.
144
- *
145
- * `**` and the `glob` magic word are both load-bearing. nax writes artifacts
146
- * to a repo-root `.nax/` *and* to a per-package `<pkg>/.nax/` — a root-anchored
147
- * `:!.nax/**` silently keeps the per-package copy, which is routinely the
148
- * largest file in the diff (587 of 2039 insertions on the run that motivated
149
- * this). Without `glob`, git's default wildmatch lets `*` cross `/` and the
150
- * two forms stop being distinguishable.
151
- */
152
- const NAX_ARTIFACT_PATHSPEC = "**/.nax/**";
153
-
154
- /** The two halves of the branch's diff: what is under review, and what was held out. */
155
- interface DiffstatResult {
156
- diffstat?: string;
157
- artifactSummary?: string;
158
- }
159
-
160
- /** Run `git diff <...args>` under `workdir`, or `undefined` on any non-happy path. */
161
- async function runGitDiff(workdir: string, args: string[]): Promise<string | undefined> {
162
- try {
163
- const res = await _prBodyDeps.run(["git", "diff", ...args], { cwd: workdir });
164
- if (res.exitCode !== 0) return undefined;
165
- return res.stdout;
166
- } catch {
167
- return undefined;
168
- }
169
- }
170
-
171
- /**
172
- * Diffstat of the branch, excluding nax's own artifacts.
173
- *
174
- * The artifacts (`spec.md`, `prd.json`, the generated acceptance test) are
175
- * committed and real, but they are the run's exhaust rather than the change
176
- * under review, and they dominate the totals — quoting them in the headline
177
- * advertises a 2039-line change where 791 lines are reviewable code.
178
- * `artifactSummary` keeps them accounted for rather than silently dropped, so
179
- * the body still reconciles against `gh pr diff`.
180
- *
181
- * Fail-open on every non-happy path — a non-zero exit (no commits, divergent
182
- * branch, base missing), a rejected run promise (forks too slow to start), or
183
- * any thrown error — returning `undefined`. The PR's Verification block is
184
- * optional, and a routine empty-branch finish must not lose `open_pr` to a
185
- * throw that the body can simply skip.
186
- */
187
- async function runDiffstat(workdir: string, base: string): Promise<DiffstatResult> {
188
- // An empty `base` would interpolate to `...HEAD`, which git resolves as
189
- // `HEAD...HEAD` — exit 0, empty stdout — masking the missing-base case as
190
- // "no changes" instead of skipping explicitly.
191
- if (!base) return {};
192
- const range = `${base}...HEAD`;
193
- const [diffstat, artifacts] = await Promise.all([
194
- runGitDiff(workdir, ["--stat", range, "--", `:(glob,exclude)${NAX_ARTIFACT_PATHSPEC}`]),
195
- runGitDiff(workdir, ["--shortstat", range, "--", `:(glob)${NAX_ARTIFACT_PATHSPEC}`]),
196
- ]);
197
- const summary = artifacts?.trim();
198
- return { diffstat, artifactSummary: summary ? summary : undefined };
199
- }
200
-
201
- /**
202
- * Resolve the repository's PR/MR template, fail-open.
203
- *
204
- * An absent template is the common case and never warns. A genuine read failure
205
- * is swallowed too: the body is useful without this section, and `open_pr` must
206
- * not lose a PR to a permissions error on a file most repos do not have.
207
- */
208
- async function loadTemplate(workdir: string, forge: Forge | undefined): Promise<string | undefined> {
209
- if (forge === undefined) return undefined;
210
- try {
211
- return (await findPrTemplate(workdir, forge, { readText: _prBodyDeps.readText })) ?? undefined;
212
- } catch {
213
- return undefined;
214
- }
215
- }
216
-
217
- export async function loadFinishPrContext(
218
- input: FinishInput,
219
- args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string; title?: string },
220
- ): Promise<FinishPrContext> {
221
- const inputPrdPath = input.prdPath || "prd.json";
222
- const prdPath = isAbsolute(inputPrdPath) ? inputPrdPath : join(input.workdir, inputPrdPath);
223
- // [US-004] The audit trail (`rounds`), the diffstat, and the spec summary
224
- // are independent of the PRD/status reads — fetching them in parallel keeps
225
- // the loader's wall clock at max(readRounds, readJson×2, diffstat, spec).
226
- const [prd, status, rounds, stat, template, specSummary] = (await Promise.all([
227
- readJson(prdPath),
228
- readJson(join(dirname(prdPath), "status.json")),
229
- readRounds(input),
230
- runDiffstat(input.workdir, args.base),
231
- loadTemplate(input.workdir, args.forge),
232
- readSpecSummary(args.specPath, _prBodyDeps.readText),
233
- ])) as [
234
- PrdArtifact | undefined,
235
- StatusArtifact | undefined,
236
- FinishRound[],
237
- DiffstatResult,
238
- string | undefined,
239
- string | null,
240
- ];
241
- return {
242
- feature: input.feature,
243
- stories: storiesFrom(prd),
244
- outOfScope: Array.isArray(prd?.outOfScope) ? prd.outOfScope : [],
245
- acceptance: status?.postRun?.acceptance?.status,
246
- regression: status?.postRun?.regression?.status,
247
- gatesRan: args.gatesRan,
248
- rounds,
249
- diffstat: stat.diffstat,
250
- artifactSummary: stat.artifactSummary,
251
- template,
252
- ...(input.prBody?.template !== undefined ? { templateMode: input.prBody.template } : {}),
253
- ...(input.prBody?.sectionMap !== undefined ? { templateSectionMap: input.prBody.sectionMap } : {}),
254
- narrative: resolveNarrative(args.narrative, specSummary),
255
- title: resolveTitle(args.title, input.feature),
256
- run: {
257
- durationMs: status?.durationMs,
258
- storiesPassed: status?.progress?.passed,
259
- storiesTotal: status?.progress?.total,
260
- },
261
- };
262
- }
263
-
264
- /**
265
- * The PR title: the narrative node's conventional-commit subject when it
266
- * produced one, else `feat: <feature>`.
267
- *
268
- * That fallback is what this returned unconditionally, and is still what
269
- * `buildTitle` in `src/plugins/builtin/auto-pr/pr-body.ts` opens with — so a
270
- * finish run that reaches `open_pr` before the narrative node has spoken still
271
- * reads identically to an auto-PR-opened one in a list view. The two diverge
272
- * only once there is something better to say: `feat: schema-drift-gate` names
273
- * the run, not the change.
274
- */
275
- export function buildFinishTitle(ctx: FinishPrContext): string {
276
- return ctx.title;
277
- }
278
-
279
- /**
280
- * Escape a string for safe inclusion in a single markdown table cell.
281
- *
282
- * Mirrors `escapeTableCell` in `src/plugins/builtin/auto-pr/pr-body.ts`,
283
- * trimmed to the cases the finish body actually needs: pipes (which break
284
- * the column boundary) and newlines (which create new rows). Backslashes are
285
- * escaped first so the pipe escape survives a literal backslash in a title.
286
- */
287
- function escapeTableCell(value: string): string {
288
- return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
289
- }
290
-
291
- function formatDuration(durationMs: number): string {
292
- // `Math.max(0, NaN)` returns NaN, and `Math.floor(Infinity / 1000)` returns
293
- // Infinity — both would render as `"NaNm NaNs"` / `"Infinitym Infinitys"`.
294
- // A non-finite duration is a corrupted artifact (status.json is hand-editable),
295
- // so fall back to zero rather than let it leak into the PR body verbatim.
296
- if (!Number.isFinite(durationMs)) return "0m 00s";
297
- const clampedMs = Math.max(0, Math.round(durationMs));
298
- const totalSeconds = Math.floor(clampedMs / MS_PER_SECOND);
299
- const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
300
- const seconds = totalSeconds % SECONDS_PER_MINUTE;
301
- return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
302
- }
303
-
304
- function buildStoriesSection(stories: FinishPrStory[]): string {
305
- const lines: string[] = [];
306
- lines.push("| Story | Title | ACs |");
307
- lines.push("|-------|-------|-----|");
308
- for (const story of stories) {
309
- lines.push(`| ${escapeTableCell(story.id)} | ${escapeTableCell(story.title)} | ${story.acCount} |`);
310
- }
311
- return lines.join("\n");
312
- }
313
-
314
- /**
315
- * Takes the whole context rather than the five fields it reads: the section
316
- * grew past the three-positional-parameter cap in the coding standards, and
317
- * every field it wants is already on `FinishPrContext`.
318
- */
319
- function buildVerificationSection(ctx: FinishPrContext): string | null {
320
- const { acceptance, regression, gatesRan, diffstat, artifactSummary } = ctx;
321
- const lines: string[] = [];
322
- if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
323
- if (regression !== undefined) lines.push(`- Regression: ${regression}`);
324
- if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
325
- if (diffstat !== undefined && diffstat.length > 0) lines.push(`- Diffstat:\n\n\`\`\`\n${diffstat}\n\`\`\``);
326
- // Stated even though the files are excluded above: a reviewer who diffs the
327
- // branch themselves sees more than the diffstat quotes, and an unexplained
328
- // mismatch reads as a stale body.
329
- if (artifactSummary !== undefined && artifactSummary.length > 0) {
330
- lines.push(`- Excluded from diffstat — nax run artifacts: ${artifactSummary}`);
331
- }
332
- if (lines.length === 0) return null;
333
- return lines.join("\n");
334
- }
335
-
336
- function buildRoundHeading(round: FinishRound): string {
337
- const base = `### ${round.phase} attempt ${round.attempt}`;
338
- if (!round.committed || !round.sha) return base;
339
- const short = round.sha.slice(0, SHORT_SHA_LEN);
340
- return `${base} (${short})`;
341
- }
342
-
343
- /**
344
- * What an empty finding list means, in the reader's terms.
345
- *
346
- * Four different things used to render identically as "_no findings_", which a
347
- * human reads as "a reviewer looked at this and approved it" (#1507). Only
348
- * `passed` means that. `no-reviewer` is the one that actively misleads: the
349
- * gate phase has no reviewer node at all, so its empty list is the absence of a
350
- * review, not the result of one.
351
- *
352
- * `fixed` and the legacy `undefined` both fall through to "_no findings_":
353
- * rounds written before `outcome` existed carry no field to read, and claiming
354
- * anything more specific about them would be inventing detail.
355
- */
356
- const EMPTY_ROUND_NOTE: Record<string, string> = {
357
- passed: "- _no findings_",
358
- "no-reviewer": "- _no reviewer for this phase_",
359
- unparseable: "- _reviewer output could not be parsed_",
360
- escalated: "- _escalated for human review_",
361
- "review-skipped": "- _re-review skipped: this fix touched test files only_",
362
- incomplete: "- _review sent back: required evidence sections missing_",
363
- };
364
-
365
- function buildRoundBlock(round: FinishRound): string {
366
- const lines: string[] = [buildRoundHeading(round)];
367
- if (round.findings.length === 0) {
368
- lines.push(EMPTY_ROUND_NOTE[round.outcome ?? ""] ?? "- _no findings_");
369
- } else {
370
- if (round.outcome === "incomplete") {
371
- lines.push("- _not acted on — the review was sent back for missing evidence sections_");
372
- }
373
- for (const [i, finding] of round.findings.entries()) {
374
- const d = round.dispositions?.find((x) => x.index === i + 1);
375
- lines.push(d?.disposition === "rejected" ? renderRejected(finding, d) : renderFinding(finding));
376
- }
377
- }
378
- return lines.join("\n");
379
- }
380
-
381
- function buildRoundsSection(rounds: FinishRound[]): string | null {
382
- if (rounds.length === 0) return null;
383
- return rounds.map(buildRoundBlock).join("\n\n");
384
- }
385
-
386
- function renderFinding(finding: Finding): string {
387
- return `- [${finding.severity}] ${finding.title}`;
388
- }
389
-
390
- /**
391
- * A waived finding, shown as waived.
392
- *
393
- * The alternative — dropping it — would make a rejection indistinguishable from
394
- * a fix in the only artifact a human reads, which is the failure this whole
395
- * mechanism exists to avoid.
396
- */
397
- function renderRejected(finding: Finding, d: FindingDisposition): string {
398
- const evidence = d.evidence ? `\`${d.evidence}\`` : "_no evidence cited_";
399
- const caveat = d.evidenceMissing ? " — **evidence path not found**" : "";
400
- return `- [${finding.severity}] ${finding.title} — _rejected_: ${evidence}${caveat}`;
401
- }
402
-
403
- /**
404
- * Body only — the heading is attached by `buildFinishBody`, which drops any
405
- * section whose body is null. "No text" therefore cannot render a bare
406
- * `## What changed` heading, the empty-heading case #1477 forbids.
407
- */
408
- function buildNarrativeSection(narrative: string | undefined): string | null {
409
- return narrative?.trim() || null;
410
- }
411
-
412
- function buildOutOfScopeSection(outOfScope: string[]): string | null {
413
- if (outOfScope.length === 0) return null;
414
- return outOfScope.map((item) => `- ${item}`).join("\n");
415
- }
416
-
417
- function buildFooter(run: FinishPrContext["run"]): string | null {
418
- const { storiesPassed, storiesTotal, durationMs } = run;
419
- if (storiesPassed === undefined && storiesTotal === undefined && durationMs === undefined) return null;
420
- const counts =
421
- storiesPassed !== undefined && storiesTotal !== undefined ? `${storiesPassed}/${storiesTotal} stories` : null;
422
- const timing = durationMs !== undefined ? formatDuration(durationMs) : null;
423
- const parts = [counts, timing].filter((p): p is string => p !== null);
424
- if (parts.length === 0) return null;
425
- return parts.join(" · ");
426
- }
427
-
428
- /**
429
- * The nax-authored sections, in canonical order — the order they appear in
430
- * when the repo has no template, and the order the leftovers are appended in
431
- * when it has one. `key` is what `mergeTemplate` matches against the repo's
432
- * headings; the run footer carries an empty heading so it stays unmatchable
433
- * and last.
434
- */
435
- function buildBodySections(ctx: FinishPrContext): BodySection[] {
436
- const candidates: { key: string; heading: string; body: string | null }[] = [
437
- { key: "narrative", heading: "What changed", body: buildNarrativeSection(ctx.narrative) },
438
- { key: "stories", heading: "Stories", body: ctx.stories.length > 0 ? buildStoriesSection(ctx.stories) : null },
439
- { key: "verification", heading: "Verification", body: buildVerificationSection(ctx) },
440
- { key: "rounds", heading: "Review rounds", body: buildRoundsSection(ctx.rounds) },
441
- { key: "outOfScope", heading: "Out of scope", body: buildOutOfScopeSection(ctx.outOfScope) },
442
- { key: "footer", heading: "", body: buildFooter(ctx.run) },
443
- ];
444
- return candidates
445
- .filter((c): c is { key: string; heading: string; body: string } => c.body !== null)
446
- .map(({ key, heading, body }) => ({ key, heading, body }));
447
- }
448
-
449
- /**
450
- * Assemble the body, merged into the repo's own PR template when it has one.
451
- *
452
- * The template supplies the *shape* (which headings, in what order) and these
453
- * sections supply the *content* — see `pr-template-merge.ts` for why appending
454
- * it verbatim, which is what this used to do, shipped a blank form under a
455
- * filled one (nax#1504).
456
- */
457
- export function buildFinishBody(ctx: FinishPrContext): string {
458
- return mergeTemplate(ctx.template, buildBodySections(ctx), {
459
- mode: ctx.templateMode,
460
- sectionMap: ctx.templateSectionMap,
461
- });
462
- }
@@ -1,46 +0,0 @@
1
- /**
2
- * `amend_body` — rewrite the PR body once the narrative node has produced prose.
3
- *
4
- * Runs *after* the PR is open and its result file written, which is the whole
5
- * point: acpx has no error edge, so an acp node placed before `open_pr` could
6
- * kill the flow and cost the PR. Here the worst case is a body missing one
7
- * section.
8
- *
9
- * Every failure is warned and swallowed for the same reason — a throw would
10
- * fail a flow whose real work already succeeded.
11
- */
12
- import { gateOutputs, inputOf, loadCtxOf, narrativeOf, prTitleOf } from "../flow-ctx";
13
- import { detectForge } from "./forge";
14
- import { updatePrBody } from "./pr";
15
- import { _prBodyDeps, buildFinishBody, buildFinishTitle, loadFinishPrContext } from "./pr-body";
16
-
17
- export async function amendPrBodyNode(ctx: {
18
- input: unknown;
19
- outputs: unknown;
20
- }): Promise<{ route: "done"; amended: boolean }> {
21
- const narrative = narrativeOf(ctx);
22
- const title = prTitleOf(ctx);
23
- // Nothing to add: the body already in place is correct, and rewriting it
24
- // identically would spend a forge call to change nothing. A title alone is
25
- // still worth the call — it is the part a reviewer reads first.
26
- if (!narrative && !title) return { route: "done", amended: false };
27
-
28
- const i = inputOf(ctx);
29
- const loadCtx = loadCtxOf(ctx);
30
- try {
31
- const forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
32
- const prCtx = await loadFinishPrContext(i, {
33
- base: loadCtx.base ?? "",
34
- gatesRan: gateOutputs(ctx).ran ?? [],
35
- forge,
36
- specPath: loadCtx.specPath,
37
- narrative,
38
- title,
39
- });
40
- await updatePrBody(forge, i.workdir, i.branch, buildFinishTitle(prCtx), buildFinishBody(prCtx));
41
- return { route: "done", amended: true };
42
- } catch (error) {
43
- _prBodyDeps.warn("[finish-pr] Failed to amend the PR body with the narrative", { path: i.branch, error });
44
- return { route: "done", amended: false };
45
- }
46
- }
@@ -1,116 +0,0 @@
1
- import { FinishError } from "../errors";
2
- import type { RunFn } from "../types";
3
- import { type Forge, detectForge, extractUrl, viewArgv } from "./forge";
4
- import { _prBodyDeps, loadFinishPrContext } from "./pr-body";
5
-
6
- // `loadFinishPrContext` moved to `./pr-body` (the spec's stated module
7
- // boundary); re-exported here so consumers importing from `./pr` (or the
8
- // `steps` barrel, which re-exports `./pr`) keep working.
9
- export { loadFinishPrContext };
10
-
11
- // `_prDeps` is deliberately the *same object* as `./pr-body`'s `_prBodyDeps`,
12
- // not a copy — this module's `run` calls (forge CLI) and pr-body's
13
- // `readText`/`warn`/diffstat `run` calls share one injectable seam, so a
14
- // single test stub controls both. Typed to `{ run: RunFn }` here because
15
- // that's the only member this module actually calls.
16
- export const _prDeps: { run: RunFn } = _prBodyDeps;
17
-
18
- /**
19
- * Parse `gh pr view --json isDraft,url` / `glab mr view --output json` stdout.
20
- *
21
- * GitHub's schema is well-defined: `{ isDraft, url }`. GitLab's `glab mr view --output json`
22
- * schema is not guaranteed to expose an equivalent boolean under a stable name across
23
- * versions, so this is best-effort: it checks a few plausible field names for draft status
24
- * and falls back to treating any successfully-parsed MR as ready (not draft) when none are
25
- * found — per the task brief, "treat any successful view as already-ready unless you find
26
- * clear evidence otherwise."
27
- */
28
- function parseView(stdout: string, forge: Forge): { isDraft: boolean; url?: string } {
29
- try {
30
- const parsed = JSON.parse(stdout) as Record<string, unknown>;
31
- if (forge === "github") {
32
- return { isDraft: parsed.isDraft === true, url: typeof parsed.url === "string" ? parsed.url : undefined };
33
- }
34
- const isDraft = parsed.isDraft === true || parsed.draft === true || parsed.work_in_progress === true;
35
- return { isDraft, url: extractUrl(stdout) };
36
- } catch {
37
- return { isDraft: false, url: extractUrl(stdout) };
38
- }
39
- }
40
-
41
- export async function openOrPromotePr(
42
- repoRoot: string,
43
- branch: string,
44
- title: string,
45
- body: string,
46
- // Optional so a caller whose own `detectForge` threw still gets the previous
47
- // behaviour. Passing it in is what stops the body and the create-command from
48
- // disagreeing about the forge when both would otherwise detect separately.
49
- knownForge?: Forge,
50
- ): Promise<{ status: "opened" | "promoted" | "already-ready"; url?: string }> {
51
- const forge = knownForge ?? (await detectForge(_prDeps.run, repoRoot, "finish-pr"));
52
- const view = await _prDeps.run(viewArgv(forge, branch, "isDraft,url"), { cwd: repoRoot });
53
-
54
- if (view.exitCode !== 0) {
55
- const createCmd =
56
- forge === "github"
57
- ? ["gh", "pr", "create", "--title", title, "--body", body, "--head", branch]
58
- : ["glab", "mr", "create", "--title", title, "--description", body, "--source-branch", branch];
59
- const create = await _prDeps.run(createCmd, { cwd: repoRoot });
60
- if (create.exitCode !== 0) {
61
- throw new FinishError(
62
- `Failed to create PR/MR for "${branch}": ${create.stderr.trim() || `exit ${create.exitCode}`}`,
63
- "FINISH_PR_CREATE_FAILED",
64
- { stage: "finish-pr", branch },
65
- );
66
- }
67
- return { status: "opened", url: extractUrl(create.stdout) };
68
- }
69
-
70
- const { isDraft, url } = parseView(view.stdout, forge);
71
- if (isDraft) {
72
- const readyCmd = forge === "github" ? ["gh", "pr", "ready", branch] : ["glab", "mr", "update", branch, "--ready"];
73
- const ready = await _prDeps.run(readyCmd, { cwd: repoRoot });
74
- if (ready.exitCode !== 0) {
75
- throw new FinishError(
76
- `Failed to promote PR/MR "${branch}" to ready: ${ready.stderr.trim() || `exit ${ready.exitCode}`}`,
77
- "FINISH_PR_PROMOTE_FAILED",
78
- { stage: "finish-pr", branch },
79
- );
80
- }
81
- await updatePrBody(forge, repoRoot, branch, title, body);
82
- return { status: "promoted", url };
83
- }
84
-
85
- await updatePrBody(forge, repoRoot, branch, title, body);
86
- return { status: "already-ready", url };
87
- }
88
-
89
- /**
90
- * Write the finish title/body onto an already-open PR/MR.
91
- *
92
- * Non-fatal by design: this runs after the PR exists, so a failed metadata
93
- * write must not throw away that state — the caller's returned status/url
94
- * stays valid either way. Exported because `amend_body` calls it after the
95
- * narrative node produces prose.
96
- */
97
- export async function updatePrBody(
98
- forge: Forge,
99
- repoRoot: string,
100
- branch: string,
101
- title: string,
102
- body: string,
103
- ): Promise<void> {
104
- const editCmd =
105
- forge === "github"
106
- ? ["gh", "pr", "edit", branch, "--title", title, "--body", body]
107
- : ["glab", "mr", "update", branch, "--title", title, "--description", body];
108
- try {
109
- const res = await _prDeps.run(editCmd, { cwd: repoRoot });
110
- if (res.exitCode !== 0) {
111
- _prBodyDeps.warn("[finish-pr] Failed to write PR title/body", { path: branch, error: res.stderr.trim() });
112
- }
113
- } catch (error) {
114
- _prBodyDeps.warn("[finish-pr] Failed to write PR title/body", { path: branch, error });
115
- }
116
- }