dw-sdlc 0.6.0 → 0.7.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # dw-sdlc
2
2
 
3
3
  The Gate and the agentic config of a repository. `.ai/agentic.config.json` declares how a repository
4
- works for an agent: its package manager, protected branches, env-file policy, commit convention and
5
- the Gate. The package ships the `dw-sdlc` bin that runs on that file, its JSON Schema, and the
4
+ works for an agent: its package manager, protected branches, env-file policy, commit convention, the
5
+ Gate and who merges a pull request. The package ships the `dw-sdlc` bin that runs on that file, its JSON Schema, and the
6
6
  resolver of its layers.
7
7
 
8
8
  ## Install
@@ -38,6 +38,29 @@ Under `gate.checks`, a Check the package ships is `true` for its defaults or an
38
38
  `comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
39
39
  its `roots` to `maxProseLines` lines of prose, 3 by default.
40
40
 
41
+ ## The Ship script
42
+
43
+ `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
44
+ answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
45
+ whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
46
+ performs its own Actions: it updates a branch behind its base, marks a draft ready once nothing else
47
+ holds it back, and squash merges a pull request that is green, reviewed and opened by the `gh`
48
+ account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
49
+ script would perform as the dev's. It never deletes a branch; a merged pull request answers with
50
+ the delete command for the dev.
51
+
52
+ `pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
53
+ dev under `ask`, the default. The local layer may tighten it and never loosen it, and `--merge` acts
54
+ as `auto` for one call. A pull request the `gh` account did not open is always the dev's to merge.
55
+
56
+ ```json
57
+ { "pr": { "merge": "auto" } }
58
+ ```
59
+
60
+ `pr ship` answers `review` until the head carries the commit status `dw-sdlc/review`, which
61
+ `dw-sdlc pr reviewed <n> --head <sha>` posts after a clean review. It posts nothing and exits 2 once
62
+ a push moved the head, so a new head needs a fresh review.
63
+
41
64
  ## Commands
42
65
 
43
66
  | command | what it does |
@@ -50,6 +73,8 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
50
73
  | `config` | the resolved agentic config, or one file validated against the schema |
51
74
  | `references sync` | every declared Reference brought to its ref under `references/` |
52
75
  | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
76
+ | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
77
+ | `pr reviewed <n>` | a clean review pinned to the head it read, as a commit status |
53
78
 
54
79
  `dw-sdlc <command> --help` gives each one's options.
55
80
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/plugins/guardrails/config/agentic.config.schema.json",
4
4
  "title": "agentic.config",
5
- "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` but never loosen it. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
5
+ "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` and `pr.merge` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
6
6
  "type": "object",
7
7
  "properties": {
8
8
  "$schema": {
@@ -357,6 +357,17 @@
357
357
  },
358
358
  "additionalProperties": false
359
359
  },
360
+ "pr": {
361
+ "description": "How the Ship script, `dw-sdlc pr ship`, ends a pull request. The local override may tighten it and never loosen it.",
362
+ "type": "object",
363
+ "properties": {
364
+ "merge": {
365
+ "description": "Who merges a pull request that is green, reviewed and authored by the account `gh` runs as. `auto`: the script squash merges it, naming the head it read. `ask`: it parks with the merge command for the dev. Default `ask`, which an invalid layer runs too. Strictest first: ask, auto.",
366
+ "enum": ["ask", "auto"]
367
+ }
368
+ },
369
+ "additionalProperties": false
370
+ },
360
371
  "worktree": {
361
372
  "type": "object",
362
373
  "properties": {
@@ -15,6 +15,8 @@ export type EnvFiles = {
15
15
  allow: string[];
16
16
  deny: string[];
17
17
  };
18
+ /** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
19
+ export type PrMerge = "ask" | "auto";
18
20
  export type Guardrails = {
19
21
  forcePush: ForcePush;
20
22
  commitOnProtected: CommitOnProtected;
@@ -66,6 +68,9 @@ export type AgenticConfig = {
66
68
  trailer?: string;
67
69
  };
68
70
  gate?: GateLayer;
71
+ pr?: {
72
+ merge?: PrMerge;
73
+ };
69
74
  worktree?: {
70
75
  setup?: string[];
71
76
  };
@@ -85,6 +90,9 @@ export type ResolvedConfig = {
85
90
  trailer?: string;
86
91
  };
87
92
  gate: Gate;
93
+ pr: {
94
+ merge: PrMerge;
95
+ };
88
96
  worktree: {
89
97
  setup: string[];
90
98
  };
@@ -121,9 +129,12 @@ export declare const STRICTNESS: {
121
129
  readonly forcePush: readonly ["deny", "ask", "lease"];
122
130
  readonly commitOnProtected: readonly ["deny", "allow"];
123
131
  readonly envFiles: readonly ["deny", "git"];
132
+ readonly merge: readonly ["ask", "auto"];
124
133
  };
125
134
  /** What the guards run on while any layer is invalid. */
126
135
  export declare const STRICTEST_GUARDRAILS: Guardrails;
136
+ /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
137
+ export declare const STRICTEST_PR_MERGE: PrMerge;
127
138
  /** What a repository gets with nothing declared. */
128
139
  export declare const DEFAULT_GUARDRAILS: Guardrails;
129
140
  /**
@@ -15,7 +15,8 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
15
15
  export const STRICTNESS = {
16
16
  forcePush: ["deny", "ask", "lease"],
17
17
  commitOnProtected: ["deny", "allow"],
18
- envFiles: ["deny", "git"]
18
+ envFiles: ["deny", "git"],
19
+ merge: ["ask", "auto"]
19
20
  };
20
21
  /** What the guards run on while any layer is invalid. */
21
22
  export const STRICTEST_GUARDRAILS = {
@@ -24,6 +25,8 @@ export const STRICTEST_GUARDRAILS = {
24
25
  envFiles: { policy: "deny", allow: [], deny: [] },
25
26
  protectedBranches: ["master", "main", "production"]
26
27
  };
28
+ /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
29
+ export const STRICTEST_PR_MERGE = "ask";
27
30
  /** What a repository gets with nothing declared. */
28
31
  export const DEFAULT_GUARDRAILS = {
29
32
  forcePush: "lease",
@@ -190,6 +193,7 @@ export function inferConfig(root) {
190
193
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
191
194
  commit: { convention: "conventional" },
192
195
  gate: inferGate(root),
196
+ pr: { merge: STRICTEST_PR_MERGE },
193
197
  worktree: { setup: [] }
194
198
  };
195
199
  }
@@ -205,6 +209,7 @@ function applyLayer(base, layer) {
205
209
  },
206
210
  commit: { ...base.commit, ...layer.commit },
207
211
  gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
212
+ pr: { ...base.pr, ...layer.pr },
208
213
  worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
209
214
  };
210
215
  }
@@ -263,6 +268,14 @@ function loosenings(base, local) {
263
268
  }
264
269
  return problems;
265
270
  }
