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.
Files changed (52) hide show
  1. package/README.md +38 -5
  2. package/config/agentic.config.schema.json +67 -18
  3. package/dist/agentic-config.d.ts +28 -1
  4. package/dist/agentic-config.js +35 -5
  5. package/dist/checks/agents-map.d.ts +9 -1
  6. package/dist/checks/agents-map.js +60 -17
  7. package/dist/checks/decision-records.d.ts +5 -0
  8. package/dist/checks/decision-records.js +121 -16
  9. package/dist/checks/file-size-ratchet.js +7 -5
  10. package/dist/checks/index.js +2 -0
  11. package/dist/checks/markdown-budget.d.ts +2 -5
  12. package/dist/checks/markdown-budget.js +6 -15
  13. package/dist/checks/markdown-refs.js +1 -11
  14. package/dist/checks/routed-docs.d.ts +6 -0
  15. package/dist/checks/routed-docs.js +49 -0
  16. package/dist/checks/tree.d.ts +2 -0
  17. package/dist/checks/tree.js +13 -3
  18. package/dist/cli.js +5 -1
  19. package/dist/commands/check.d.ts +1 -1
  20. package/dist/commands/check.js +6 -4
  21. package/dist/commands/gate.d.ts +10 -4
  22. package/dist/commands/gate.js +202 -86
  23. package/dist/commands/pr-reviewed.d.ts +2 -0
  24. package/dist/commands/pr-reviewed.js +92 -0
  25. package/dist/commands/pr-ship.d.ts +2 -0
  26. package/dist/commands/pr-ship.js +183 -0
  27. package/dist/commands/references-sync.js +5 -6
  28. package/dist/drift.d.ts +17 -0
  29. package/dist/drift.js +68 -0
  30. package/dist/gate-cache.d.ts +13 -0
  31. package/dist/gate-cache.js +39 -0
  32. package/dist/git-env.d.ts +2 -0
  33. package/dist/git-env.js +10 -0
  34. package/dist/markdown.d.ts +19 -0
  35. package/dist/markdown.js +46 -2
  36. package/dist/package-version.d.ts +2 -0
  37. package/dist/package-version.js +13 -0
  38. package/dist/pr/decide.d.ts +59 -0
  39. package/dist/pr/decide.js +250 -0
  40. package/dist/pr/read.d.ts +24 -0
  41. package/dist/pr/read.js +112 -0
  42. package/dist/pr/state.d.ts +54 -0
  43. package/dist/pr/state.js +220 -0
  44. package/dist/router-block.d.ts +4 -1
  45. package/dist/router-block.js +31 -7
  46. package/dist/run-command.d.ts +24 -0
  47. package/dist/run-command.js +121 -0
  48. package/dist/setup-gate.js +3 -12
  49. package/dist/working-tree.d.ts +13 -0
  50. package/dist/working-tree.js +60 -0
  51. package/package.json +2 -2
  52. package/templates/router.md +1 -0
@@ -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, its output passed through, stopping at the first that
14
- fails; then every Check under gate.checks, the run scripts and then the Checks the package ships,
15
- none stopping the next. Prints one line per failure: the file where there is one, the rule, the
16
- remedy; under GitHub Actions each line is also a workflow annotation. --json prints one document
17
- instead and sends the commands' own output to stderr.
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 file joins. Where there
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 cannot run: an
24
- invalid config layer, a Step or Check whose command the shell cannot find or execute, or a Check
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
- /** What the shell answers for a command it cannot find, and for one it cannot execute. */
28
- const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
29
- const SKIPPED = { status: "skipped", exitCode: null };
30
- /** One Check run by name says nothing before its result. */
31
- const QUIET = { write: () => true };
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
- stepNotRunnable: "install the command or fix gate.steps in the agentic config",
36
- checkNotRunnable: "install the command or fix gate.checks.run in the agentic config",
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
- return {
54
- help: values.help === true,
55
- json: values.json === true,
56
- root: values.root,
57
- updateBaseline: values["update-baseline"] === true
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
- * Runs `command` through `sh -c` from `root`, stdin closed and stderr passed through; stdout too, or
70
- * onto stderr when `--json` keeps stdout for the document. `detail` says what went wrong, when it did.
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 runCommand(command, root, json) {
73
- const result = spawnSync(command, { shell: true, cwd: root, stdio: ["ignore", json ? 2 : "inherit", "inherit"] });
74
- if (result.error !== undefined) {
75
- return { status: "error", exitCode: null, detail: `could not start: ${result.error.message}` };
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
- if (result.status === 0) {
78
- return { status: "pass", exitCode: 0 };
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
- if (result.status === null) {
81
- return { status: "fail", exitCode: null, detail: `was stopped by ${result.signal ?? "a signal"}` };
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
- const notRunnable = NOT_RUNNABLE[result.status];
84
- if (notRunnable !== undefined) {
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
- return { status: "fail", exitCode: result.status, detail: `exited ${result.status}` };
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 in `others` and every other
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, log) {
187
+ function refreshBaselines(judged, others, root) {
116
188
  if (!judged.some(({ check }) => check.baseline !== undefined)) {
117
- log.write("gate: no Ratchet is turned on, so no baseline to refresh\n");
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
- log.write(`gate: baseline ${file} not refreshed — a Step or another Check did not pass\n`);
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
- log.write(`gate: baseline ${file} ${refresh(root)}\n`);
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
- log.write(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}\n`);
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 log = only !== undefined ? QUIET : json ? process.stderr : process.stdout;
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
- /** Runs one Step or Check and records its failure under `rule`, if it has one. */
180
- const runOne = (command, rule, notRunnable) => {
181
- const { detail, ...outcome } = runCommand(command, root, json);
182
- if (detail !== undefined) {
183
- const remedy = outcome.status === "error" ? notRunnable : REMEDIES.failed;
184
- failures.push({ rule, message: `${JSON.stringify(command)} ${detail}`, remedy });
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 outcome;
282
+ return { status: ran.status, exitCode: ran.exitCode, tail: ran.tail };
187
283
  };
188
- const steps = [];
189
- let stopped = false;
190
- for (const step of selected.steps) {
191
- if (stopped) {
192
- steps.push({ command: step, ...SKIPPED });
193
- continue;
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
- log.write(`gate: step ${step}\n`);
196
- const outcome = runOne(step, "step", REMEDIES.stepNotRunnable);
197
- steps.push({ command: step, ...outcome });
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
- log.write(`gate: check ${check.name}\n`);
207
- checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
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
- log.write(`gate: check ${check.name}\n`);
314
+ progress.start("check", check.name);
214
315
  return runShipped(check, root);
215
316
  });
216
- if ("updateBaseline" in options && options.updateBaseline) {
217
- refreshBaselines(judged, [...steps, ...checks], root, log);
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
- return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
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, updateBaseline } = cli;
264
- return printReport(runGate(repositoryRoot(cli.root ?? process.cwd()), { json, updateBaseline }), json, "gate");
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,2 @@
1
+ export declare const summary = "pin a clean review to the head it read, as a commit status";
2
+ export declare function run(argv: string[]): number;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "decide the Action one pull request needs next, as a line or JSON";
2
+ export declare function run(argv: string[]): number;