dw-sdlc 0.7.0 → 0.9.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/README.md +20 -12
- package/config/agentic.config.schema.json +56 -18
- package/dist/agentic-config.d.ts +23 -7
- package/dist/agentic-config.js +26 -12
- package/dist/checks/agents-map.d.ts +9 -1
- package/dist/checks/agents-map.js +60 -17
- package/dist/checks/decision-records.d.ts +5 -0
- package/dist/checks/decision-records.js +121 -16
- package/dist/checks/file-size-ratchet.js +7 -5
- package/dist/checks/index.js +2 -0
- package/dist/checks/markdown-budget.d.ts +2 -5
- package/dist/checks/markdown-budget.js +6 -15
- package/dist/checks/markdown-refs.js +1 -11
- package/dist/checks/routed-docs.d.ts +6 -0
- package/dist/checks/routed-docs.js +49 -0
- package/dist/checks/tree.d.ts +2 -0
- package/dist/checks/tree.js +13 -3
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +6 -4
- package/dist/commands/gate.d.ts +10 -4
- package/dist/commands/gate.js +202 -86
- package/dist/commands/pr-reviewed.js +5 -21
- package/dist/commands/pr-ship.js +37 -17
- package/dist/commands/references-sync.js +5 -6
- package/dist/drift.d.ts +17 -0
- package/dist/drift.js +68 -0
- package/dist/gate-cache.d.ts +13 -0
- package/dist/gate-cache.js +39 -0
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.js +10 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +46 -2
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +13 -0
- package/dist/pr/decide.d.ts +31 -8
- package/dist/pr/decide.js +65 -31
- package/dist/router-block.d.ts +4 -1
- package/dist/router-block.js +31 -7
- package/dist/run-command.d.ts +24 -0
- package/dist/run-command.js +121 -0
- package/dist/setup-gate.js +3 -12
- package/dist/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +1 -1
- package/templates/router.md +1 -0
package/dist/commands/gate.js
CHANGED
|
@@ -1,39 +1,67 @@
|
|
|
1
1
|
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
|
|
2
2
|
// run and the report serve `dw-sdlc check` too, which judges one Check of it.
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import { parseArgs } from "node:util";
|
|
5
4
|
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
6
5
|
import { CannotRun } from "../checks/check.js";
|
|
7
6
|
import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
|
|
7
|
+
import { watchDrift } from "../drift.js";
|
|
8
|
+
import { fingerprint, isGreen, stampGreen } from "../gate-cache.js";
|
|
8
9
|
import { annotation, failureJson, failureLine } from "../report.js";
|
|
10
|
+
import { holdingSignals, runCommand, stoppedBy, TAIL_LINES } from "../run-command.js";
|
|
9
11
|
export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
10
|
-
const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
|
|
12
|
+
const USAGE = `Usage: dw-sdlc gate [--json] [--verbose] [--root <dir>] [--update-baseline | --cache]
|
|
11
13
|
|
|
12
14
|
Runs the Gate of the repository holding --root (default: the current directory): each Step of
|
|
13
|
-
gate.steps in order from the repository root,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
gate.steps in order from the repository root, stopping at the first that fails; then every Check
|
|
16
|
+
under gate.checks, the run scripts and then the Checks the package ships, none stopping the next.
|
|
17
|
+
|
|
18
|
+
Prints one line per Step with its time, one per Check, one per failure and a last line, green or
|
|
19
|
+
failed. A failure line names the file where there is one, the rule and the remedy; under GitHub
|
|
20
|
+
Actions it is also a workflow annotation. A Step or run script prints nothing of its own unless it
|
|
21
|
+
does not pass: then its last ${TAIL_LINES} lines, both streams in the order written, under its own
|
|
22
|
+
line. In a terminal a Step's line shows when it starts and takes its result when it ends.
|
|
23
|
+
|
|
24
|
+
Each Step runs between two records of every file git tracks or would track: a file the Step created,
|
|
25
|
+
changed or deleted is Drift, one failure per file, and the Steps after it still run. Ignored files
|
|
26
|
+
and edits made before the run never drift, and a Step a signal stopped is not compared. gate.drift
|
|
27
|
+
"allow" turns the watch off.
|
|
28
|
+
|
|
29
|
+
SIGINT or SIGTERM sent while a command runs stops it and everything after it, and the report still
|
|
30
|
+
prints: the stopped command's last lines, its failure line and the last line. A second signal, or one
|
|
31
|
+
sent while the Checks the package ships run, ends the Gate at once.
|
|
32
|
+
|
|
33
|
+
--verbose passes each command's output through as it runs instead, under a line naming the command,
|
|
34
|
+
and keeps no tail. --json prints the document alone; each Step and Check in it carries tail, the last
|
|
35
|
+
${TAIL_LINES} lines of a command that did not pass, null otherwise. With --verbose, under --json, the
|
|
36
|
+
commands' output goes to stderr and every tail is null.
|
|
18
37
|
|
|
19
38
|
--update-baseline then rewrites the baseline of each Ratchet, only when every Step and every other
|
|
20
|
-
Check passed, and only tighter: a file now within its limit leaves it and no
|
|
21
|
-
is no baseline, it records one. The Ratchet is judged again on what it wrote.
|
|
39
|
+
Check passed and no Step drifted, and only tighter: a file now within its limit leaves it and no
|
|
40
|
+
file joins. Where there is no baseline, it records one. The Ratchet is judged again on what it wrote.
|
|
41
|
+
|
|
42
|
+
--cache skips the run when the working tree and the Gate match the last green run in this checkout.
|
|
43
|
+
Untracked files count and ignored files do not. Each worktree keeps its own stamp. A tree it cannot
|
|
44
|
+
hash runs the whole Gate.
|
|
22
45
|
|
|
23
|
-
Exit 0 when everything passes, 1 when a Step or a Check fails, 2 when the Gate
|
|
24
|
-
invalid config layer, a Step or Check whose command the shell cannot find or execute,
|
|
25
|
-
missing an input it reads.`;
|
|
46
|
+
Exit 0 when everything passes, 1 when a Step or a Check fails or a Step drifts, 2 when the Gate
|
|
47
|
+
cannot run: an invalid config layer, a Step or Check whose command the shell cannot find or execute,
|
|
48
|
+
a Check missing an input it reads, or a tree it cannot record for Drift.`;
|
|
26
49
|
const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
const SKIPPED = { status: "skipped", exitCode: null, tail: null };
|
|
51
|
+
const SILENT = { write: () => true };
|
|
52
|
+
const STATUS_WORDS = {
|
|
53
|
+
pass: "pass",
|
|
54
|
+
fail: "failed",
|
|
55
|
+
error: "cannot run",
|
|
56
|
+
skipped: "skipped"
|
|
57
|
+
};
|
|
32
58
|
const REMEDIES = {
|
|
33
59
|
invalidLayer: "fix the file; the Gate runs once every layer is valid",
|
|
34
|
-
failed: "fix what it printed above",
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
failed: "fix what it printed above; run it alone for the full output",
|
|
61
|
+
notRunnable: {
|
|
62
|
+
step: "install the command or fix gate.steps in the agentic config",
|
|
63
|
+
check: "install the command or fix gate.checks.run in the agentic config"
|
|
64
|
+
},
|
|
37
65
|
checkStopped: "fix what stopped it, or report it to dw-sdlc"
|
|
38
66
|
};
|
|
39
67
|
function plural(count, noun) {
|
|
@@ -46,16 +74,20 @@ function parseCli(argv) {
|
|
|
46
74
|
options: {
|
|
47
75
|
help: { type: "boolean", short: "h" },
|
|
48
76
|
json: { type: "boolean" },
|
|
77
|
+
verbose: { type: "boolean" },
|
|
49
78
|
root: { type: "string" },
|
|
50
|
-
"update-baseline": { type: "boolean" }
|
|
79
|
+
"update-baseline": { type: "boolean" },
|
|
80
|
+
cache: { type: "boolean" }
|
|
51
81
|
}
|
|
52
82
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
const refresh = values["update-baseline"] === true;
|
|
84
|
+
const cache = values.cache === true;
|
|
85
|
+
if (refresh && cache) {
|
|
86
|
+
return { error: "--cache and --update-baseline do not combine: a refresh needs the Gate to run" };
|
|
87
|
+
}
|
|
88
|
+
const mode = refresh ? "update-baseline" : cache ? "cache" : "plain";
|
|
89
|
+
const flags = { help: values.help === true, json: values.json === true, verbose: values.verbose === true };
|
|
90
|
+
return { ...flags, root: values.root, mode };
|
|
59
91
|
}
|
|
60
92
|
catch (error) {
|
|
61
93
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
@@ -66,25 +98,64 @@ function configFailure({ file, path, message }) {
|
|
|
66
98
|
return { file, rule: "config", message: where, remedy: REMEDIES.invalidLayer };
|
|
67
99
|
}
|
|
68
100
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
101
|
+
* What follows a Step's or Check's name once it ran: pass, failed or cannot run, with a command's exit,
|
|
102
|
+
* a Step's time, and a captured tail indented under the line.
|
|
71
103
|
*/
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
function outcomeText(kind, outcome) {
|
|
105
|
+
const isCommand = "seconds" in outcome;
|
|
106
|
+
const exit = isCommand && outcome.status !== "pass" ? howItEnded(outcome) : "";
|
|
107
|
+
const time = isCommand && kind === "step" ? ` (${Math.round(outcome.seconds)} s)` : "";
|
|
108
|
+
const head = `${STATUS_WORDS[outcome.status]}${exit}${time}`;
|
|
109
|
+
if (outcome.tail === null) {
|
|
110
|
+
return head;
|
|
111
|
+
}
|
|
112
|
+
if (outcome.tail.length === 0) {
|
|
113
|
+
return `${head}; it printed nothing`;
|
|
76
114
|
}
|
|
77
|
-
|
|
78
|
-
|
|
115
|
+
const tail = outcome.tail.map((line) => (line === "" ? "" : ` ${line}`));
|
|
116
|
+
return [`${head}; its last ${TAIL_LINES} lines:`, ...tail].join("\n");
|
|
117
|
+
}
|
|
118
|
+
/** How a command that did not pass ended: the signal that stopped it, or its exit code. */
|
|
119
|
+
function howItEnded({ signal, exitCode }) {
|
|
120
|
+
if (signal !== null) {
|
|
121
|
+
return `, stopped by ${signal}`;
|
|
79
122
|
}
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
return exitCode === null ? "" : `, exit ${exitCode}`;
|
|
124
|
+
}
|
|
125
|
+
function none() { }
|
|
126
|
+
/** Whether a Step's line goes out before it runs, to take its result in place: only in a terminal. */
|
|
127
|
+
function live(kind) {
|
|
128
|
+
return kind === "step" && process.stdout.isTTY;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Quiet, the Gate prints each result, a Step's name first when stdout is a terminal; `--verbose` names
|
|
132
|
+
* each command before its output; `--json` prints neither. One Check run by name prints only a tail.
|
|
133
|
+
*/
|
|
134
|
+
function progressOf(options, log) {
|
|
135
|
+
if ("only" in options) {
|
|
136
|
+
const end = (kind, subject, outcome) => {
|
|
137
|
+
if (!options.json && outcome.tail !== null) {
|
|
138
|
+
process.stdout.write(`${kind} ${subject} — ${outcomeText(kind, outcome)}\n`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
return { start: none, end };
|
|
82
142
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return { status: "error", exitCode: result.status, detail: `${notRunnable} (exit ${result.status})` };
|
|
143
|
+
if (options.verbose) {
|
|
144
|
+
return { start: (kind, subject) => log.write(`gate: ${kind} ${subject}\n`), end: none };
|
|
86
145
|
}
|
|
87
|
-
|
|
146
|
+
if (options.json) {
|
|
147
|
+
return { start: none, end: none };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
start: (kind, subject) => {
|
|
151
|
+
if (live(kind)) {
|
|
152
|
+
process.stdout.write(`gate: ${kind} ${subject}`);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
end: (kind, subject, outcome) => {
|
|
156
|
+
process.stdout.write(`${live(kind) ? "" : `gate: ${kind} ${subject}`} — ${outcomeText(kind, outcome)}\n`);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
88
159
|
}
|
|
89
160
|
/** What a Check that threw reports: the input it lacks, or what stopped it. */
|
|
90
161
|
function erred(check, error) {
|
|
@@ -93,7 +164,7 @@ function erred(check, error) {
|
|
|
93
164
|
: { message: `stopped: ${error instanceof Error ? error.message : String(error)}`, remedy: REMEDIES.checkStopped };
|
|
94
165
|
return {
|
|
95
166
|
check,
|
|
96
|
-
outcome: { status: "error", exitCode: EXIT_CODES.error },
|
|
167
|
+
outcome: { status: "error", exitCode: EXIT_CODES.error, tail: null },
|
|
97
168
|
failures: [{ rule: check.name, ...finding }]
|
|
98
169
|
};
|
|
99
170
|
}
|
|
@@ -102,21 +173,22 @@ function runShipped(check, root) {
|
|
|
102
173
|
try {
|
|
103
174
|
const failures = check.run(root).map((finding) => ({ rule: check.name, ...finding }));
|
|
104
175
|
const status = failures.length === 0 ? "pass" : "fail";
|
|
105
|
-
return { check, outcome: { status, exitCode: EXIT_CODES[status] }, failures };
|
|
176
|
+
return { check, outcome: { status, exitCode: EXIT_CODES[status], tail: null }, failures };
|
|
106
177
|
}
|
|
107
178
|
catch (error) {
|
|
108
179
|
return erred(check, error);
|
|
109
180
|
}
|
|
110
181
|
}
|
|
111
182
|
/**
|
|
112
|
-
* Rewrites the baseline of each Ratchet while every Step, every run Check
|
|
113
|
-
* shipped Check passed, and judges the Ratchet again on what it wrote.
|
|
183
|
+
* Rewrites the baseline of each Ratchet while every Step, every run Check and the watch for Drift in
|
|
184
|
+
* `others` and every other shipped Check passed, and judges the Ratchet again on what it wrote.
|
|
185
|
+
* Returns a line on each.
|
|
114
186
|
*/
|
|
115
|
-
function refreshBaselines(judged, others, root
|
|
187
|
+
function refreshBaselines(judged, others, root) {
|
|
116
188
|
if (!judged.some(({ check }) => check.baseline !== undefined)) {
|
|
117
|
-
|
|
118
|
-
return;
|
|
189
|
+
return ["gate: no Ratchet is turned on, so no baseline to refresh"];
|
|
119
190
|
}
|
|
191
|
+
const notes = [];
|
|
120
192
|
for (const [index, { check }] of judged.entries()) {
|
|
121
193
|
if (check.baseline === undefined) {
|
|
122
194
|
continue;
|
|
@@ -124,22 +196,23 @@ function refreshBaselines(judged, others, root, log) {
|
|
|
124
196
|
const { file, refresh } = check.baseline;
|
|
125
197
|
const rest = [...others, ...judged.filter((_, at) => at !== index).map(({ outcome }) => outcome)];
|
|
126
198
|
if (rest.some(({ status }) => status !== "pass")) {
|
|
127
|
-
|
|
199
|
+
notes.push(`gate: baseline ${file} not refreshed — a Step or another Check did not pass`);
|
|
128
200
|
continue;
|
|
129
201
|
}
|
|
130
202
|
try {
|
|
131
|
-
|
|
203
|
+
notes.push(`gate: baseline ${file} ${refresh(root)}`);
|
|
132
204
|
judged[index] = runShipped(check, root);
|
|
133
205
|
}
|
|
134
206
|
catch (error) {
|
|
135
207
|
if (error instanceof CannotRun) {
|
|
136
|
-
|
|
208
|
+
notes.push(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}`);
|
|
137
209
|
}
|
|
138
210
|
else {
|
|
139
211
|
judged[index] = erred(check, error);
|
|
140
212
|
}
|
|
141
213
|
}
|
|
142
214
|
}
|
|
215
|
+
return notes;
|
|
143
216
|
}
|
|
144
217
|
function worst(outcomes) {
|
|
145
218
|
const statuses = new Set(outcomes.map(({ status }) => status));
|
|
@@ -162,10 +235,13 @@ function select(gate, only) {
|
|
|
162
235
|
}
|
|
163
236
|
return { steps: [], run: scripts, shipped: shipped === undefined ? [] : [shipped] };
|
|
164
237
|
}
|
|
165
|
-
export function runGate(root, options) {
|
|
238
|
+
export async function runGate(root, options) {
|
|
166
239
|
const { json } = options;
|
|
167
240
|
const only = "only" in options ? options.only : undefined;
|
|
168
|
-
const
|
|
241
|
+
const mode = "mode" in options ? options.mode : undefined;
|
|
242
|
+
const log = only !== undefined ? SILENT : json ? process.stderr : process.stdout;
|
|
243
|
+
const progress = progressOf(options, log);
|
|
244
|
+
const output = "verbose" in options && options.verbose ? (json ? "stderr" : "stdout") : "capture";
|
|
169
245
|
const { config, problems } = resolveConfig(root);
|
|
170
246
|
const gateCommand = config.gate.command ?? null;
|
|
171
247
|
if (problems.length > 0) {
|
|
@@ -175,55 +251,94 @@ export function runGate(root, options) {
|
|
|
175
251
|
if ("rule" in selected) {
|
|
176
252
|
return { status: "error", command: gateCommand, steps: [], checks: [], failures: [selected] };
|
|
177
253
|
}
|
|
254
|
+
let toStamp;
|
|
255
|
+
if (mode === "cache") {
|
|
256
|
+
const found = fingerprint(root, config.gate);
|
|
257
|
+
if ("error" in found) {
|
|
258
|
+
log.write(`gate: cannot fingerprint the tree, so the whole Gate runs — ${found.error}\n`);
|
|
259
|
+
}
|
|
260
|
+
else if (isGreen(found)) {
|
|
261
|
+
return { status: "pass", command: gateCommand, cached: true, steps: [], checks: [], failures: [] };
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
toStamp = found;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
178
267
|
const failures = [];
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
const watch = watchDrift(root, config.gate);
|
|
269
|
+
/** Runs one Step or Check, prints its progress and records its failure under `rule`, if it has one. */
|
|
270
|
+
const runOne = async (kind, subject, command) => {
|
|
271
|
+
progress.start(kind, subject);
|
|
272
|
+
const ran = await runCommand(command, root, output);
|
|
273
|
+
progress.end(kind, subject, ran);
|
|
274
|
+
if (ran.detail !== undefined) {
|
|
275
|
+
const remedy = ran.status === "error" ? REMEDIES.notRunnable[kind] : REMEDIES.failed;
|
|
276
|
+
failures.push({
|
|
277
|
+
rule: kind === "step" ? "step" : subject,
|
|
278
|
+
message: `${JSON.stringify(command)} ${ran.detail}`,
|
|
279
|
+
remedy
|
|
280
|
+
});
|
|
185
281
|
}
|
|
186
|
-
return
|
|
282
|
+
return { status: ran.status, exitCode: ran.exitCode, tail: ran.tail };
|
|
187
283
|
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
284
|
+
// While commands run, SIGINT and SIGTERM stop the one running and every one after it, and the report
|
|
285
|
+
// still prints. The Checks the package ships run in this process, where a signal ends the Gate at once.
|
|
286
|
+
const commands = await holdingSignals(async () => {
|
|
287
|
+
let failedStep = false;
|
|
288
|
+
const halted = () => failedStep || stoppedBy() !== undefined;
|
|
289
|
+
const steps = [];
|
|
290
|
+
for (const step of selected.steps) {
|
|
291
|
+
const watched = halted() ? undefined : await watch.step(step, () => runOne("step", step, step));
|
|
292
|
+
failures.push(...(watched?.failures ?? []));
|
|
293
|
+
const outcome = watched?.result ?? SKIPPED;
|
|
294
|
+
steps.push({ command: step, ...outcome });
|
|
295
|
+
failedStep ||= outcome.status !== "pass";
|
|
194
296
|
}
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
stopped = outcome.status !== "pass";
|
|
199
|
-
}
|
|
200
|
-
const checks = [];
|
|
201
|
-
for (const check of selected.run) {
|
|
202
|
-
if (stopped) {
|
|
203
|
-
checks.push({ ...check, ...SKIPPED });
|
|
204
|
-
continue;
|
|
297
|
+
const checks = [];
|
|
298
|
+
for (const check of selected.run) {
|
|
299
|
+
checks.push({ ...check, ...(halted() ? SKIPPED : await runOne("check", check.name, check.command)) });
|
|
205
300
|
}
|
|
206
|
-
|
|
207
|
-
|
|
301
|
+
return { steps, checks, halted: halted() };
|
|
302
|
+
});
|
|
303
|
+
const { steps, checks } = commands;
|
|
304
|
+
const late = commands.halted ? undefined : stoppedBy();
|
|
305
|
+
if (late !== undefined) {
|
|
306
|
+
// It landed after the last command, which it could not stop: it ends the Gate as in the shipped Checks.
|
|
307
|
+
process.kill(process.pid, late);
|
|
208
308
|
}
|
|
309
|
+
const stopped = commands.halted;
|
|
209
310
|
const judged = selected.shipped.map((check) => {
|
|
210
311
|
if (stopped) {
|
|
211
312
|
return { check, outcome: SKIPPED, failures: [] };
|
|
212
313
|
}
|
|
213
|
-
|
|
314
|
+
progress.start("check", check.name);
|
|
214
315
|
return runShipped(check, root);
|
|
215
316
|
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
317
|
+
const watchOutcome = { status: watch.status() };
|
|
318
|
+
const notes = mode === "update-baseline" ? refreshBaselines(judged, [...steps, ...checks, watchOutcome], root) : [];
|
|
319
|
+
// A Ratchet's result is printed after its refresh, which judges it again.
|
|
219
320
|
for (const { check, outcome, failures: found } of judged) {
|
|
321
|
+
if (outcome.status !== "skipped") {
|
|
322
|
+
progress.end("check", check.name, outcome);
|
|
323
|
+
}
|
|
220
324
|
failures.push(...found);
|
|
221
325
|
checks.push({ name: check.name, command: null, ...outcome });
|
|
222
326
|
}
|
|
223
|
-
|
|
327
|
+
for (const note of notes) {
|
|
328
|
+
log.write(`${note}\n`);
|
|
329
|
+
}
|
|
330
|
+
const status = worst([...steps, ...checks, watchOutcome]);
|
|
331
|
+
// Stamped with the fingerprint taken before the run: a Gate that changed the tree runs again next time.
|
|
332
|
+
if (status === "pass" && toStamp !== undefined) {
|
|
333
|
+
stampGreen(toStamp);
|
|
334
|
+
}
|
|
335
|
+
return { status, command: gateCommand, steps, checks, failures };
|
|
224
336
|
}
|
|
225
337
|
/** The last line, after `label`: green with what ran, or failed or cannot run with how many failures. */
|
|
226
338
|
function verdict(label, report) {
|
|
339
|
+
if (report.cached === true) {
|
|
340
|
+
return `${label}: green — cached, the tree matches the last green run`;
|
|
341
|
+
}
|
|
227
342
|
if (report.status === "pass") {
|
|
228
343
|
return `${label}: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
|
|
229
344
|
}
|
|
@@ -250,7 +365,7 @@ export function printReport(report, json, label) {
|
|
|
250
365
|
}
|
|
251
366
|
return EXIT_CODES[report.status];
|
|
252
367
|
}
|
|
253
|
-
export function run(argv) {
|
|
368
|
+
export async function run(argv) {
|
|
254
369
|
const cli = parseCli(argv);
|
|
255
370
|
if ("error" in cli) {
|
|
256
371
|
process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
|
|
@@ -260,6 +375,7 @@ export function run(argv) {
|
|
|
260
375
|
process.stdout.write(`${USAGE}\n`);
|
|
261
376
|
return 0;
|
|
262
377
|
}
|
|
263
|
-
const { json,
|
|
264
|
-
|
|
378
|
+
const { json, verbose, mode } = cli;
|
|
379
|
+
const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, verbose, mode });
|
|
380
|
+
return printReport(report, json, "gate");
|
|
265
381
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `dw-sdlc pr reviewed <n> --head <sha>`: pins a clean review to the head it read, as the commit status
|
|
2
2
|
// `dw-sdlc/review` in state success, so a push after the review leaves the new head unreviewed.
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
+
import { REVIEW, REVIEW_STATE, reviewStatusArgs } from "../pr/decide.js";
|
|
4
5
|
import { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
|
|
5
6
|
export const summary = "pin a clean review to the head it read, as a commit status";
|
|
6
7
|
const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--json]
|
|
@@ -11,9 +12,6 @@ a push moved the head since the review, it posts nothing. One line, or one docum
|
|
|
11
12
|
Needs an authenticated gh.
|
|
12
13
|
|
|
13
14
|
Exit 0 posted, 2 when it cannot run or the head moved.`;
|
|
14
|
-
const CONTEXT = "dw-sdlc/review";
|
|
15
|
-
/** The one state it posts: a failure or a pending status would turn the pull request unstable. */
|
|
16
|
-
const STATE = "success";
|
|
17
15
|
const DESCRIPTION = "a clean review of this head";
|
|
18
16
|
function parseCli(argv) {
|
|
19
17
|
try {
|
|
@@ -60,23 +58,9 @@ function post({ number, repo, head }) {
|
|
|
60
58
|
if (pr.head !== head) {
|
|
61
59
|
throw new CannotRun(`${pr.repo}#${number}: the head is ${pr.head}, not ${head}: a push landed since the review`, "review the new head, then call again with its sha");
|
|
62
60
|
}
|
|
63
|
-
const posted = gh(
|
|
64
|
-
"api",
|
|
65
|
-
`repos/${pr.repo}/statuses/${head}`,
|
|
66
|
-
"--method",
|
|
67
|
-
"POST",
|
|
68
|
-
"--silent",
|
|
69
|
-
"-f",
|
|
70
|
-
`state=${STATE}`,
|
|
71
|
-
"-f",
|
|
72
|
-
`context=${CONTEXT}`,
|
|
73
|
-
"-f",
|
|
74
|
-
`target_url=${pr.url}`,
|
|
75
|
-
"-f",
|
|
76
|
-
`description=${DESCRIPTION}`
|
|
77
|
-
]);
|
|
61
|
+
const posted = gh(reviewStatusArgs(pr, DESCRIPTION));
|
|
78
62
|
if (posted.status !== 0) {
|
|
79
|
-
throw new CannotRun(`${pr.repo}#${number}: posting ${
|
|
63
|
+
throw new CannotRun(`${pr.repo}#${number}: posting ${REVIEW} failed: ${said(posted.stderr)}`, `check that gh's token may write commit statuses to ${pr.repo}, then call again`);
|
|
80
64
|
}
|
|
81
65
|
}
|
|
82
66
|
export function run(argv) {
|
|
@@ -101,8 +85,8 @@ export function run(argv) {
|
|
|
101
85
|
}
|
|
102
86
|
const { head } = cli;
|
|
103
87
|
const answer = cli.json
|
|
104
|
-
? JSON.stringify({ head, context:
|
|
105
|
-
: `posted ${
|
|
88
|
+
? JSON.stringify({ head, context: REVIEW, state: REVIEW_STATE }, null, 2)
|
|
89
|
+
: `posted ${REVIEW} ${REVIEW_STATE} on ${head}`;
|
|
106
90
|
process.stdout.write(`${answer}\n`);
|
|
107
91
|
return 0;
|
|
108
92
|
}
|
package/dist/commands/pr-ship.js
CHANGED
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
// needs next, performs it when it is the script's and reads again, then answers the first Action that
|
|
3
3
|
// is not, as a line or one JSON document, with an exit code that names whose turn it is.
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
6
|
-
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, shellLine } from "../pr/decide.js";
|
|
5
|
+
import { repositoryRoot, resolveConfig, STRICTNESS } from "../agentic-config.js";
|
|
6
|
+
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
|
|
7
7
|
import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
8
8
|
export const summary = "decide the Action one pull request needs next, as a line or JSON";
|
|
9
|
-
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--dry-run] [--json] [--verbose]
|
|
9
|
+
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
|
|
10
10
|
|
|
11
11
|
Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
|
|
12
12
|
is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
|
|
13
13
|
The script performs its own Actions, each at most once and then reads again: it updates a branch
|
|
14
14
|
behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
|
|
15
15
|
request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
|
|
16
|
-
under ask, the default, it parks with the merge command.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
|
|
17
|
+
gets the Review depth --review names for this call, else pr.review: under full, the default, the
|
|
18
|
+
agent reviews it with the command pr.reviewCommand names, /code-review by default; under off the
|
|
19
|
+
script posts the success itself, its description saying why.
|
|
20
|
+
--dry-run performs nothing: it parks at the first Action the script would perform, listed under
|
|
21
|
+
performed. One line, or one document with --json; --verbose adds the state as read to the document
|
|
22
|
+
under facts. Needs an authenticated gh.
|
|
19
23
|
|
|
20
24
|
Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
|
|
21
25
|
const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
|
|
@@ -30,12 +34,14 @@ function parseCli(argv) {
|
|
|
30
34
|
json: { type: "boolean" },
|
|
31
35
|
verbose: { type: "boolean" },
|
|
32
36
|
merge: { type: "boolean" },
|
|
37
|
+
review: { type: "string" },
|
|
33
38
|
"dry-run": { type: "boolean" },
|
|
34
39
|
repo: { type: "string", short: "R" }
|
|
35
40
|
}
|
|
36
41
|
});
|
|
37
42
|
const [number, ...more] = positionals;
|
|
38
43
|
const { repo } = values;
|
|
44
|
+
const review = STRICTNESS.review.find((depth) => depth === values.review);
|
|
39
45
|
if (values.help !== true) {
|
|
40
46
|
if (more.length > 0) {
|
|
41
47
|
return { error: `name one pull request, not ${positionals.length}` };
|
|
@@ -44,12 +50,16 @@ function parseCli(argv) {
|
|
|
44
50
|
if (problem !== undefined) {
|
|
45
51
|
return { error: problem };
|
|
46
52
|
}
|
|
53
|
+
if (values.review !== undefined && review === undefined) {
|
|
54
|
+
return { error: `--review takes ${STRICTNESS.review.join(" or ")}, not ${JSON.stringify(values.review)}` };
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
57
|
return {
|
|
49
58
|
help: values.help === true,
|
|
50
59
|
json: values.json === true,
|
|
51
60
|
verbose: values.verbose === true,
|
|
52
61
|
merge: values.merge === true,
|
|
62
|
+
review,
|
|
53
63
|
dryRun: values["dry-run"] === true,
|
|
54
64
|
number: number === undefined ? undefined : Number(number),
|
|
55
65
|
repo
|
|
@@ -75,45 +85,50 @@ function now() {
|
|
|
75
85
|
const NAMED = {
|
|
76
86
|
"update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
|
|
77
87
|
"mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
|
|
88
|
+
"mark-reviewed": {
|
|
89
|
+
would: "mark it reviewed",
|
|
90
|
+
act: "the mark-reviewed",
|
|
91
|
+
still: `the head still carries no ${REVIEW} success`
|
|
92
|
+
},
|
|
78
93
|
merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
|
|
79
94
|
};
|
|
80
95
|
/** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
|
|
81
|
-
function handedOver(pr, answer, action, reason) {
|
|
96
|
+
function handedOver(pr, policy, answer, action, reason) {
|
|
82
97
|
if (action === "merge") {
|
|
83
|
-
return { ...readyToMerge(pr, reason), blockers: answer.blockers };
|
|
98
|
+
return { ...readyToMerge(pr, policy, reason), blockers: answer.blockers };
|
|
84
99
|
}
|
|
85
|
-
return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action) };
|
|
100
|
+
return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
|
|
86
101
|
}
|
|
87
102
|
/**
|
|
88
103
|
* Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
|
|
89
104
|
* pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
|
|
90
105
|
*/
|
|
91
|
-
function ship(cli,
|
|
106
|
+
function ship(cli, policy) {
|
|
92
107
|
let pr = read(cli, "pr ship");
|
|
93
108
|
const performed = [];
|
|
94
109
|
const refusals = {};
|
|
95
110
|
for (;;) {
|
|
96
|
-
const answer = decide(pr, now(),
|
|
111
|
+
const answer = decide(pr, now(), policy);
|
|
97
112
|
const { action } = answer;
|
|
98
113
|
if (!isScriptAction(action)) {
|
|
99
114
|
return { pr, answer, performed };
|
|
100
115
|
}
|
|
101
116
|
// -R names the repository, so the checkout the script runs in plays no part.
|
|
102
|
-
const args = hostArgs(pr, action, true);
|
|
117
|
+
const args = hostArgs(pr, action, policy, true);
|
|
103
118
|
const command = shellLine(["gh", ...args]);
|
|
104
119
|
const { would, act, still } = NAMED[action];
|
|
105
120
|
if (cli.dryRun) {
|
|
106
121
|
performed.push({ action, command, ok: null });
|
|
107
122
|
return {
|
|
108
123
|
pr,
|
|
109
|
-
answer: handedOver(pr, answer, action, `the script would ${would}; --dry-run performs nothing`),
|
|
124
|
+
answer: handedOver(pr, policy, answer, action, `the script would ${would}; --dry-run performs nothing`),
|
|
110
125
|
performed
|
|
111
126
|
};
|
|
112
127
|
}
|
|
113
128
|
if (performed.some((done) => done.action === action)) {
|
|
114
129
|
const refusal = refusals[action];
|
|
115
130
|
const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
|
|
116
|
-
return { pr, answer: handedOver(pr, answer, action, reason), performed };
|
|
131
|
+
return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
|
|
117
132
|
}
|
|
118
133
|
const result = gh(args);
|
|
119
134
|
performed.push({ action, command, ok: result.status === 0 });
|
|
@@ -143,11 +158,16 @@ export function run(argv) {
|
|
|
143
158
|
process.stdout.write(`${USAGE}\n`);
|
|
144
159
|
return 0;
|
|
145
160
|
}
|
|
146
|
-
// An invalid config layer resolves to ask, like every other strictest setting.
|
|
147
|
-
const
|
|
161
|
+
// An invalid config layer resolves to ask and full, like every other strictest setting.
|
|
162
|
+
const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
|
|
163
|
+
const policy = {
|
|
164
|
+
merge: cli.merge ? "auto" : declared.merge,
|
|
165
|
+
review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
|
|
166
|
+
reviewCommand: declared.reviewCommand
|
|
167
|
+
};
|
|
148
168
|
let shipped;
|
|
149
169
|
try {
|
|
150
|
-
shipped = ship(cli,
|
|
170
|
+
shipped = ship(cli, policy);
|
|
151
171
|
}
|
|
152
172
|
catch (error) {
|
|
153
173
|
if (error instanceof CannotRun) {
|
|
@@ -6,6 +6,7 @@ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync,
|
|
|
6
6
|
import { extname, join } from "node:path";
|
|
7
7
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
|
+
import { repositoryFreeEnv } from "../git-env.js";
|
|
9
10
|
import { readPins, resolveRef } from "../pins.js";
|
|
10
11
|
import { REFERENCES_FILE, readReferences } from "../references-key.js";
|
|
11
12
|
import { renderBlock, withBlock } from "../router-block.js";
|
|
@@ -133,13 +134,11 @@ async function lock(root, seconds) {
|
|
|
133
134
|
rmSync(own, { force: true });
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
|
-
/** The variables that tie git to one repository, as git itself lists them. */
|
|
137
|
-
const REPOSITORY_VARS = new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
|
|
138
137
|
function git(args, cwd) {
|
|
139
|
-
// Each call finds its repository from `cwd
|
|
140
|
-
//
|
|
141
|
-
const env =
|
|
142
|
-
const result = spawnSync("git", args, { cwd, encoding: "utf8", env
|
|
138
|
+
// Each call finds its repository from `cwd`. A missing credential fails the fetch instead of waiting
|
|
139
|
+
// on a prompt nobody sees.
|
|
140
|
+
const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
|
|
141
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", env });
|
|
143
142
|
// git's error on one line, so each Reference stays one line of the report.
|
|
144
143
|
const err = (result.stderr ?? "")
|
|
145
144
|
.split("\n")
|