271
+ /** A local `pr.merge` less strict than the one it would override: `auto` over `ask`. */
272
+ function prLoosenings(base, local) {
273
+ if (local === undefined || rank("merge", local) <= rank("merge", base)) {
274
+ return [];
275
+ }
276
+ const message = `${show(local)} loosens ${show(base)}; the local file may tighten pr.merge, never loosen it`;
277
+ return [{ file: LOCAL_FILE, path: "pr.merge", message }];
278
+ }
266
279
  /** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
267
280
  function finalize(merged) {
268
281
  const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
@@ -284,7 +297,10 @@ export function resolveConfig(root) {
284
297
  config = applyLayer(config, committed.value);
285
298
  }
286
299
  if (local.value !== undefined) {
287
- const loosened = loosenings(config.guardrails, local.value.guardrails);
300
+ const loosened = [
301
+ ...loosenings(config.guardrails, local.value.guardrails),
302
+ ...prLoosenings(config.pr.merge, local.value.pr?.merge)
303
+ ];
288
304
  if (loosened.length > 0) {
289
305
  problems.push(...loosened);
290
306
  localState = "invalid";
@@ -294,7 +310,7 @@ export function resolveConfig(root) {
294
310
  }
295
311
  }
296
312
  if (problems.length > 0) {
297
- config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS) };
313
+ config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { merge: STRICTEST_PR_MERGE } };
298
314
  }
299
315
  return {
300
316
  config: finalize(config),
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import * as check from "./commands/check.js";
5
5
  import * as config from "./commands/config.js";
6
6
  import * as doctor from "./commands/doctor.js";
7
7
  import * as gate from "./commands/gate.js";
8
+ import * as prReviewed from "./commands/pr-reviewed.js";
9
+ import * as prShip from "./commands/pr-ship.js";
8
10
  import * as primitives from "./commands/primitives.js";
9
11
  import * as referencesSetup from "./commands/references-setup.js";
10
12
  import * as referencesSync from "./commands/references-sync.js";
@@ -17,7 +19,9 @@ const COMMANDS = new Map([
17
19
  ["doctor", doctor],
18
20
  ["config", config],
19
21
  ["references sync", referencesSync],
20
- ["references setup", referencesSetup]
22
+ ["references setup", referencesSetup],
23
+ ["pr ship", prShip],
24
+ ["pr reviewed", prReviewed]
21
25
  ]);
22
26
  function usage() {
23
27
  const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
@@ -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,108 @@
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 { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
5
+ export const summary = "pin a clean review to the head it read, as a commit status";
6
+ const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--json]
7
+
8
+ Reads pull request <n> through gh, the read pr ship makes, and when its head is still <sha> posts
9
+ the commit status dw-sdlc/review in state success on it, the pull request's URL as its target. When
10
+ a push moved the head since the review, it posts nothing. One line, or one document with --json.
11
+ Needs an authenticated gh.
12
+
13
+ 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
+ const DESCRIPTION = "a clean review of this head";
18
+ function parseCli(argv) {
19
+ try {
20
+ const { values, positionals } = parseArgs({
21
+ args: argv,
22
+ allowPositionals: true,
23
+ options: {
24
+ help: { type: "boolean", short: "h" },
25
+ json: { type: "boolean" },
26
+ repo: { type: "string", short: "R" },
27
+ head: { type: "string" }
28
+ }
29
+ });
30
+ if (values.help === true) {
31
+ return "help";
32
+ }
33
+ const [number, ...more] = positionals;
34
+ const { repo, head } = values;
35
+ if (number === undefined) {
36
+ return { error: "name the pull request: dw-sdlc pr reviewed <n> --head <sha>" };
37
+ }
38
+ if (more.length > 0) {
39
+ return { error: `name one pull request, not ${positionals.length}` };
40
+ }
41
+ const problem = namedProblem(number, repo);
42
+ if (problem !== undefined) {
43
+ return { error: problem };
44
+ }
45
+ if (head === undefined) {
46
+ return { error: "name the head the review read: --head <sha>" };
47
+ }
48
+ if (!/^[0-9a-f]{40}$/.test(head)) {
49
+ return { error: `--head takes the full 40-character sha, not ${JSON.stringify(head)}` };
50
+ }
51
+ return { json: values.json === true, number: Number(number), repo, head };
52
+ }
53
+ catch (error) {
54
+ return { error: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ }
57
+ /** Posts the review status on `head` once the read shows it is still the pull request's head. */
58
+ function post({ number, repo, head }) {
59
+ const pr = read({ number, repo }, "pr reviewed");
60
+ if (pr.head !== head) {
61
+ 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
+ }
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
+ ]);
78
+ if (posted.status !== 0) {
79
+ throw new CannotRun(`${pr.repo}#${number}: posting ${CONTEXT} failed: ${said(posted.stderr)}`, `check that gh's token may write commit statuses to ${pr.repo}, then call again`);
80
+ }
81
+ }
82
+ export function run(argv) {
83
+ const cli = parseCli(argv);
84
+ if (cli === "help") {
85
+ process.stdout.write(`${USAGE}\n`);
86
+ return 0;
87
+ }
88
+ if ("error" in cli) {
89
+ process.stderr.write(`dw-sdlc pr reviewed: ${cli.error}\n\n${USAGE}\n`);
90
+ return 2;
91
+ }
92
+ try {
93
+ post(cli);
94
+ }
95
+ catch (error) {
96
+ if (error instanceof CannotRun) {
97
+ process.stderr.write(`dw-sdlc pr reviewed: ${error.message} — ${error.remedy}\n`);
98
+ return 2;
99
+ }
100
+ throw error;
101
+ }
102
+ const { head } = cli;
103
+ const answer = cli.json
104
+ ? JSON.stringify({ head, context: CONTEXT, state: STATE }, null, 2)
105
+ : `posted ${CONTEXT} ${STATE} on ${head}`;
106
+ process.stdout.write(`${answer}\n`);
107
+ return 0;
108
+ }
@@ -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;
@@ -0,0 +1,163 @@
1
+ // `dw-sdlc pr ship [<n>]`, the Ship script: reads one pull request through gh, decides the Action it
2
+ // needs next, performs it when it is the script's and reads again, then answers the first Action that
3
+ // is not, as a line or one JSON document, with an exit code that names whose turn it is.
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";
7
+ import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
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]
10
+
11
+ Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
12
+ is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
13
+ The script performs its own Actions, each at most once and then reads again: it updates a branch
14
+ behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
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. --dry-run performs nothing: it parks at the
17
+ first Action the script would perform, listed under performed. One line, or one document with
18
+ --json; --verbose adds the state as read to the document under facts. Needs an authenticated gh.
19
+
20
+ Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
21
+ const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
22
+ const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
23
+ function parseCli(argv) {
24
+ try {
25
+ const { values, positionals } = parseArgs({
26
+ args: argv,
27
+ allowPositionals: true,
28
+ options: {
29
+ help: { type: "boolean", short: "h" },
30
+ json: { type: "boolean" },
31
+ verbose: { type: "boolean" },
32
+ merge: { type: "boolean" },
33
+ "dry-run": { type: "boolean" },
34
+ repo: { type: "string", short: "R" }
35
+ }
36
+ });
37
+ const [number, ...more] = positionals;
38
+ const { repo } = values;
39
+ if (values.help !== true) {
40
+ if (more.length > 0) {
41
+ return { error: `name one pull request, not ${positionals.length}` };
42
+ }
43
+ const problem = namedProblem(number, repo);
44
+ if (problem !== undefined) {
45
+ return { error: problem };
46
+ }
47
+ }
48
+ return {
49
+ help: values.help === true,
50
+ json: values.json === true,
51
+ verbose: values.verbose === true,
52
+ merge: values.merge === true,
53
+ dryRun: values["dry-run"] === true,
54
+ number: number === undefined ? undefined : Number(number),
55
+ repo
56
+ };
57
+ }
58
+ catch (error) {
59
+ return { error: error instanceof Error ? error.message : String(error) };
60
+ }
61
+ }
62
+ /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
63
+ function now() {
64
+ const given = process.env.DW_SDLC_NOW?.trim();
65
+ if (given === undefined || given === "") {
66
+ return Date.now();
67
+ }
68
+ const time = Date.parse(given);
69
+ if (Number.isNaN(time)) {
70
+ throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
71
+ }
72
+ return time;
73
+ }
74
+ /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
75
+ const NAMED = {
76
+ "update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
77
+ "mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
78
+ merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
79
+ };
80
+ /** 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) {
82
+ if (action === "merge") {
83
+ return { ...readyToMerge(pr, reason), blockers: answer.blockers };
84
+ }
85
+ return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action) };
86
+ }
87
+ /**
88
+ * Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
89
+ * pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
90
+ */
91
+ function ship(cli, merge) {
92
+ let pr = read(cli, "pr ship");
93
+ const performed = [];
94
+ const refusals = {};
95
+ for (;;) {
96
+ const answer = decide(pr, now(), merge);
97
+ const { action } = answer;
98
+ if (!isScriptAction(action)) {
99
+ return { pr, answer, performed };
100
+ }
101
+ // -R names the repository, so the checkout the script runs in plays no part.
102
+ const args = hostArgs(pr, action, true);
103
+ const command = shellLine(["gh", ...args]);
104
+ const { would, act, still } = NAMED[action];
105
+ if (cli.dryRun) {
106
+ performed.push({ action, command, ok: null });
107
+ return {
108
+ pr,
109
+ answer: handedOver(pr, answer, action, `the script would ${would}; --dry-run performs nothing`),
110
+ performed
111
+ };
112
+ }
113
+ if (performed.some((done) => done.action === action)) {
114
+ const refusal = refusals[action];
115
+ const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
116
+ return { pr, answer: handedOver(pr, answer, action, reason), performed };
117
+ }
118
+ const result = gh(args);
119
+ performed.push({ action, command, ok: result.status === 0 });
120
+ if (result.status !== 0) {
121
+ refusals[action] = said(result.stderr) || `gh exited ${result.status}`;
122
+ }
123
+ pr = readAgain(pr);
124
+ }
125
+ }
126
+ /** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
127
+ function lineOf({ status, action, reason, command, recheckAfter }) {
128
+ const label = status === action ? status : `${status} ${action}`;
129
+ const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
130
+ return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
131
+ }
132
+ function documentOf(pr, answer, performed, verbose) {
133
+ const facts = verbose ? { facts: pr } : {};
134
+ return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
135
+ }
136
+ export function run(argv) {
137
+ const cli = parseCli(argv);
138
+ if ("error" in cli) {
139
+ process.stderr.write(`dw-sdlc pr ship: ${cli.error}\n\n${USAGE}\n`);
140
+ return 2;
141
+ }
142
+ if (cli.help) {
143
+ process.stdout.write(`${USAGE}\n`);
144
+ return 0;
145
+ }
146
+ // An invalid config layer resolves to ask, like every other strictest setting.
147
+ const merge = cli.merge ? "auto" : resolveConfig(repositoryRoot(process.cwd())).config.pr.merge;
148
+ let shipped;
149
+ try {
150
+ shipped = ship(cli, merge);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof CannotRun) {
154
+ process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n`);
155
+ return 2;
156
+ }
157
+ throw error;
158
+ }
159
+ const { pr, answer, performed } = shipped;
160
+ const output = cli.json ? JSON.stringify(documentOf(pr, answer, performed, cli.verbose), null, 2) : lineOf(answer);
161
+ process.stdout.write(`${output}\n`);
162
+ return EXIT[answer.status];
163
+ }
@@ -0,0 +1,36 @@
1
+ import type { PrMerge } from "../agentic-config.ts";
2
+ import type { PrState } from "./state.ts";
3
+ export type Status = "act" | "wait" | "park" | "done";
4
+ export type Actor = "script" | "agent" | "dev";
5
+ export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-approval" | "ready-to-merge" | "merge";
6
+ /** The Actions the script performs itself, each one gh command. */
7
+ export type ScriptAction = "update-branch" | "mark-ready" | "merge";
8
+ /** An Action the pull request needs beside the one its answer names, so one push can fix several. */
9
+ export type Blocker = {
10
+ action: Action;
11
+ actor: Actor;
12
+ reason: string;
13
+ details?: Record<string, unknown>;
14
+ };
15
+ export type Answer = Blocker & {
16
+ status: Status;
17
+ /** A shell-quoted command, when there is one to run. */
18
+ command?: string;
19
+ /** Seconds to wait before calling again. */
20
+ recheckAfter?: number;
21
+ blockers: Blocker[];
22
+ };
23
+ /** The words as one POSIX shell command line, each word that needs it single-quoted. */
24
+ export declare function shellLine(words: string[]): string;
25
+ export declare function isScriptAction(action: Action): action is ScriptAction;
26
+ /**
27
+ * The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
28
+ * base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
29
+ */
30
+ export declare function hostArgs(pr: PrState, action: ScriptAction, named: boolean): string[];
31
+ /** The command a script Action leaves the dev to run, from a checkout of the repository. */
32
+ export declare function devCommand(pr: PrState, action: ScriptAction): string;
33
+ /** The merge left to the dev, `reason` saying why it is theirs. */
34
+ export declare function readyToMerge(pr: PrState, reason: string): Omit<Answer, "blockers">;
35
+ /** The Action `pr` needs at `now`, milliseconds since the epoch, when `merge` says who merges it. */
36
+ export declare function decide(pr: PrState, now: number, merge: PrMerge): Answer;
@@ -0,0 +1,216 @@
1
+ // The decision of the Ship script: one ordered list of rows, and the first row whose `when` holds for
2
+ // the pull request answers its Action at the time given; every later row that holds rides in its
3
+ // blockers. The last rows say who merges what no row before them holds back.
4
+ /** How long a head may carry no check before it parks as unverified, from its commit. */
5
+ const GRACE_SECONDS = 120;
6
+ /** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
7
+ const BUDGET_MINUTES = 40;
8
+ /** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
9
+ const OWN_PREFIX = "dw-sdlc/";
10
+ /** The commit status a clean review of the head leaves on it. */
11
+ const REVIEW = "dw-sdlc/review";
12
+ /** The words as one POSIX shell command line, each word that needs it single-quoted. */
13
+ export function shellLine(words) {
14
+ return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
15
+ }
16
+ function ciOf(pr) {
17
+ return pr.checks.filter((check) => !check.name.startsWith(OWN_PREFIX));
18
+ }
19
+ function pendingOf(pr) {
20
+ return ciOf(pr).filter((check) => check.bucket === "pending");
21
+ }
22
+ function failingOf(pr) {
23
+ return ciOf(pr).filter((check) => check.bucket === "fail");
24
+ }
25
+ /** A failing check as the agent reads it: its link, and the command that prints a GitHub Actions job's log. */
26
+ function failure({ name, link, log }) {
27
+ return { name, link, ...(log === null ? {} : { log: shellLine(log) }) };
28
+ }
29
+ /** The unresolved threads whose last comment is the viewer's, or with `false` someone else's. */
30
+ function threads(pr, lastByViewer) {
31
+ return pr.unresolvedThreads.filter((thread) => thread.lastByViewer === lastByViewer);
32
+ }
33
+ function listed({ id, path, line, author, body }) {
34
+ return { id, path, line, author, body };
35
+ }
36
+ const HOST = {
37
+ "update-branch": { verb: "update-branch", flags: () => [] },
38
+ "mark-ready": { verb: "ready", flags: () => [] },
39
+ merge: { verb: "merge", flags: (pr) => ["--squash", "--match-head-commit", pr.head] }
40
+ };
41
+ export function isScriptAction(action) {
42
+ return Object.hasOwn(HOST, action);
43
+ }
44
+ /**
45
+ * The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
46
+ * base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
47
+ */
48
+ export function hostArgs(pr, action, named) {
49
+ const { verb, flags } = HOST[action];
50
+ return ["pr", verb, String(pr.number), ...(named ? ["-R", pr.repo] : []), ...flags(pr)];
51
+ }
52
+ /** The command a script Action leaves the dev to run, from a checkout of the repository. */
53
+ export function devCommand(pr, action) {
54
+ return shellLine(["gh", ...hostArgs(pr, action, false)]);
55
+ }
56
+ /** The merge left to the dev, `reason` saying why it is theirs. */
57
+ export function readyToMerge(pr, reason) {
58
+ return { status: "park", action: "ready-to-merge", actor: "dev", reason, command: devCommand(pr, "merge") };
59
+ }
60
+ const ROWS = [
61
+ {
62
+ when: (pr) => pr.state === "MERGED",
63
+ answer: (pr) => ({
64
+ status: "done",
65
+ action: "done",
66
+ actor: "dev",
67
+ reason: pr.mergeCommit === null ? "merged" : `merged as ${pr.mergeCommit.slice(0, 7)}`,
68
+ command: shellLine(["git", "push", "origin", "--delete", pr.branch]),
69
+ ...(pr.mergeCommit === null ? {} : { details: { mergeCommit: pr.mergeCommit } })
70
+ }),
71
+ alone: true
72
+ },
73
+ {
74
+ when: (pr) => pr.state === "CLOSED",
75
+ answer: () => ({ status: "park", action: "stop", actor: "dev", reason: "closed without merging" }),
76
+ alone: true
77
+ },
78
+ {
79
+ when: (pr) => pr.mergeable === "UNKNOWN",
80
+ answer: () => ({
81
+ status: "wait",
82
+ action: "wait",
83
+ actor: "script",
84
+ reason: "mergeability-unknown",
85
+ recheckAfter: 15
86
+ }),
87
+ alone: true
88
+ },
89
+ {
90
+ when: (pr) => pr.mergeable === "CONFLICTING",
91
+ answer: (pr) => ({
92
+ status: "act",
93
+ action: "fix-conflicts",
94
+ actor: "agent",
95
+ reason: `conflicts with ${pr.base}`,
96
+ details: { base: pr.base }
97
+ })
98
+ },
99
+ {
100
+ when: (pr) => pr.mergeStateStatus === "BEHIND",
101
+ answer: (pr) => ({ status: "act", action: "update-branch", actor: "script", reason: `behind ${pr.base}` })
102
+ },
103
+ {
104
+ when: (pr) => failingOf(pr).length > 0,
105
+ answer: (pr) => ({
106
+ status: "act",
107
+ action: "fix-ci",
108
+ actor: "agent",
109
+ reason: `${failingOf(pr).length} check(s) failing`,
110
+ details: { checks: failingOf(pr).map(failure) }
111
+ })
112
+ },
113
+ {
114
+ when: (pr) => threads(pr, false).length > 0,
115
+ answer: (pr) => ({
116
+ status: "act",
117
+ action: "address-review",
118
+ actor: "agent",
119
+ reason: `${threads(pr, false).length} unresolved thread(s)`,
120
+ details: { threads: threads(pr, false).map(listed) }
121
+ })
122
+ },
123
+ {
124
+ when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED",
125
+ answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
126
+ },
127
+ {
128
+ when: (pr) => !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
129
+ answer: (pr) => ({
130
+ status: "act",
131
+ action: "review",
132
+ actor: "agent",
133
+ reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}`
134
+ })
135
+ },
136
+ {
137
+ when: (pr) => ciOf(pr).length === 0,
138
+ answer: (pr, now) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
139
+ ? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
140
+ : {
141
+ status: "park",
142
+ action: "unverified",
143
+ actor: "dev",
144
+ reason: `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`,
145
+ command: devCommand(pr, "merge")
146
+ }
147
+ },
148
+ {
149
+ when: (pr) => pendingOf(pr).length > 0,
150
+ answer: (pr, now) => {
151
+ const pending = pendingOf(pr);
152
+ // A check queued and not yet started counts from the head's commit.
153
+ const start = Math.min(...pending.map((check) => Date.parse(check.startedAt ?? pr.headCommittedAt)));
154
+ const minutes = Math.floor((now - start) / 60_000);
155
+ return now - start <= BUDGET_MINUTES * 60_000
156
+ ? { status: "wait", action: "wait", actor: "script", reason: "ci-pending", recheckAfter: 60 }
157
+ : {
158
+ status: "park",
159
+ action: "ci-stuck",
160
+ actor: "dev",
161
+ reason: `${pending.length} check(s) pending for ${minutes} min, past the ${BUDGET_MINUTES} min budget`
162
+ };
163
+ }
164
+ },
165
+ {
166
+ when: (pr) => threads(pr, true).length > 0,
167
+ answer: (pr) => ({
168
+ status: "park",
169
+ action: "awaiting-reply",
170
+ actor: "dev",
171
+ reason: `${threads(pr, true).length} thread(s) await a reply`,
172
+ details: { threads: threads(pr, true).map(listed) }
173
+ })
174
+ },
175
+ {
176
+ when: (pr) => pr.draft,
177
+ answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
178
+ },
179
+ {
180
+ when: (pr) => pr.reviewDecision === "REVIEW_REQUIRED",
181
+ answer: () => ({
182
+ status: "park",
183
+ action: "awaiting-approval",
184
+ actor: "dev",
185
+ reason: "an approving review is required"
186
+ })
187
+ },
188
+ {
189
+ when: (pr) => !pr.viewerDidAuthor,
190
+ answer: (pr) => readyToMerge(pr, "another author's pull request: the merge is the dev's"),
191
+ merging: true
192
+ },
193
+ {
194
+ when: (_, merge) => merge === "auto",
195
+ answer: () => ({ status: "act", action: "merge", actor: "script", reason: "green, reviewed and the viewer's own" }),
196
+ merging: true
197
+ },
198
+ {
199
+ when: (_, merge) => merge === "ask",
200
+ answer: (pr) => readyToMerge(pr, "pr.merge is ask: the merge is the dev's"),
201
+ merging: true
202
+ }
203
+ ];
204
+ function blockerOf({ action, actor, reason, details }) {
205
+ return { action, actor, reason, ...(details === undefined ? {} : { details }) };
206
+ }
207
+ /** The Action `pr` needs at `now`, milliseconds since the epoch, when `merge` says who merges it. */
208
+ export function decide(pr, now, merge) {
209
+ const index = ROWS.findIndex((row) => row.when(pr, merge));
210
+ const row = ROWS[index];
211
+ if (row === undefined) {
212
+ throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${merge}`);
213
+ }
214
+ const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr, merge));
215
+ return { ...row.answer(pr, now), blockers: later.map((other) => blockerOf(other.answer(pr, now))) };
216
+ }
@@ -0,0 +1,24 @@
1
+ import { type PrState } from "./state.ts";
2
+ /** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
3
+ export declare class CannotRun extends Error {
4
+ remedy: string;
5
+ constructor(message: string, remedy: string);
6
+ }
7
+ /** The pull request a command line names; what it leaves out, `gh pr view` resolves. */
8
+ export type Named = {
9
+ number: number | undefined;
10
+ repo: string | undefined;
11
+ };
12
+ /** The problem with `<n>` or `-R` as given on a command line, if there is one. */
13
+ export declare function namedProblem(number: string | undefined, repo: string | undefined): string | undefined;
14
+ /** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
15
+ export declare function said(stderr: string): string;
16
+ export declare function gh(args: string[]): {
17
+ status: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ };
21
+ /** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
22
+ export declare function read(named: Named, command: string): PrState;
23
+ /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
24
+ export declare function readAgain(pr: PrState): PrState;
@@ -0,0 +1,112 @@
1
+ // The read both pr commands make: gh as a child process, its auth on the host gh api reads from, the
2
+ // pull request a command names, and that pull request's state from one GraphQL request.
3
+ import { spawnSync } from "node:child_process";
4
+ import { parsePr, parseView, QUERY, ReplyError } from "./state.js";
5
+ /** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
6
+ export class CannotRun extends Error {
7
+ remedy;
8
+ constructor(message, remedy) {
9
+ super(message);
10
+ this.remedy = remedy;
11
+ }
12
+ }
13
+ const REMEDIES = {
14
+ missing: "install the GitHub CLI from https://cli.github.com",
15
+ auth: "run gh auth login, or set GH_TOKEN",
16
+ target: "check the number, or name the repository with -R owner/name",
17
+ read: "check gh auth status and the network, then call again",
18
+ reply: "gh answered a shape dw-sdlc does not read; report it with the output of gh --version"
19
+ };
20
+ /** The problem with `<n>` or `-R` as given on a command line, if there is one. */
21
+ export function namedProblem(number, repo) {
22
+ if (number !== undefined && !/^[1-9]\d*$/.test(number)) {
23
+ return `${JSON.stringify(number)} is not a pull request number`;
24
+ }
25
+ if (repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
26
+ return `-R takes owner/name, not ${JSON.stringify(repo)}`;
27
+ }
28
+ return undefined;
29
+ }
30
+ function firstLine(text) {
31
+ return text.trim().split("\n")[0] ?? "";
32
+ }
33
+ /** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
34
+ export function said(stderr) {
35
+ return firstLine(stderr).replace(/^(?:gh: |X )/, "");
36
+ }
37
+ /** The problem `gh auth status` reports: its line marked `X` under the host, else its first line. */
38
+ function authProblem(stderr) {
39
+ const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
40
+ return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
41
+ }
42
+ export function gh(args) {
43
+ const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
44
+ if (result.error !== undefined) {
45
+ if ("code" in result.error && result.error.code === "ENOENT") {
46
+ throw new CannotRun("gh is not on PATH", REMEDIES.missing);
47
+ }
48
+ throw result.error;
49
+ }
50
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
51
+ }
52
+ /** What `parse` returns; a reply it cannot parse stops the command, naming the field. */
53
+ function parseOrCannotRun(parse) {
54
+ try {
55
+ return parse();
56
+ }
57
+ catch (error) {
58
+ if (error instanceof ReplyError) {
59
+ throw new CannotRun(`the reply does not parse: ${error.message}`, REMEDIES.reply);
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+ /** The pull request named in full, or the one `gh pr view` resolves from what is given. */
65
+ function targetOf({ number, repo }, command) {
66
+ if (number !== undefined && repo !== undefined) {
67
+ return { repo, number };
68
+ }
69
+ const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
70
+ const view = gh(["pr", "view", ...named, "--json", "url"]);
71
+ if (view.status !== 0) {
72
+ const remedy = `name it: dw-sdlc ${command} <n> -R owner/name`;
73
+ throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, remedy);
74
+ }
75
+ return parseOrCannotRun(() => parseView(view.stdout));
76
+ }
77
+ /** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
78
+ export function read(named, command) {
79
+ // The host gh api reads from: an account on any other host has no say in this call.
80
+ const host = process.env.GH_HOST?.trim() || "github.com";
81
+ const auth = gh(["auth", "status", "--active", "--hostname", host]);
82
+ if (auth.status !== 0) {
83
+ throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
84
+ }
85
+ return query(targetOf(named, command));
86
+ }
87
+ /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
88
+ export function readAgain(pr) {
89
+ return query(pr);
90
+ }
91
+ /** The state of pull request `number` of `repo`, from one GraphQL request. */
92
+ function query({ repo, number }) {
93
+ const [owner = "", name = ""] = repo.split("/");
94
+ const reply = gh([
95
+ "api",
96
+ "graphql",
97
+ "-f",
98
+ `query=${QUERY}`,
99
+ "-f",
100
+ `owner=${owner}`,
101
+ "-f",
102
+ `name=${name}`,
103
+ "-F",
104
+ `number=${number}`
105
+ ]);
106
+ if (reply.status !== 0) {
107
+ const problem = said(reply.stderr);
108
+ const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
109
+ throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
110
+ }
111
+ return parseOrCannotRun(() => parsePr(reply.stdout, repo));
112
+ }
@@ -0,0 +1,54 @@
1
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n }\n }\n}";
2
+ export type Bucket = "pass" | "fail" | "pending" | "skipping";
3
+ export type CiCheck = {
4
+ name: string;
5
+ bucket: Bucket;
6
+ required: boolean;
7
+ link: string | null;
8
+ /** The command that prints a failed GitHub Actions job's log; null for any other CI. */
9
+ log: string[] | null;
10
+ startedAt: string | null;
11
+ };
12
+ export type Thread = {
13
+ id: string;
14
+ path: string;
15
+ line: number | null;
16
+ author: string;
17
+ body: string;
18
+ /** Whether the viewer, the account gh reads as, wrote the thread's last comment. */
19
+ lastByViewer: boolean;
20
+ };
21
+ declare const STATES: readonly ["OPEN", "CLOSED", "MERGED"];
22
+ declare const MERGEABLE: readonly ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
23
+ declare const REVIEW_DECISIONS: readonly ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
24
+ export type PrState = {
25
+ url: string;
26
+ repo: string;
27
+ number: number;
28
+ state: (typeof STATES)[number];
29
+ draft: boolean;
30
+ mergeable: (typeof MERGEABLE)[number];
31
+ /** BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN or UNSTABLE, as GitHub reports it. */
32
+ mergeStateStatus: string;
33
+ reviewDecision: (typeof REVIEW_DECISIONS)[number] | null;
34
+ viewerDidAuthor: boolean;
35
+ head: string;
36
+ headCommittedAt: string;
37
+ branch: string;
38
+ base: string;
39
+ mergeCommit: string | null;
40
+ checks: CiCheck[];
41
+ /** The threads neither resolved nor outdated. */
42
+ unresolvedThreads: Thread[];
43
+ };
44
+ /** A reply that is not the shape the query asks for; the message names the field. */
45
+ export declare class ReplyError extends Error {
46
+ }
47
+ /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
48
+ export declare function parseView(reply: string): {
49
+ repo: string;
50
+ number: number;
51
+ };
52
+ /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
53
+ export declare function parsePr(reply: string, repo: string): PrState;
54
+ export {};
@@ -0,0 +1,220 @@
1
+ // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
+ // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
3
+ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
4
+ repository(owner: $owner, name: $name) {
5
+ pullRequest(number: $number) {
6
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
7
+ viewerDidAuthor mergeCommit { oid }
8
+ reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
9
+ comments(first: 1) { nodes { author { login } body } }
10
+ latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
11
+ commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
12
+ contexts(first: 50) { nodes {
13
+ ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
14
+ ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
15
+ }
16
+ }
17
+ }`;
18
+ const STATES = ["OPEN", "CLOSED", "MERGED"];
19
+ const MERGEABLE = ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
20
+ const REVIEW_DECISIONS = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
21
+ /** A reply that is not the shape the query asks for; the message names the field. */
22
+ export class ReplyError extends Error {
23
+ }
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function shown(value) {
28
+ return value === undefined ? "missing" : JSON.stringify(value);
29
+ }
30
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
31
+ function rootOf(reply, what) {
32
+ let json;
33
+ try {
34
+ json = JSON.parse(reply);
35
+ }
36
+ catch {
37
+ throw new ReplyError(`${what} is not JSON`);
38
+ }
39
+ if (!isRecord(json)) {
40
+ throw new ReplyError(`${what} is not a JSON object`);
41
+ }
42
+ return { value: json, at: "" };
43
+ }
44
+ function fieldOf(node, key) {
45
+ return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
46
+ }
47
+ function object(value, at) {
48
+ if (!isRecord(value)) {
49
+ throw new ReplyError(`${at} is ${shown(value)}, not an object`);
50
+ }
51
+ return { value, at };
52
+ }
53
+ function child(node, key) {
54
+ const { value, at } = fieldOf(node, key);
55
+ return object(value, at);
56
+ }
57
+ function childOrNull(node, key) {
58
+ const { value, at } = fieldOf(node, key);
59
+ return value === null ? null : object(value, at);
60
+ }
61
+ function string(node, key) {
62
+ const { value, at } = fieldOf(node, key);
63
+ if (typeof value !== "string") {
64
+ throw new ReplyError(`${at} is ${shown(value)}, not a string`);
65
+ }
66
+ return value;
67
+ }
68
+ function stringOrNull(node, key) {
69
+ return node.value[key] === null ? null : string(node, key);
70
+ }
71
+ function boolean(node, key) {
72
+ const { value, at } = fieldOf(node, key);
73
+ if (typeof value !== "boolean") {
74
+ throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
75
+ }
76
+ return value;
77
+ }
78
+ function integer(node, key) {
79
+ const { value, at } = fieldOf(node, key);
80
+ if (typeof value !== "number" || !Number.isInteger(value)) {
81
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
82
+ }
83
+ return value;
84
+ }
85
+ function integerOrNull(node, key) {
86
+ const { value, at } = fieldOf(node, key);
87
+ if (value !== null && !Number.isInteger(value)) {
88
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
89
+ }
90
+ return typeof value === "number" ? value : null;
91
+ }
92
+ /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
93
+ function time(node, key) {
94
+ const value = string(node, key);
95
+ if (Number.isNaN(Date.parse(value))) {
96
+ throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
97
+ }
98
+ return value;
99
+ }
100
+ function timeOrNull(node, key) {
101
+ return node.value[key] === null ? null : time(node, key);
102
+ }
103
+ function isOneOf(values, value) {
104
+ return values.some((known) => known === value);
105
+ }
106
+ function oneOf(node, key, values) {
107
+ const { value, at } = fieldOf(node, key);
108
+ if (!isOneOf(values, value)) {
109
+ throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
110
+ }
111
+ return value;
112
+ }
113
+ /** The objects of a connection's `nodes`. */
114
+ function nodes(node, key) {
115
+ const { value, at } = fieldOf(child(node, key), "nodes");
116
+ if (!Array.isArray(value)) {
117
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
118
+ }
119
+ return value.map((item, index) => object(item, `${at}[${index}]`));
120
+ }
121
+ /** The first object of a connection's `nodes`; an empty connection does not parse. */
122
+ function firstNode(node, key) {
123
+ const [first] = nodes(node, key);
124
+ if (first === undefined) {
125
+ throw new ReplyError(`${node.at}.${key}.nodes is empty`);
126
+ }
127
+ return first;
128
+ }
129
+ const FAIL = new Set(["ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
130
+ const SKIPPING = new Set(["SKIPPED", "NEUTRAL"]);
131
+ /** A check's bucket as `gh pr checks` sorts it: every word not passed, failed or skipped is pending. */
132
+ function bucketOf(word) {
133
+ if (word === "SUCCESS") {
134
+ return "pass";
135
+ }
136
+ if (word !== null && FAIL.has(word)) {
137
+ return "fail";
138
+ }
139
+ return word !== null && SKIPPING.has(word) ? "skipping" : "pending";
140
+ }
141
+ function logCommand(link, repo) {
142
+ const job = link?.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1];
143
+ return job === undefined ? null : ["gh", "run", "view", "--job", job, "-R", repo, "--log-failed"];
144
+ }
145
+ /** A CheckRun has a name and a status; a StatusContext has a context and a state. */
146
+ function checkOf(context, repo) {
147
+ if ("name" in context.value) {
148
+ const status = string(context, "status");
149
+ const link = stringOrNull(context, "detailsUrl");
150
+ return {
151
+ name: string(context, "name"),
152
+ bucket: bucketOf(status === "COMPLETED" ? stringOrNull(context, "conclusion") : status),
153
+ required: boolean(context, "isRequired"),
154
+ link,
155
+ log: logCommand(link, repo),
156
+ startedAt: timeOrNull(context, "startedAt")
157
+ };
158
+ }
159
+ if ("context" in context.value) {
160
+ const link = stringOrNull(context, "targetUrl");
161
+ return {
162
+ name: string(context, "context"),
163
+ bucket: bucketOf(string(context, "state")),
164
+ required: boolean(context, "isRequired"),
165
+ link,
166
+ log: logCommand(link, repo),
167
+ startedAt: time(context, "createdAt")
168
+ };
169
+ }
170
+ throw new ReplyError(`${context.at} is neither a CheckRun nor a StatusContext`);
171
+ }
172
+ /** A thread by its opening comment; a deleted account's comment has no author and reads as GitHub's ghost. */
173
+ function threadOf(thread) {
174
+ const first = firstNode(thread, "comments");
175
+ const author = childOrNull(first, "author");
176
+ return {
177
+ id: string(thread, "id"),
178
+ path: string(thread, "path"),
179
+ line: integerOrNull(thread, "line"),
180
+ author: author === null ? "ghost" : string(author, "login"),
181
+ body: string(first, "body"),
182
+ lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
183
+ };
184
+ }
185
+ /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
186
+ export function parseView(reply) {
187
+ const url = string(rootOf(reply, "the reply of gh pr view"), "url");
188
+ const [, repo, number] = /^https:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)$/.exec(url) ?? [];
189
+ if (repo === undefined || number === undefined) {
190
+ throw new ReplyError(`url is ${JSON.stringify(url)}, not a pull request's URL`);
191
+ }
192
+ return { repo, number: Number(number) };
193
+ }
194
+ /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
195
+ export function parsePr(reply, repo) {
196
+ const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
197
+ const commit = child(firstNode(pr, "commits"), "commit");
198
+ const rollup = childOrNull(commit, "statusCheckRollup");
199
+ const mergeCommit = childOrNull(pr, "mergeCommit");
200
+ return {
201
+ url: string(pr, "url"),
202
+ repo,
203
+ number: integer(pr, "number"),
204
+ state: oneOf(pr, "state", STATES),
205
+ draft: boolean(pr, "isDraft"),
206
+ mergeable: oneOf(pr, "mergeable", MERGEABLE),
207
+ mergeStateStatus: string(pr, "mergeStateStatus"),
208
+ reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
209
+ viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
210
+ head: string(pr, "headRefOid"),
211
+ headCommittedAt: time(commit, "committedDate"),
212
+ branch: string(pr, "headRefName"),
213
+ base: string(pr, "baseRefName"),
214
+ mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
215
+ checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
216
+ unresolvedThreads: nodes(pr, "reviewThreads")
217
+ .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
218
+ .map(threadOf)
219
+ };
220
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.6.0",
4
- "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
3
+ "version": "0.7.0",
4
+ "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",