@nathapp/nax 0.75.6 → 0.77.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/dist/nax.js +6292 -3809
- package/flows/nax-finish/commit-message.ts +136 -0
- package/flows/nax-finish/flow-ctx.ts +107 -0
- package/flows/nax-finish/narrative.ts +133 -0
- package/flows/nax-finish/nax-finish.flow.ts +209 -86
- package/flows/nax-finish/pr-template.ts +56 -0
- package/flows/nax-finish/review-prompts.ts +62 -4
- package/flows/nax-finish/steps/context.ts +38 -0
- package/flows/nax-finish/steps/git.ts +26 -3
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +345 -0
- package/flows/nax-finish/steps/pr-narrative.ts +43 -0
- package/flows/nax-finish/steps/pr.ts +48 -3
- package/flows/nax-finish/steps/result.ts +99 -10
- package/flows/nax-finish/types.ts +61 -4
- package/flows/nax-finish/verdict.ts +159 -0
- package/package.json +1 -1
|
@@ -58,12 +58,35 @@ async function isDirty(repoRoot: string): Promise<boolean> {
|
|
|
58
58
|
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
59
|
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
60
|
*/
|
|
61
|
+
/** Current HEAD sha, or null outside a repo / on an unborn branch. */
|
|
62
|
+
async function headSha(repoRoot: string): Promise<string | null> {
|
|
63
|
+
const res = await _gitDeps.run(["git", "rev-parse", "HEAD"], { cwd: repoRoot });
|
|
64
|
+
return res.exitCode === 0 ? res.stdout.trim() || null : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Repo-root-relative paths touched by a commit.
|
|
69
|
+
*
|
|
70
|
+
* `--format=` suppresses the header so the output is just the file list.
|
|
71
|
+
* Failure yields `[]`, which the gate loop reads as "cannot tell what changed"
|
|
72
|
+
* and therefore reviews — see `partitionTestFiles`.
|
|
73
|
+
*/
|
|
74
|
+
export async function filesInCommit(repoRoot: string, sha: string): Promise<string[]> {
|
|
75
|
+
const res = await _gitDeps.run(["git", "show", "--name-only", "--format=", sha], { cwd: repoRoot });
|
|
76
|
+
if (res.exitCode !== 0) return [];
|
|
77
|
+
return res.stdout
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((l) => l.trim())
|
|
80
|
+
.filter((l) => l.length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
export async function commitFixes(
|
|
62
84
|
repoRoot: string,
|
|
63
85
|
message: string,
|
|
64
86
|
opts: { skipHooks?: boolean } = {},
|
|
65
|
-
): Promise<{ committed: boolean }> {
|
|
66
|
-
|
|
87
|
+
): Promise<{ committed: boolean; shaBefore: string | null; shaAfter: string | null }> {
|
|
88
|
+
const shaBefore = await headSha(repoRoot);
|
|
89
|
+
if (!(await isDirty(repoRoot))) return { committed: false, shaBefore, shaAfter: shaBefore };
|
|
67
90
|
|
|
68
91
|
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
69
92
|
if (add.exitCode !== 0) {
|
|
@@ -82,7 +105,7 @@ export async function commitFixes(
|
|
|
82
105
|
{ stage: "finish-git", repoRoot },
|
|
83
106
|
);
|
|
84
107
|
}
|
|
85
|
-
return { committed: true };
|
|
108
|
+
return { committed: true, shaBefore, shaAfter: await headSha(repoRoot) };
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
/**
|
|
@@ -0,0 +1,345 @@
|
|
|
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 — the title matches
|
|
9
|
+
* `src/plugins/builtin/auto-pr/pr-body.ts:buildTitle`, and the body is
|
|
10
|
+
* assembled by string joins over the fields in `FinishPrContext`. No model
|
|
11
|
+
* call: every section is reproducible from artifacts that exist before
|
|
12
|
+
* `open_pr` runs, and so the body stays greppable in PR history.
|
|
13
|
+
*
|
|
14
|
+
* Reimplemented here (rather than imported from `src/`) because `flows/`
|
|
15
|
+
* ships to a different runtime — `acpx flow run` runs it in acpx's own Node
|
|
16
|
+
* process where nax's `src/` and its `@/*` alias are not available.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
20
|
+
import { runArgv } from "../exec";
|
|
21
|
+
import { readSpecSummary, resolveNarrative } from "../narrative";
|
|
22
|
+
import { findPrTemplate } from "../pr-template";
|
|
23
|
+
import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
|
|
24
|
+
import type { Forge } from "./forge";
|
|
25
|
+
import { readRounds } from "./result";
|
|
26
|
+
|
|
27
|
+
const SECONDS_PER_MINUTE = 60;
|
|
28
|
+
const MS_PER_SECOND = 1000;
|
|
29
|
+
|
|
30
|
+
/** Six hex + one — the abbreviated form used everywhere in PR bodies and logs. */
|
|
31
|
+
const SHORT_SHA_LEN = 7;
|
|
32
|
+
|
|
33
|
+
/** One row in the Stories table. */
|
|
34
|
+
export interface FinishPrStory {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
acCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Everything `open_pr` renders, sourced from finish-audit artifacts. */
|
|
41
|
+
export interface FinishPrContext {
|
|
42
|
+
feature: string;
|
|
43
|
+
stories: FinishPrStory[];
|
|
44
|
+
outOfScope: string[];
|
|
45
|
+
acceptance?: string;
|
|
46
|
+
regression?: string;
|
|
47
|
+
gatesRan: string[];
|
|
48
|
+
diffstat?: string;
|
|
49
|
+
/** Repository PR/MR template, verbatim. Absent when none resolves. */
|
|
50
|
+
template?: string;
|
|
51
|
+
/** Resolved "What changed" prose. Absent when neither source produced text. */
|
|
52
|
+
narrative?: string;
|
|
53
|
+
rounds: FinishRound[];
|
|
54
|
+
run: {
|
|
55
|
+
durationMs?: number;
|
|
56
|
+
storiesPassed?: number;
|
|
57
|
+
storiesTotal?: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const _prBodyDeps: {
|
|
62
|
+
run: RunFn;
|
|
63
|
+
readText: (path: string) => Promise<string | null>;
|
|
64
|
+
warn: (message: string, details: { path: string; error: unknown }) => void;
|
|
65
|
+
} = {
|
|
66
|
+
run: runArgv,
|
|
67
|
+
// ENOENT is the routine case (status.json/prd.json not yet written) and must
|
|
68
|
+
// stay silent — mirrors `_qualityDeps.readText` in `steps/quality.ts`. Only a
|
|
69
|
+
// genuine I/O failure (permission denied, corrupted mount) should warn.
|
|
70
|
+
readText: async (path) => {
|
|
71
|
+
try {
|
|
72
|
+
return await readFile(path, "utf8");
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
warn: (message, details) => process.emitWarning(message, { detail: `${details.path}: ${String(details.error)}` }),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
interface PrdArtifact {
|
|
82
|
+
userStories?: { id: string; title: string; acceptanceCriteria?: unknown[] }[];
|
|
83
|
+
outOfScope?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface StatusArtifact {
|
|
87
|
+
postRun?: { acceptance?: { status?: string }; regression?: { status?: string } };
|
|
88
|
+
durationMs?: number;
|
|
89
|
+
progress?: { passed?: number; total?: number };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readJson(path: string): Promise<unknown> {
|
|
93
|
+
let text: string | null;
|
|
94
|
+
try {
|
|
95
|
+
text = await _prBodyDeps.readText(path);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
_prBodyDeps.warn("[finish-pr] Failed to read PR context artifact", { path, error });
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (text === null) return undefined;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(text);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
_prBodyDeps.warn("[finish-pr] Failed to parse PR context artifact", { path, error });
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
|
|
110
|
+
if (!Array.isArray(prd?.userStories)) return [];
|
|
111
|
+
// A hand-edited or older-schema PRD can carry a story with a missing/non-string
|
|
112
|
+
// `id`/`title` — drop only that row rather than letting `escapeTableCell` throw
|
|
113
|
+
// and take down the entire PR body (caught upstream by `open_pr`'s fallback).
|
|
114
|
+
return prd.userStories
|
|
115
|
+
.filter((story) => typeof story.id === "string" && typeof story.title === "string")
|
|
116
|
+
.map((story) => ({
|
|
117
|
+
id: story.id,
|
|
118
|
+
title: story.title,
|
|
119
|
+
acCount: Array.isArray(story.acceptanceCriteria) ? story.acceptanceCriteria.length : 0,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run `git diff --stat <base>...HEAD` and return its stdout on success.
|
|
125
|
+
*
|
|
126
|
+
* Fail-open on every non-happy path — a non-zero exit (no commits, divergent
|
|
127
|
+
* branch, base missing), a rejected run promise (forks too slow to start), or
|
|
128
|
+
* any thrown error — returning `undefined`. The PR's Verification block is
|
|
129
|
+
* optional, and a routine empty-branch finish must not lose `open_pr` to a
|
|
130
|
+
* throw that the body can simply skip.
|
|
131
|
+
*/
|
|
132
|
+
async function runDiffstat(workdir: string, base: string): Promise<string | undefined> {
|
|
133
|
+
// An empty `base` would interpolate to `...HEAD`, which git resolves as
|
|
134
|
+
// `HEAD...HEAD` — exit 0, empty stdout — masking the missing-base case as
|
|
135
|
+
// "no changes" instead of skipping explicitly.
|
|
136
|
+
if (!base) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
const res = await _prBodyDeps.run(["git", "diff", "--stat", `${base}...HEAD`], { cwd: workdir });
|
|
139
|
+
if (res.exitCode !== 0) return undefined;
|
|
140
|
+
return res.stdout;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the repository's PR/MR template, fail-open.
|
|
148
|
+
*
|
|
149
|
+
* An absent template is the common case and never warns. A genuine read failure
|
|
150
|
+
* is swallowed too: the body is useful without this section, and `open_pr` must
|
|
151
|
+
* not lose a PR to a permissions error on a file most repos do not have.
|
|
152
|
+
*/
|
|
153
|
+
async function loadTemplate(workdir: string, forge: Forge | undefined): Promise<string | undefined> {
|
|
154
|
+
if (forge === undefined) return undefined;
|
|
155
|
+
try {
|
|
156
|
+
return (await findPrTemplate(workdir, forge, { readText: _prBodyDeps.readText })) ?? undefined;
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function loadFinishPrContext(
|
|
163
|
+
input: FinishInput,
|
|
164
|
+
args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string },
|
|
165
|
+
): Promise<FinishPrContext> {
|
|
166
|
+
const inputPrdPath = input.prdPath || "prd.json";
|
|
167
|
+
const prdPath = isAbsolute(inputPrdPath) ? inputPrdPath : join(input.workdir, inputPrdPath);
|
|
168
|
+
// [US-004] The audit trail (`rounds`), the diffstat, and the spec summary
|
|
169
|
+
// are independent of the PRD/status reads — fetching them in parallel keeps
|
|
170
|
+
// the loader's wall clock at max(readRounds, readJson×2, diffstat, spec).
|
|
171
|
+
const [prd, status, rounds, diffstat, template, specSummary] = (await Promise.all([
|
|
172
|
+
readJson(prdPath),
|
|
173
|
+
readJson(join(dirname(prdPath), "status.json")),
|
|
174
|
+
readRounds(input),
|
|
175
|
+
runDiffstat(input.workdir, args.base),
|
|
176
|
+
loadTemplate(input.workdir, args.forge),
|
|
177
|
+
readSpecSummary(args.specPath, _prBodyDeps.readText),
|
|
178
|
+
])) as [
|
|
179
|
+
PrdArtifact | undefined,
|
|
180
|
+
StatusArtifact | undefined,
|
|
181
|
+
FinishRound[],
|
|
182
|
+
string | undefined,
|
|
183
|
+
string | undefined,
|
|
184
|
+
string | null,
|
|
185
|
+
];
|
|
186
|
+
return {
|
|
187
|
+
feature: input.feature,
|
|
188
|
+
stories: storiesFrom(prd),
|
|
189
|
+
outOfScope: Array.isArray(prd?.outOfScope) ? prd.outOfScope : [],
|
|
190
|
+
acceptance: status?.postRun?.acceptance?.status,
|
|
191
|
+
regression: status?.postRun?.regression?.status,
|
|
192
|
+
gatesRan: args.gatesRan,
|
|
193
|
+
rounds,
|
|
194
|
+
diffstat,
|
|
195
|
+
template,
|
|
196
|
+
narrative: resolveNarrative(args.narrative, specSummary),
|
|
197
|
+
run: {
|
|
198
|
+
durationMs: status?.durationMs,
|
|
199
|
+
storiesPassed: status?.progress?.passed,
|
|
200
|
+
storiesTotal: status?.progress?.total,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Conventional-commit title matching `buildTitle` in
|
|
207
|
+
* `src/plugins/builtin/auto-pr/pr-body.ts`, so finish-opened and
|
|
208
|
+
* auto-PR-opened PRs read the same in a list view.
|
|
209
|
+
*/
|
|
210
|
+
export function buildFinishTitle(ctx: FinishPrContext): string {
|
|
211
|
+
return `feat: ${ctx.feature}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Escape a string for safe inclusion in a single markdown table cell.
|
|
216
|
+
*
|
|
217
|
+
* Mirrors `escapeTableCell` in `src/plugins/builtin/auto-pr/pr-body.ts`,
|
|
218
|
+
* trimmed to the cases the finish body actually needs: pipes (which break
|
|
219
|
+
* the column boundary) and newlines (which create new rows). Backslashes are
|
|
220
|
+
* escaped first so the pipe escape survives a literal backslash in a title.
|
|
221
|
+
*/
|
|
222
|
+
function escapeTableCell(value: string): string {
|
|
223
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatDuration(durationMs: number): string {
|
|
227
|
+
// `Math.max(0, NaN)` returns NaN, and `Math.floor(Infinity / 1000)` returns
|
|
228
|
+
// Infinity — both would render as `"NaNm NaNs"` / `"Infinitym Infinitys"`.
|
|
229
|
+
// A non-finite duration is a corrupted artifact (status.json is hand-editable),
|
|
230
|
+
// so fall back to zero rather than let it leak into the PR body verbatim.
|
|
231
|
+
if (!Number.isFinite(durationMs)) return "0m 00s";
|
|
232
|
+
const clampedMs = Math.max(0, Math.round(durationMs));
|
|
233
|
+
const totalSeconds = Math.floor(clampedMs / MS_PER_SECOND);
|
|
234
|
+
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
|
235
|
+
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
|
236
|
+
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildStoriesSection(stories: FinishPrStory[]): string {
|
|
240
|
+
const lines: string[] = [];
|
|
241
|
+
lines.push("## Stories");
|
|
242
|
+
lines.push("| Story | Title | ACs |");
|
|
243
|
+
lines.push("|-------|-------|-----|");
|
|
244
|
+
for (const story of stories) {
|
|
245
|
+
lines.push(`| ${escapeTableCell(story.id)} | ${escapeTableCell(story.title)} | ${story.acCount} |`);
|
|
246
|
+
}
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildVerificationSection(
|
|
251
|
+
acceptance: string | undefined,
|
|
252
|
+
regression: string | undefined,
|
|
253
|
+
gatesRan: string[],
|
|
254
|
+
diffstat: string | undefined,
|
|
255
|
+
): string | null {
|
|
256
|
+
const lines: string[] = ["## Verification"];
|
|
257
|
+
if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
|
|
258
|
+
if (regression !== undefined) lines.push(`- Regression: ${regression}`);
|
|
259
|
+
if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
|
|
260
|
+
if (diffstat !== undefined && diffstat.length > 0) lines.push(`- Diffstat:\n\n\`\`\`\n${diffstat}\n\`\`\``);
|
|
261
|
+
if (lines.length === 1) return null;
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildRoundHeading(round: FinishRound): string {
|
|
266
|
+
const base = `### ${round.phase} attempt ${round.attempt}`;
|
|
267
|
+
if (!round.committed || !round.sha) return base;
|
|
268
|
+
const short = round.sha.slice(0, SHORT_SHA_LEN);
|
|
269
|
+
return `${base} (${short})`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildRoundBlock(round: FinishRound): string {
|
|
273
|
+
const lines: string[] = [buildRoundHeading(round)];
|
|
274
|
+
if (round.findings.length === 0) {
|
|
275
|
+
lines.push("- _no findings_");
|
|
276
|
+
} else {
|
|
277
|
+
for (const finding of round.findings) lines.push(renderFinding(finding));
|
|
278
|
+
}
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildRoundsSection(rounds: FinishRound[]): string | null {
|
|
283
|
+
if (rounds.length === 0) return null;
|
|
284
|
+
const blocks = rounds.map(buildRoundBlock);
|
|
285
|
+
return ["## Review rounds", ...blocks].join("\n\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function renderFinding(finding: Finding): string {
|
|
289
|
+
return `- [${finding.severity}] ${finding.title}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Heading and text are produced together, so "no text" cannot render a bare
|
|
294
|
+
* `## What changed` heading — the empty-heading case #1477 forbids.
|
|
295
|
+
*/
|
|
296
|
+
function buildNarrativeSection(narrative: string | undefined): string | null {
|
|
297
|
+
const text = narrative?.trim();
|
|
298
|
+
if (!text) return null;
|
|
299
|
+
return ["## What changed", text].join("\n\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildOutOfScopeSection(outOfScope: string[]): string | null {
|
|
303
|
+
if (outOfScope.length === 0) return null;
|
|
304
|
+
const lines: string[] = ["## Out of scope"];
|
|
305
|
+
for (const item of outOfScope) lines.push(`- ${item}`);
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildFooter(run: FinishPrContext["run"]): string | null {
|
|
310
|
+
const { storiesPassed, storiesTotal, durationMs } = run;
|
|
311
|
+
if (storiesPassed === undefined && storiesTotal === undefined && durationMs === undefined) return null;
|
|
312
|
+
const counts =
|
|
313
|
+
storiesPassed !== undefined && storiesTotal !== undefined ? `${storiesPassed}/${storiesTotal} stories` : null;
|
|
314
|
+
const timing = durationMs !== undefined ? formatDuration(durationMs) : null;
|
|
315
|
+
const parts = [counts, timing].filter((p): p is string => p !== null);
|
|
316
|
+
if (parts.length === 0) return null;
|
|
317
|
+
return parts.join(" · ");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function buildFinishBody(ctx: FinishPrContext): string {
|
|
321
|
+
const sections: string[] = [];
|
|
322
|
+
|
|
323
|
+
const narrativeSection = buildNarrativeSection(ctx.narrative);
|
|
324
|
+
if (narrativeSection !== null) sections.push(narrativeSection);
|
|
325
|
+
|
|
326
|
+
if (ctx.stories.length > 0) sections.push(buildStoriesSection(ctx.stories));
|
|
327
|
+
|
|
328
|
+
const verification = buildVerificationSection(ctx.acceptance, ctx.regression, ctx.gatesRan, ctx.diffstat);
|
|
329
|
+
if (verification !== null) sections.push(verification);
|
|
330
|
+
|
|
331
|
+
const roundsSection = buildRoundsSection(ctx.rounds);
|
|
332
|
+
if (roundsSection !== null) sections.push(roundsSection);
|
|
333
|
+
|
|
334
|
+
const outOfScopeSection = buildOutOfScopeSection(ctx.outOfScope);
|
|
335
|
+
if (outOfScopeSection !== null) sections.push(outOfScopeSection);
|
|
336
|
+
|
|
337
|
+
const footer = buildFooter(ctx.run);
|
|
338
|
+
if (footer !== null) sections.push(footer);
|
|
339
|
+
|
|
340
|
+
// Appended last and verbatim: `gh` / `glab` suppress the repo's own template
|
|
341
|
+
// whenever `--body` / `--description` is passed, so it has to be re-embedded.
|
|
342
|
+
if (ctx.template !== undefined && ctx.template.trim().length > 0) sections.push(ctx.template.trim());
|
|
343
|
+
|
|
344
|
+
return sections.join("\n\n");
|
|
345
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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 } 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
|
+
// Nothing to add: the body already in place is correct, and rewriting it
|
|
23
|
+
// identically would spend a forge call to change nothing.
|
|
24
|
+
if (!narrative) return { route: "done", amended: false };
|
|
25
|
+
|
|
26
|
+
const i = inputOf(ctx);
|
|
27
|
+
const loadCtx = loadCtxOf(ctx);
|
|
28
|
+
try {
|
|
29
|
+
const forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
|
|
30
|
+
const prCtx = await loadFinishPrContext(i, {
|
|
31
|
+
base: loadCtx.base ?? "",
|
|
32
|
+
gatesRan: gateOutputs(ctx).ran ?? [],
|
|
33
|
+
forge,
|
|
34
|
+
specPath: loadCtx.specPath,
|
|
35
|
+
narrative,
|
|
36
|
+
});
|
|
37
|
+
await updatePrBody(forge, i.workdir, i.branch, buildFinishTitle(prCtx), buildFinishBody(prCtx));
|
|
38
|
+
return { route: "done", amended: true };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
_prBodyDeps.warn("[finish-pr] Failed to amend the PR body with the narrative", { path: i.branch, error });
|
|
41
|
+
return { route: "done", amended: false };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { FinishError } from "../errors";
|
|
2
|
-
import { runArgv } from "../exec";
|
|
3
2
|
import type { RunFn } from "../types";
|
|
4
3
|
import { type Forge, detectForge, extractUrl, viewArgv } from "./forge";
|
|
4
|
+
import { _prBodyDeps, loadFinishPrContext } from "./pr-body";
|
|
5
5
|
|
|
6
|
-
|
|
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;
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Parse `gh pr view --json isDraft,url` / `glab mr view --output json` stdout.
|
|
@@ -33,8 +43,12 @@ export async function openOrPromotePr(
|
|
|
33
43
|
branch: string,
|
|
34
44
|
title: string,
|
|
35
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,
|
|
36
50
|
): Promise<{ status: "opened" | "promoted" | "already-ready"; url?: string }> {
|
|
37
|
-
const forge = await detectForge(_prDeps.run, repoRoot, "finish-pr");
|
|
51
|
+
const forge = knownForge ?? (await detectForge(_prDeps.run, repoRoot, "finish-pr"));
|
|
38
52
|
const view = await _prDeps.run(viewArgv(forge, branch, "isDraft,url"), { cwd: repoRoot });
|
|
39
53
|
|
|
40
54
|
if (view.exitCode !== 0) {
|
|
@@ -64,8 +78,39 @@ export async function openOrPromotePr(
|
|
|
64
78
|
{ stage: "finish-pr", branch },
|
|
65
79
|
);
|
|
66
80
|
}
|
|
81
|
+
await updatePrBody(forge, repoRoot, branch, title, body);
|
|
67
82
|
return { status: "promoted", url };
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
await updatePrBody(forge, repoRoot, branch, title, body);
|
|
70
86
|
return { status: "already-ready", url };
|
|
71
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
|
+
}
|
|
@@ -1,23 +1,112 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Finish-audit artifacts.
|
|
3
|
+
*
|
|
4
|
+
* These live under nax's global per-project output directory —
|
|
5
|
+
* `~/.nax/<project>/finish-audit/<feature>/` — alongside `prompt-audit/` and
|
|
6
|
+
* `review-audit/`, not in the user's repo. Two reasons the repo was the wrong
|
|
7
|
+
* home: the artifact describes a *run*, not the source tree, so committing it
|
|
8
|
+
* and gitignoring it are both wrong answers; and a per-feature, per-run path
|
|
9
|
+
* makes the history queryable across runs, which a single overwritten
|
|
10
|
+
* `.nax/nax-finish-result.json` never was.
|
|
11
|
+
*
|
|
12
|
+
* The plugin supplies `auditDir` because it owns nax's path SSOT
|
|
13
|
+
* (`src/runtime/paths.ts`), which this module may not import — `flows/` is
|
|
14
|
+
* loaded by acpx, outside nax's own process. Absent, we fall back to a
|
|
15
|
+
* repo-local directory so a hand-run `acpx flow run` still records something.
|
|
16
|
+
*
|
|
17
|
+
* Two files per run:
|
|
18
|
+
* - `<runId>.jsonl` — one line per fix round, appended as it happens
|
|
19
|
+
* - `<runId>.result.json` — the terminal result the plugin reads back
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import type { FinishInput, FinishResult, FinishRound } from "../types";
|
|
4
24
|
|
|
5
|
-
|
|
6
|
-
|
|
25
|
+
/** Used when the plugin supplied no run id (e.g. a hand-run `acpx flow run`). */
|
|
26
|
+
const FALLBACK_RUN_ID = "run";
|
|
27
|
+
|
|
28
|
+
type AuditTarget = Pick<FinishInput, "auditDir" | "workdir" | "feature" | "runId">;
|
|
29
|
+
|
|
30
|
+
export function resolveAuditDir(input: AuditTarget): string {
|
|
31
|
+
return input.auditDir ?? join(input.workdir, ".nax", "finish-audit", input.feature);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resultPath(input: AuditTarget): string {
|
|
35
|
+
return join(resolveAuditDir(input), `${input.runId || FALLBACK_RUN_ID}.result.json`);
|
|
7
36
|
}
|
|
8
37
|
|
|
9
|
-
export
|
|
38
|
+
export function roundsPath(input: AuditTarget): string {
|
|
39
|
+
return join(resolveAuditDir(input), `${input.runId || FALLBACK_RUN_ID}.jsonl`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const _resultDeps: {
|
|
43
|
+
writeText: (p: string, s: string) => Promise<void>;
|
|
44
|
+
appendText: (p: string, s: string) => Promise<void>;
|
|
45
|
+
readText: (p: string) => Promise<string | null>;
|
|
46
|
+
} = {
|
|
10
47
|
// node:fs, not Bun.write — this module runs inside acpx's Node process, where
|
|
11
48
|
// the `Bun` global does not exist (see the header of `../exec.ts`). The mkdir
|
|
12
49
|
// is not redundant: Bun.write creates missing parent directories implicitly,
|
|
13
|
-
// writeFile does not, and
|
|
14
|
-
//
|
|
50
|
+
// writeFile does not, and the audit directory now lives under `~/.nax/`,
|
|
51
|
+
// where for a project's first run nothing on the path exists yet.
|
|
15
52
|
writeText: async (p, s) => {
|
|
16
53
|
await mkdir(dirname(p), { recursive: true });
|
|
17
54
|
await writeFile(p, s, "utf8");
|
|
18
55
|
},
|
|
56
|
+
appendText: async (p, s) => {
|
|
57
|
+
await mkdir(dirname(p), { recursive: true });
|
|
58
|
+
await writeFile(p, s, { encoding: "utf8", flag: "a" });
|
|
59
|
+
},
|
|
60
|
+
readText: async (p) => {
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(p, "utf8");
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
19
67
|
};
|
|
20
68
|
|
|
21
|
-
|
|
22
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Append one fix round to the run's audit trail.
|
|
71
|
+
*
|
|
72
|
+
* Best-effort: an unwritable audit directory must not take the flow down
|
|
73
|
+
* mid-loop. The round is a record of work already done — losing the record is
|
|
74
|
+
* bad, losing the run that did the work is worse.
|
|
75
|
+
*/
|
|
76
|
+
export async function appendRound(input: AuditTarget, round: FinishRound): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await _resultDeps.appendText(roundsPath(input), `${JSON.stringify(round)}\n`);
|
|
79
|
+
} catch {
|
|
80
|
+
// Intentionally swallowed — see the doc comment above.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read back every round recorded for this run, so a terminal result can embed them. */
|
|
85
|
+
export async function readRounds(input: AuditTarget): Promise<FinishRound[]> {
|
|
86
|
+
const raw = await _resultDeps.readText(roundsPath(input));
|
|
87
|
+
if (!raw) return [];
|
|
88
|
+
const rounds: FinishRound[] = [];
|
|
89
|
+
for (const line of raw.split("\n")) {
|
|
90
|
+
if (!line.trim()) continue;
|
|
91
|
+
try {
|
|
92
|
+
rounds.push(JSON.parse(line) as FinishRound);
|
|
93
|
+
} catch {
|
|
94
|
+
// A torn final line (killed mid-write) must not lose the rounds before it.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return rounds;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write the terminal result, embedding every round this run recorded.
|
|
102
|
+
*
|
|
103
|
+
* Rounds are attached on *every* status, not just `escalated`: a finish that
|
|
104
|
+
* succeeded after four rounds is precisely the case worth auditing — it says
|
|
105
|
+
* the run's own review gates missed four defects — and it was the one case
|
|
106
|
+
* that previously recorded nothing at all.
|
|
107
|
+
*/
|
|
108
|
+
export async function writeResult(input: AuditTarget, result: FinishResult): Promise<void> {
|
|
109
|
+
const rounds = await readRounds(input);
|
|
110
|
+
const withRounds: FinishResult = rounds.length > 0 ? { ...result, rounds } : result;
|
|
111
|
+
await _resultDeps.writeText(resultPath(input), `${JSON.stringify(withRounds, null, 2)}\n`);
|
|
23
112
|
}
|