@nathapp/nax 0.75.3 → 0.75.5
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 +1050 -360
- package/flows/nax-finish/nax-finish.flow.ts +155 -16
- package/flows/nax-finish/steps/acceptance.ts +28 -5
- package/flows/nax-finish/steps/forge.ts +55 -6
- package/flows/nax-finish/steps/git.ts +61 -27
- package/flows/nax-finish/types.ts +14 -0
- package/package.json +1 -1
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
* - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
|
|
18
18
|
* resolve` once, and its output feeds both the review prompts (specPath) and
|
|
19
19
|
* the acceptance gate (groups), so nothing resolves the feature twice.
|
|
20
|
-
* - Review fixes loop: `review_* → route_* → fix_* → (re-run
|
|
21
|
-
* re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
-
* trips. A single-shot fix left the fixed diff unverified.
|
|
20
|
+
* - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
|
|
21
|
+
* acceptance | re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
+
* the fix cap trips. A single-shot fix left the fixed diff unverified.
|
|
23
|
+
* - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
|
|
24
|
+
* `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
|
|
25
|
+
* re-review: the loop re-reported findings that were already fixed and always
|
|
26
|
+
* escalated at the cap (issue #1397).
|
|
23
27
|
* - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
|
|
24
28
|
* enforced deterministically rather than trusting the model's own route.
|
|
29
|
+
* - `quality_gates` re-runs the feature's acceptance tests before the repo's
|
|
30
|
+
* own commands. The quality-review and gate fix loops both edit code after
|
|
31
|
+
* the `acceptance` node last passed, and the repo-root `test` command does
|
|
32
|
+
* not cover per-feature acceptance tests — so without this a fix could break
|
|
33
|
+
* the contract the first gate proved and still ship.
|
|
25
34
|
*/
|
|
26
35
|
import { defineFlow, extractJsonObject } from "acpx/flows";
|
|
27
36
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
_contextDeps,
|
|
30
39
|
buildEscalationComment,
|
|
31
40
|
commitAndPush,
|
|
41
|
+
commitFixes,
|
|
32
42
|
detectBaseBranch,
|
|
33
43
|
loadQualityCommands,
|
|
34
44
|
openOrPromotePr,
|
|
@@ -39,7 +49,7 @@ import {
|
|
|
39
49
|
runQualityGates,
|
|
40
50
|
writeResult,
|
|
41
51
|
} from "./steps";
|
|
42
|
-
import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
|
|
52
|
+
import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
|
|
43
53
|
|
|
44
54
|
const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
45
55
|
|
|
@@ -55,6 +65,8 @@ interface LoadCtxOutput {
|
|
|
55
65
|
base?: string;
|
|
56
66
|
specPath?: string;
|
|
57
67
|
groups?: AcceptanceGroup[];
|
|
68
|
+
/** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
|
|
69
|
+
acceptanceStatus?: string;
|
|
58
70
|
route?: string;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -66,16 +78,48 @@ function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
|
|
|
66
78
|
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
83
|
+
*
|
|
84
|
+
* "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
|
|
85
|
+
* unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
|
|
86
|
+
* `no-prd` and `disabled`, and reports `exists: false` for a group whose test
|
|
87
|
+
* was expected at its canonical path but never generated. Treating all of those
|
|
88
|
+
* as green let the flow open a ready PR having verified nothing about the
|
|
89
|
+
* feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
|
|
90
|
+
* — skips cleanly.
|
|
91
|
+
*/
|
|
70
92
|
async function acceptanceGateNode(ctx: {
|
|
71
93
|
input: unknown;
|
|
72
94
|
outputs: unknown;
|
|
73
95
|
state: { steps: { nodeId: string }[] };
|
|
74
96
|
}): Promise<{ route: string; reason?: string; output: string }> {
|
|
75
97
|
const i = inputOf(ctx);
|
|
76
|
-
const groups = loadCtxOf(ctx)
|
|
98
|
+
const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
|
|
99
|
+
if (acceptanceStatus === "disabled") {
|
|
100
|
+
return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
|
|
101
|
+
}
|
|
102
|
+
if (acceptanceStatus === "no-prd") {
|
|
103
|
+
return {
|
|
104
|
+
route: "escalate",
|
|
105
|
+
reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
|
|
106
|
+
output: "[acceptance] no prd.json resolved — acceptance targets unknown",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
78
|
-
if (r.passed)
|
|
111
|
+
if (r.passed) {
|
|
112
|
+
// A real failure below routes to the fix loop, which is more actionable;
|
|
113
|
+
// the coverage hole is only reported once the runnable groups are green.
|
|
114
|
+
if (r.missing.length > 0) {
|
|
115
|
+
return {
|
|
116
|
+
route: "escalate",
|
|
117
|
+
reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
|
|
118
|
+
output: r.output,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { route: "proceed", output: r.output };
|
|
122
|
+
}
|
|
79
123
|
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
80
124
|
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
81
125
|
return {
|
|
@@ -119,6 +163,26 @@ function routeReview(
|
|
|
119
163
|
return { route: "fix", findings };
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
168
|
+
*
|
|
169
|
+
* One node per phase rather than a single shared one because each returns to a
|
|
170
|
+
* different successor, and acpx routes on the node id — a shared node would
|
|
171
|
+
* need a switch reconstructing which fix ran from the step history.
|
|
172
|
+
*/
|
|
173
|
+
function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
|
|
174
|
+
return {
|
|
175
|
+
nodeType: "action" as const,
|
|
176
|
+
async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
|
|
177
|
+
const i = inputOf(ctx);
|
|
178
|
+
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
179
|
+
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
180
|
+
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
181
|
+
return commitFixes(i.workdir, `fix(${i.feature}): nax-finish ${phase} fixes`, { skipHooks: true });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
123
187
|
function parseVerdict(text: string): ReviewVerdict {
|
|
124
188
|
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
@@ -162,6 +226,7 @@ export default defineFlow({
|
|
|
162
226
|
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
163
227
|
parse: parseVerdict,
|
|
164
228
|
},
|
|
229
|
+
commit_acceptance: commitFixNode("acceptance"),
|
|
165
230
|
review_spec: {
|
|
166
231
|
nodeType: "acp",
|
|
167
232
|
session: { isolated: true },
|
|
@@ -181,6 +246,7 @@ export default defineFlow({
|
|
|
181
246
|
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
182
247
|
parse: parseVerdict,
|
|
183
248
|
},
|
|
249
|
+
commit_spec: commitFixNode("spec"),
|
|
184
250
|
review_quality: {
|
|
185
251
|
nodeType: "acp",
|
|
186
252
|
session: { isolated: true },
|
|
@@ -200,15 +266,57 @@ export default defineFlow({
|
|
|
200
266
|
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
201
267
|
parse: parseVerdict,
|
|
202
268
|
},
|
|
269
|
+
commit_quality: commitFixNode("quality"),
|
|
203
270
|
fix_gate: {
|
|
204
271
|
nodeType: "acp",
|
|
205
272
|
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
206
273
|
parse: parseVerdict,
|
|
207
274
|
},
|
|
275
|
+
commit_gate: commitFixNode("gate"),
|
|
208
276
|
quality_gates: {
|
|
209
277
|
nodeType: "action",
|
|
210
278
|
async run(ctx) {
|
|
211
279
|
const i = inputOf(ctx);
|
|
280
|
+
|
|
281
|
+
// Acceptance is gate zero here, not just at the `acceptance` node.
|
|
282
|
+
// Both fix loops that run after it — quality review and this gate —
|
|
283
|
+
// edit code, and the repo-root `test` command does not cover the
|
|
284
|
+
// feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
|
|
285
|
+
// and usually need their own runner config. Re-running them here is what
|
|
286
|
+
// makes "nothing reaches open_pr without the feature's own contract
|
|
287
|
+
// passing against the tree as it will ship" true on every path (#1398).
|
|
288
|
+
//
|
|
289
|
+
// Unconditional, though the common green path re-runs a gate that
|
|
290
|
+
// already passed: acceptance is the cheapest gate in the pipeline, and a
|
|
291
|
+
// conditional skip derived from step history would be a check that can
|
|
292
|
+
// be *wrong* — a silent false green, the failure mode this exists to
|
|
293
|
+
// prevent.
|
|
294
|
+
//
|
|
295
|
+
// `missing` is deliberately ignored: groups are resolved once at
|
|
296
|
+
// load_ctx, so a coverage hole was already escalated by the acceptance
|
|
297
|
+
// node and cannot appear here.
|
|
298
|
+
const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
|
|
299
|
+
timeoutMs: i.timeouts?.acceptanceMs,
|
|
300
|
+
});
|
|
301
|
+
if (!acc.passed) {
|
|
302
|
+
// Short-circuit: the repo gates are re-run next round anyway, and
|
|
303
|
+
// skipping them keeps this out of the "nothing configured" branch
|
|
304
|
+
// below, which would otherwise misreport configured-but-skipped
|
|
305
|
+
// commands as absent.
|
|
306
|
+
const accAttempts = fixAttemptCount(ctx, "fix_gate");
|
|
307
|
+
const failing = ["acceptance"];
|
|
308
|
+
if (accAttempts >= MAX_FIX_ATTEMPTS) {
|
|
309
|
+
return {
|
|
310
|
+
route: "escalate",
|
|
311
|
+
reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
|
|
312
|
+
ran: [],
|
|
313
|
+
failing,
|
|
314
|
+
output: acc.output,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { route: "fix", ran: [], failing, output: acc.output };
|
|
318
|
+
}
|
|
319
|
+
|
|
212
320
|
const cmds = await loadQualityCommands(i.workdir);
|
|
213
321
|
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
214
322
|
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
@@ -289,12 +397,36 @@ export default defineFlow({
|
|
|
289
397
|
syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
|
|
290
398
|
}
|
|
291
399
|
|
|
400
|
+
// Write the result BEFORE attempting delivery. Delivery touches the
|
|
401
|
+
// network and the forge — a rate limit, an expired token, a locked PR
|
|
402
|
+
// or an unrecognised remote used to throw here, killing the node before
|
|
403
|
+
// any result existed. The plugin then had nothing to report and, on the
|
|
404
|
+
// Telegram channel, nothing to notify from: the one path whose job is
|
|
405
|
+
// to say "a human is needed" was the one path with no fallback (#1399).
|
|
406
|
+
const result: FinishResult = {
|
|
407
|
+
feature: i.feature,
|
|
408
|
+
status: "escalated",
|
|
409
|
+
escalationReason: reason,
|
|
410
|
+
findings: verdict?.findings ?? [],
|
|
411
|
+
};
|
|
412
|
+
await writeResult(i.workdir, result);
|
|
413
|
+
|
|
292
414
|
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
415
|
+
let url: string | undefined;
|
|
416
|
+
let channel: string | undefined;
|
|
417
|
+
let deliveryError: string | undefined;
|
|
418
|
+
try {
|
|
419
|
+
const posted = await postEscalation(i.workdir, i.branch, comment, {
|
|
420
|
+
preferTelegram: i.escalateTelegram,
|
|
421
|
+
});
|
|
422
|
+
url = posted.url;
|
|
423
|
+
channel = posted.channel;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
deliveryError = String(err);
|
|
426
|
+
}
|
|
427
|
+
await writeResult(i.workdir, { ...result, url, deliveryError });
|
|
428
|
+
|
|
429
|
+
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
298
430
|
},
|
|
299
431
|
},
|
|
300
432
|
},
|
|
@@ -304,7 +436,11 @@ export default defineFlow({
|
|
|
304
436
|
from: "acceptance",
|
|
305
437
|
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
306
438
|
},
|
|
307
|
-
|
|
439
|
+
// Each fix commits before anything re-reads the diff: the reviewers see
|
|
440
|
+
// `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
|
|
441
|
+
// verbatim until the cap escalated it (#1397).
|
|
442
|
+
{ from: "fix_acceptance", to: "commit_acceptance" },
|
|
443
|
+
{ from: "commit_acceptance", to: "acceptance" },
|
|
308
444
|
{ from: "review_spec", to: "route_spec" },
|
|
309
445
|
{
|
|
310
446
|
from: "route_spec",
|
|
@@ -312,7 +448,8 @@ export default defineFlow({
|
|
|
312
448
|
},
|
|
313
449
|
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
314
450
|
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
315
|
-
{ from: "fix_spec", to: "
|
|
451
|
+
{ from: "fix_spec", to: "commit_spec" },
|
|
452
|
+
{ from: "commit_spec", to: "acceptance" },
|
|
316
453
|
{ from: "review_quality", to: "route_quality" },
|
|
317
454
|
{
|
|
318
455
|
from: "route_quality",
|
|
@@ -320,11 +457,13 @@ export default defineFlow({
|
|
|
320
457
|
},
|
|
321
458
|
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
322
459
|
// follow catch anything the fix broke mechanically.
|
|
323
|
-
{ from: "fix_quality", to: "
|
|
460
|
+
{ from: "fix_quality", to: "commit_quality" },
|
|
461
|
+
{ from: "commit_quality", to: "review_quality" },
|
|
324
462
|
{
|
|
325
463
|
from: "quality_gates",
|
|
326
464
|
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
327
465
|
},
|
|
328
|
-
{ from: "fix_gate", to: "
|
|
466
|
+
{ from: "fix_gate", to: "commit_gate" },
|
|
467
|
+
{ from: "commit_gate", to: "quality_gates" },
|
|
329
468
|
],
|
|
330
469
|
});
|
|
@@ -21,24 +21,47 @@ export function buildAcceptanceCommand(repoRoot: string, group: AcceptanceGroup)
|
|
|
21
21
|
return template.replace(/\{\{FILE\}\}|\{\{file\}\}|\{\{files\}\}/g, absFile);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface AcceptanceGateOutcome {
|
|
25
|
+
/** Every group that ran exited 0. Says nothing about groups that could not run. */
|
|
26
|
+
passed: boolean;
|
|
27
|
+
ran: number;
|
|
28
|
+
/**
|
|
29
|
+
* Package names whose acceptance test the resolver expected at its canonical
|
|
30
|
+
* path but which is absent on disk — never generated, or generation failed.
|
|
31
|
+
* The caller must treat a non-empty list as a coverage hole rather than a
|
|
32
|
+
* pass: skipping these silently is how a feature reached a "ready" PR with
|
|
33
|
+
* nothing verified.
|
|
34
|
+
*/
|
|
35
|
+
missing: string[];
|
|
36
|
+
output: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export async function runAcceptanceGate(
|
|
25
40
|
repoRoot: string,
|
|
26
41
|
groups: AcceptanceGroup[],
|
|
27
42
|
opts: { timeoutMs?: number } = {},
|
|
28
|
-
): Promise<
|
|
43
|
+
): Promise<AcceptanceGateOutcome> {
|
|
29
44
|
const chunks: string[] = [];
|
|
30
45
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS;
|
|
46
|
+
const missing: string[] = [];
|
|
31
47
|
let ran = 0;
|
|
32
48
|
for (const g of groups) {
|
|
33
|
-
|
|
49
|
+
const name = g.packageDir || "root";
|
|
50
|
+
if (!g.exists) {
|
|
51
|
+
missing.push(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
34
54
|
const cwd = g.packageDir ? `${repoRoot}/${g.packageDir}` : repoRoot;
|
|
35
55
|
ran += 1;
|
|
36
56
|
const res = await _acceptanceDeps.runShell(buildAcceptanceCommand(repoRoot, g), { cwd, timeoutMs });
|
|
37
|
-
chunks.push(`[${
|
|
38
|
-
if (res.exitCode !== 0) return { passed: false, ran, output: chunks.join("\n\n") };
|
|
57
|
+
chunks.push(`[${name}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
|
|
58
|
+
if (res.exitCode !== 0) return { passed: false, ran, missing, output: chunks.join("\n\n") };
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
chunks.push(`[acceptance] no acceptance test file on disk for: ${missing.join(", ")}`);
|
|
39
62
|
}
|
|
40
63
|
if (ran === 0) chunks.push("[acceptance] no acceptance test files present — nothing to run");
|
|
41
|
-
return { passed: true, ran, output: chunks.join("\n\n") };
|
|
64
|
+
return { passed: true, ran, missing, output: chunks.join("\n\n") };
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function languageRunner(language: string): string {
|
|
@@ -12,15 +12,64 @@ const URL_REGEX = /https?:\/\/\S+/;
|
|
|
12
12
|
|
|
13
13
|
export type Forge = "github" | "gitlab";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Host of a git remote, for both URL forms git accepts:
|
|
17
|
+
* `git@host:path` (scp-like) and `scheme://[user@]host[:port]/path`.
|
|
18
|
+
*/
|
|
19
|
+
export function remoteHost(remoteUrl: string): string {
|
|
20
|
+
const scp = remoteUrl.match(/^[^/]*@([^:/]+):/);
|
|
21
|
+
if (scp?.[1]) return scp[1].toLowerCase();
|
|
22
|
+
const url = remoteUrl.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/]+)/i);
|
|
23
|
+
return url?.[1]?.toLowerCase() ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify by host name.
|
|
28
|
+
*
|
|
29
|
+
* Matching the host (not a substring of the whole URL) is what makes
|
|
30
|
+
* self-hosted instances work: `"gitlab.mycorp.com".includes("gitlab.com")` is
|
|
31
|
+
* false, so the previous check rejected every self-hosted forge. GitHub is
|
|
32
|
+
* tested first purely for determinism on an absurd host naming both.
|
|
33
|
+
*/
|
|
34
|
+
function forgeFromHost(host: string): Forge | null {
|
|
35
|
+
if (host.includes("github")) return "github";
|
|
36
|
+
if (host.includes("gitlab")) return "gitlab";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Last resort for an enterprise host that names neither forge (`git.corp.com`):
|
|
42
|
+
* ask which CLI is installed. Only decisive when exactly one is — with both or
|
|
43
|
+
* neither present a guess would send `gh` at a GitLab remote.
|
|
44
|
+
*/
|
|
45
|
+
async function forgeFromCli(run: RunFn, repoRoot: string): Promise<Forge | null> {
|
|
46
|
+
const [gh, glab] = await Promise.all([
|
|
47
|
+
run(["gh", "--version"], { cwd: repoRoot }),
|
|
48
|
+
run(["glab", "--version"], { cwd: repoRoot }),
|
|
49
|
+
]);
|
|
50
|
+
const hasGh = gh.exitCode === 0;
|
|
51
|
+
const hasGlab = glab.exitCode === 0;
|
|
52
|
+
if (hasGh && !hasGlab) return "github";
|
|
53
|
+
if (hasGlab && !hasGh) return "gitlab";
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
|
|
16
58
|
const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
|
|
17
59
|
const remoteUrl = remote.stdout.trim();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
60
|
+
const host = remoteHost(remoteUrl);
|
|
61
|
+
|
|
62
|
+
const byHost = forgeFromHost(host);
|
|
63
|
+
if (byHost) return byHost;
|
|
64
|
+
|
|
65
|
+
const byCli = await forgeFromCli(run, repoRoot);
|
|
66
|
+
if (byCli) return byCli;
|
|
67
|
+
|
|
68
|
+
throw new FinishError(
|
|
69
|
+
`Unable to determine forge for remote host "${host || remoteUrl}" — its name matches neither github nor gitlab, and the gh/glab probe was inconclusive`,
|
|
70
|
+
"FINISH_UNKNOWN_FORGE",
|
|
71
|
+
{ stage, remoteUrl, host },
|
|
72
|
+
);
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
/** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Branch synchronisation for the nax-finish flow.
|
|
3
3
|
*
|
|
4
|
-
* Every fix node edits the working tree in place
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Every fix node edits the working tree in place, which two different consumers
|
|
5
|
+
* would otherwise miss:
|
|
6
|
+
*
|
|
7
|
+
* - The **reviewers** read `git diff <base>...HEAD` — committed history only.
|
|
8
|
+
* With the fixes uncommitted, every re-review re-read the pre-fix code and
|
|
9
|
+
* re-reported findings the fix node had already resolved, so the loop could
|
|
10
|
+
* never converge and always escalated at the fix cap (issue #1397). Each
|
|
11
|
+
* `commit_*` node calls `commitFixes` for this reason.
|
|
12
|
+
* - The **forge**: `gh pr create --head <branch>` opens a PR from the *remote*
|
|
13
|
+
* branch, and an escalation comment would describe state nobody else can see.
|
|
14
|
+
* Both terminal nodes call `commitAndPush` before touching the forge.
|
|
9
15
|
*/
|
|
10
16
|
import { FinishError } from "../errors";
|
|
11
17
|
import { runArgv } from "../exec";
|
|
@@ -32,34 +38,62 @@ async function isDirty(repoRoot: string): Promise<boolean> {
|
|
|
32
38
|
return status.stdout.trim().length > 0;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Commit the working tree, if it has anything in it, without pushing.
|
|
43
|
+
*
|
|
44
|
+
* Called by the `commit_*` nodes after every fix node so the next reviewer's
|
|
45
|
+
* `git diff <base>...HEAD` contains the fix. `git add -A` (not `-u`) because a
|
|
46
|
+
* fix routinely adds a *new* test file, and an untracked file is invisible to
|
|
47
|
+
* that diff — which is also why committing beats widening the reviewer's diff
|
|
48
|
+
* to include the working tree.
|
|
49
|
+
*
|
|
50
|
+
* `skipHooks` (used by every mid-loop `commit_*` node) adds `--no-verify`. Those
|
|
51
|
+
* commits are internal checkpoints, not shipped history: a repo whose
|
|
52
|
+
* pre-commit hook runs lint or typecheck would otherwise reject an intermediate
|
|
53
|
+
* state — a lint error the gate loop was about to fix — and take the whole flow
|
|
54
|
+
* down with it, with no result file. Nothing is lost by skipping them, because
|
|
55
|
+
* `quality_gates` runs the repo's own build/typecheck/lint/test and no PR opens
|
|
56
|
+
* unless they are green. The terminal `commitAndPush` leaves hooks enabled.
|
|
57
|
+
*
|
|
58
|
+
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
|
+
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
|
+
*/
|
|
61
|
+
export async function commitFixes(
|
|
62
|
+
repoRoot: string,
|
|
63
|
+
message: string,
|
|
64
|
+
opts: { skipHooks?: boolean } = {},
|
|
65
|
+
): Promise<{ committed: boolean }> {
|
|
66
|
+
if (!(await isDirty(repoRoot))) return { committed: false };
|
|
67
|
+
|
|
68
|
+
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
69
|
+
if (add.exitCode !== 0) {
|
|
70
|
+
throw new FinishError(
|
|
71
|
+
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
72
|
+
"FINISH_GIT_ADD_FAILED",
|
|
73
|
+
{ stage: "finish-git", repoRoot },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const commitArgv = ["git", "commit", "-m", message, ...(opts.skipHooks ? ["--no-verify"] : [])];
|
|
77
|
+
const commit = await _gitDeps.run(commitArgv, { cwd: repoRoot });
|
|
78
|
+
if (commit.exitCode !== 0) {
|
|
79
|
+
throw new FinishError(
|
|
80
|
+
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
81
|
+
"FINISH_GIT_COMMIT_FAILED",
|
|
82
|
+
{ stage: "finish-git", repoRoot },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { committed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
/**
|
|
36
89
|
* Commit any outstanding fixes and push the branch to `origin`.
|
|
37
90
|
*
|
|
38
91
|
* The push is unconditional — even with nothing new to commit the local branch
|
|
39
|
-
* may be ahead of its remote (nax's own run commits, or
|
|
40
|
-
*
|
|
92
|
+
* may be ahead of its remote (nax's own run commits, or the `commit_*` nodes'
|
|
93
|
+
* per-round commits), and the PR must reflect HEAD.
|
|
41
94
|
*/
|
|
42
95
|
export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
|
|
43
|
-
|
|
44
|
-
if (await isDirty(repoRoot)) {
|
|
45
|
-
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
46
|
-
if (add.exitCode !== 0) {
|
|
47
|
-
throw new FinishError(
|
|
48
|
-
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
49
|
-
"FINISH_GIT_ADD_FAILED",
|
|
50
|
-
{ stage: "finish-git", repoRoot },
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const commit = await _gitDeps.run(["git", "commit", "-m", message], { cwd: repoRoot });
|
|
54
|
-
if (commit.exitCode !== 0) {
|
|
55
|
-
throw new FinishError(
|
|
56
|
-
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
57
|
-
"FINISH_GIT_COMMIT_FAILED",
|
|
58
|
-
{ stage: "finish-git", repoRoot, branch },
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
committed = true;
|
|
62
|
-
}
|
|
96
|
+
const { committed } = await commitFixes(repoRoot, message);
|
|
63
97
|
|
|
64
98
|
const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
|
|
65
99
|
if (push.exitCode !== 0) {
|
|
@@ -48,6 +48,20 @@ export interface FinishResult {
|
|
|
48
48
|
status: "opened" | "promoted" | "already-ready" | "escalated" | "nothing-to-finish";
|
|
49
49
|
url?: string;
|
|
50
50
|
escalationReason?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The findings behind an escalation. Persisted because the reason alone is a
|
|
53
|
+
* bare count ("3 finding(s) after 3 fix attempts"), and on the Telegram
|
|
54
|
+
* channel the composed PR comment — the only other thing carrying them — is
|
|
55
|
+
* never posted. Without this the findings survived only in acpx's run bundle.
|
|
56
|
+
*/
|
|
57
|
+
findings?: Finding[];
|
|
58
|
+
/**
|
|
59
|
+
* Set when the escalation could not be delivered to its channel (forge
|
|
60
|
+
* comment failed, remote unrecognised). The result file is written before
|
|
61
|
+
* delivery is attempted, so an undelivered escalation is still reported
|
|
62
|
+
* rather than lost.
|
|
63
|
+
*/
|
|
64
|
+
deliveryError?: string;
|
|
51
65
|
}
|
|
52
66
|
export interface RunResult {
|
|
53
67
|
exitCode: number;
|