dw-sdlc 0.6.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 +38 -5
- package/config/agentic.config.schema.json +67 -18
- package/dist/agentic-config.d.ts +28 -1
- package/dist/agentic-config.js +35 -5
- 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/cli.js +5 -1
- 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.d.ts +2 -0
- package/dist/commands/pr-reviewed.js +92 -0
- package/dist/commands/pr-ship.d.ts +2 -0
- package/dist/commands/pr-ship.js +183 -0
- 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 +59 -0
- package/dist/pr/decide.js +250 -0
- package/dist/pr/read.d.ts +24 -0
- package/dist/pr/read.js +112 -0
- package/dist/pr/state.d.ts +54 -0
- package/dist/pr/state.js +220 -0
- 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 +2 -2
- 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
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// `dw-sdlc pr reviewed <n> --head <sha>`: pins a clean review to the head it read, as the commit status
|
|
2
|
+
// `dw-sdlc/review` in state success, so a push after the review leaves the new head unreviewed.
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { REVIEW, REVIEW_STATE, reviewStatusArgs } from "../pr/decide.js";
|
|
5
|
+
import { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
|
|
6
|
+
export const summary = "pin a clean review to the head it read, as a commit status";
|
|
7
|
+
const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--json]
|
|
8
|
+
|
|
9
|
+
Reads pull request <n> through gh, the read pr ship makes, and when its head is still <sha> posts
|
|
10
|
+
the commit status dw-sdlc/review in state success on it, the pull request's URL as its target. When
|
|
11
|
+
a push moved the head since the review, it posts nothing. One line, or one document with --json.
|
|
12
|
+
Needs an authenticated gh.
|
|
13
|
+
|
|
14
|
+
Exit 0 posted, 2 when it cannot run or the head moved.`;
|
|
15
|
+
const DESCRIPTION = "a clean review of this head";
|
|
16
|
+
function parseCli(argv) {
|
|
17
|
+
try {
|
|
18
|
+
const { values, positionals } = parseArgs({
|
|
19
|
+
args: argv,
|
|
20
|
+
allowPositionals: true,
|
|
21
|
+
options: {
|
|
22
|
+
help: { type: "boolean", short: "h" },
|
|
23
|
+
json: { type: "boolean" },
|
|
24
|
+
repo: { type: "string", short: "R" },
|
|
25
|
+
head: { type: "string" }
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
if (values.help === true) {
|
|
29
|
+
return "help";
|
|
30
|
+
}
|
|
31
|
+
const [number, ...more] = positionals;
|
|
32
|
+
const { repo, head } = values;
|
|
33
|
+
if (number === undefined) {
|
|
34
|
+
return { error: "name the pull request: dw-sdlc pr reviewed <n> --head <sha>" };
|
|
35
|
+
}
|
|
36
|
+
if (more.length > 0) {
|
|
37
|
+
return { error: `name one pull request, not ${positionals.length}` };
|
|
38
|
+
}
|
|
39
|
+
const problem = namedProblem(number, repo);
|
|
40
|
+
if (problem !== undefined) {
|
|
41
|
+
return { error: problem };
|
|
42
|
+
}
|
|
43
|
+
if (head === undefined) {
|
|
44
|
+
return { error: "name the head the review read: --head <sha>" };
|
|
45
|
+
}
|
|
46
|
+
if (!/^[0-9a-f]{40}$/.test(head)) {
|
|
47
|
+
return { error: `--head takes the full 40-character sha, not ${JSON.stringify(head)}` };
|
|
48
|
+
}
|
|
49
|
+
return { json: values.json === true, number: Number(number), repo, head };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Posts the review status on `head` once the read shows it is still the pull request's head. */
|
|
56
|
+
function post({ number, repo, head }) {
|
|
57
|
+
const pr = read({ number, repo }, "pr reviewed");
|
|
58
|
+
if (pr.head !== head) {
|
|
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");
|
|
60
|
+
}
|
|
61
|
+
const posted = gh(reviewStatusArgs(pr, DESCRIPTION));
|
|
62
|
+
if (posted.status !== 0) {
|
|
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`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function run(argv) {
|
|
67
|
+
const cli = parseCli(argv);
|
|
68
|
+
if (cli === "help") {
|
|
69
|
+
process.stdout.write(`${USAGE}\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if ("error" in cli) {
|
|
73
|
+
process.stderr.write(`dw-sdlc pr reviewed: ${cli.error}\n\n${USAGE}\n`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
post(cli);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof CannotRun) {
|
|
81
|
+
process.stderr.write(`dw-sdlc pr reviewed: ${error.message} — ${error.remedy}\n`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const { head } = cli;
|
|
87
|
+
const answer = cli.json
|
|
88
|
+
? JSON.stringify({ head, context: REVIEW, state: REVIEW_STATE }, null, 2)
|
|
89
|
+
: `posted ${REVIEW} ${REVIEW_STATE} on ${head}`;
|
|
90
|
+
process.stdout.write(`${answer}\n`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|