@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.
- package/dist/nax.js +44737 -42009
- package/package.json +6 -6
- package/flows/nax-finish/commit-message.ts +0 -239
- package/flows/nax-finish/errors.ts +0 -23
- package/flows/nax-finish/exec.ts +0 -135
- package/flows/nax-finish/findings-parse.ts +0 -150
- package/flows/nax-finish/flow-ctx.ts +0 -150
- package/flows/nax-finish/narrative.ts +0 -215
- package/flows/nax-finish/nax-finish.flow.ts +0 -566
- package/flows/nax-finish/pr-template-merge.ts +0 -253
- package/flows/nax-finish/pr-template.ts +0 -56
- package/flows/nax-finish/pr-title.ts +0 -140
- package/flows/nax-finish/review-prompts.ts +0 -468
- package/flows/nax-finish/steps/acceptance.ts +0 -76
- package/flows/nax-finish/steps/commit-round.ts +0 -67
- package/flows/nax-finish/steps/context.ts +0 -158
- package/flows/nax-finish/steps/escalate.ts +0 -93
- package/flows/nax-finish/steps/forge.ts +0 -93
- package/flows/nax-finish/steps/gates.ts +0 -183
- package/flows/nax-finish/steps/git.ts +0 -130
- package/flows/nax-finish/steps/index.ts +0 -13
- package/flows/nax-finish/steps/pr-body.ts +0 -462
- package/flows/nax-finish/steps/pr-narrative.ts +0 -46
- package/flows/nax-finish/steps/pr.ts +0 -116
- package/flows/nax-finish/steps/quality.ts +0 -102
- package/flows/nax-finish/steps/result.ts +0 -112
- package/flows/nax-finish/steps/review-audit.ts +0 -91
- package/flows/nax-finish/steps/review-round.ts +0 -109
- package/flows/nax-finish/types.ts +0 -260
- package/flows/nax-finish/verdict.ts +0 -210
|
@@ -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
|
-
}
|