dw-sdlc 0.5.1 → 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.
@@ -1,12 +1,12 @@
1
- // `dw-sdlc references setup` sets a repository up to declare References: the five files its USAGE names,
2
- // each written only when it would change. Existing content stays: a list gains the entries it lacks at
3
- // its end, nothing is removed or reordered, and a file the step cannot read as it expects is refused.
1
+ // `dw-sdlc references setup` sets a repository up to declare References: the files its USAGE names,
2
+ // each written only when it would change. A list gains the entries it lacks at its end; the one removal
3
+ // is a `references` key moving out of the agentic config. A file it cannot read as expected is refused.
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
- import { appendKey, parseObject, render } from "../json-text.js";
9
- import { CONFIG, readReferences } from "../references-key.js";
8
+ import { parseObject, removeKey, render } from "../json-text.js";
9
+ import { CONFIG, REFERENCES_FILE, leftoverKey, leftoverProblem, parseReferences, readFile } from "../references-key.js";
10
10
  import { renderBlock, withBlock } from "../router-block.js";
11
11
  const ROUTER = "references/AGENTS.md";
12
12
  /** A symlink to the Router, so Claude Code loads it. */
@@ -14,6 +14,8 @@ const ROUTER_LINK = "references/CLAUDE.md";
14
14
  const SETTINGS = ".claude/settings.json";
15
15
  const IGNORE = ".gitignore";
16
16
  const TEMPLATE = join(import.meta.dirname, "..", "..", "templates", "router.md");
17
+ /** Where a file the setup creates points `$schema`: the schema the pinned package installs. */
18
+ const SCHEMA = "../node_modules/dw-sdlc/config/references.schema.json";
17
19
  /** A clone's own agent files at any depth; Claude Code matches the globs against absolute paths. */
18
20
  const EXCLUDES = [
19
21
  "**/references/*/CLAUDE.md",
@@ -33,12 +35,13 @@ const USAGE = `Usage: dw-sdlc references setup [--root <dir>] [--dry-run | --che
33
35
 
34
36
  Sets the repository at --root, or the one the current directory sits in, up to declare References:
35
37
  the Router in ${ROUTER} with its CLAUDE.md symlink and, between its two markers, the table of each
36
- Reference's dir and when from the references key, claudeMdExcludes in ${SETTINGS} for the clones'
37
- own agent files, the ${IGNORE} lines that keep every clone out of git but the Router, and an empty
38
- references key in ${CONFIG}. Every file is written only when it would change, and the text around
39
- the Router's table never; --dry-run reports without writing, and --check does the same and exits 1
40
- when a file would change. Prints one line per file. Exit 1 when a file is refused, 2 on a bad
41
- argument.`;
38
+ Reference's dir and when from ${REFERENCES_FILE}, claudeMdExcludes in ${SETTINGS} for the clones' own agent
39
+ files, the ${IGNORE} lines that keep every clone out of git but the Router, and ${REFERENCES_FILE} itself
40
+ with no References when it is missing. A references key in ${CONFIG} moves into a missing ${REFERENCES_FILE}
41
+ and leaves the config; with the file there too, both stay and the step is refused. Every file is
42
+ written only when it would change, and the text around the Router's table never; --dry-run reports
43
+ without writing, and --check does the same and exits 1 when a file would change. Prints one line
44
+ per file. Exit 1 when a file is refused, 2 on a bad argument.`;
42
45
  function messageOf(error) {
43
46
  return error instanceof Error ? error.message : String(error);
44
47
  }
@@ -116,16 +119,43 @@ function withIgnoreLines(current) {
116
119
  const gap = head !== "" && !ignored ? "\n" : "";
117
120
  return { text: `${head}${gap}${missing.join("\n")}\n` };
118
121
  }
119
- /** The config with an empty `references` key when it has none, inserted so its own layout stays. */
120
- function withEmptyKey(current) {
121
- if (current === undefined) {
122
- return { text: render({ references: [] }) };
122
+ /** The text of a References file the setup creates, declaring `references`. */
123
+ function fileOf(references) {
124
+ return render({ $schema: SCHEMA, version: 1, references });
125
+ }
126
+ /**
127
+ * Where the References come from: a `references` key left in the config moves into a missing file,
128
+ * and a repository with neither gets the file with no References; the file and the key both there
129
+ * are refused, and so is a key the parser refuses.
130
+ */
131
+ function source(root) {
132
+ const key = leftoverKey(root);
133
+ const path = join(root, REFERENCES_FILE);
134
+ if (key === undefined) {
135
+ const reading = readFile(root);
136
+ if ("problem" in reading) {
137
+ // The problem is the file's own, and the Router's line names it; the file stays as it is.
138
+ return { problem: reading.problem, file: { text: readFileSync(path, "utf8") } };
139
+ }
140
+ return {
141
+ references: reading.references,
142
+ file: { text: existsSync(path) ? readFileSync(path, "utf8") : fileOf([]) }
143
+ };
123
144
  }
124
- const config = parseObject(current);
125
- if (config === undefined) {
126
- return { refusal: "is not a JSON object; fix it by hand first" };
145
+ if (existsSync(path)) {
146
+ return { problem: leftoverProblem(true) };
147
+ }
148
+ const parsed = parseReferences(key);
149
+ if ("problem" in parsed) {
150
+ return { problem: `${CONFIG}: ${parsed.problem.path}: ${parsed.problem.message}; fix it by hand first` };
127
151
  }
128
- return { text: Object.hasOwn(config, "references") ? current : appendKey(current, "references", []) };
152
+ return {
153
+ references: parsed.references,
154
+ file: { text: fileOf(key) },
155
+ config: (current) => current === undefined || parseObject(current) === undefined
156
+ ? { refusal: "is not a JSON object; fix it by hand first" }
157
+ : { text: removeKey(current, "references") }
158
+ };
129
159
  }
130
160
  /** The repository `dir` sits in, or `dir` itself outside one. */
131
161
  function toplevel(dir) {
@@ -135,12 +165,12 @@ function toplevel(dir) {
135
165
  function setup(root, dryRun) {
136
166
  const writer = new Writer(root, dryRun);
137
167
  const template = readFileSync(TEMPLATE, "utf8");
138
- const reading = readReferences(root);
168
+ const from = source(root);
139
169
  writer.put(ROUTER, (current) => {
140
- if ("problem" in reading) {
141
- return { refusal: `${reading.problem}; the table comes from the key, so fix it first` };
170
+ if ("problem" in from) {
171
+ return { refusal: `${from.problem}; the table comes from ${REFERENCES_FILE}, so fix it first` };
142
172
  }
143
- const next = withBlock(current ?? template, renderBlock(reading.references ?? []));
173
+ const next = withBlock(current ?? template, renderBlock(from.references ?? []));
144
174
  return "problem" in next ? { refusal: next.problem } : next;
145
175
  });
146
176
  writer.create(ROUTER_LINK, (path) => {
@@ -148,7 +178,10 @@ function setup(root, dryRun) {
148
178
  });
149
179
  writer.put(SETTINGS, withExcludes);
150
180
  writer.put(IGNORE, withIgnoreLines);
151
- writer.put(CONFIG, withEmptyKey);
181
+ writer.put(REFERENCES_FILE, () => ("problem" in from ? (from.file ?? { refusal: from.problem }) : from.file));
182
+ if (!("problem" in from) && from.config !== undefined) {
183
+ writer.put(CONFIG, from.config);
184
+ }
152
185
  return writer.steps;
153
186
  }
154
187
  export function run(argv) {
@@ -1,4 +1,4 @@
1
- // `dw-sdlc references sync`, the Sync: every Reference the committed config declares brought to its ref
1
+ // `dw-sdlc references sync`, the Sync: every Reference `.ai/references.json` declares brought to its ref
2
2
  // under references/<dir>, each clone shallow, blobless, detached and sparse where its entry says. An
3
3
  // entry that fails fails alone, and a repository that declares no References gets no output at all.
4
4
  import { spawnSync } from "node:child_process";
@@ -7,7 +7,7 @@ import { extname, join } from "node:path";
7
7
  import { setTimeout as sleep } from "node:timers/promises";
8
8
  import { parseArgs } from "node:util";
9
9
  import { readPins, resolveRef } from "../pins.js";
10
- import { CONFIG, readReferences } from "../references-key.js";
10
+ import { REFERENCES_FILE, readReferences } from "../references-key.js";
11
11
  import { renderBlock, withBlock } from "../router-block.js";
12
12
  const CLONES = "references";
13
13
  const ROUTER = "references/AGENTS.md";
@@ -22,7 +22,7 @@ export const summary = "bring every declared Reference to its ref under referenc
22
22
  const USAGE = `Usage: dw-sdlc references sync [dir...]
23
23
  or: pnpm references:sync [dir...], in a checkout of dw-sdlc
24
24
 
25
- Brings every Reference the references key of ${CONFIG} declares to its ref under ${CLONES}/<dir>, or
25
+ Brings every Reference ${REFERENCES_FILE} declares to its ref under ${CLONES}/<dir>, or
26
26
  only the given dirs: a missing one cloned shallow, an existing one moved to its branch's head or its
27
27
  tag, holding the top-level files and the paths its only names when it has one, without the paths
28
28
  its skip names. Prints one line per Reference. Exit 1 when any Reference fails, 2 on a bad key,
@@ -304,7 +304,7 @@ export async function run(argv) {
304
304
  const references = reading.references ?? [];
305
305
  const unknown = cli.dirs.filter((dir) => !references.some((reference) => reference.dir === dir));
306
306
  if (unknown.length > 0) {
307
- fail(`not in the references of ${CONFIG}: ${unknown.join(", ")}`);
307
+ fail(`not in ${REFERENCES_FILE}: ${unknown.join(", ")}`);
308
308
  }
309
309
  if (reading.references === undefined) {
310
310
  return 0;
@@ -335,7 +335,7 @@ export async function run(argv) {
335
335
  .map((entry) => `${CLONES}/${entry.name}`)
336
336
  : [];
337
337
  if (stray.length > 0) {
338
- console.log(`Not listed in ${CONFIG}, left alone: ${stray.join(", ")}`);
338
+ console.log(`Not listed in ${REFERENCES_FILE}, left alone: ${stray.join(", ")}`);
339
339
  }
340
340
  return failed ? 1 : 0;
341
341
  }
@@ -5,3 +5,8 @@ export declare function parseObject(text: string): JsonObject | undefined;
5
5
  export declare function render(value: unknown): string;
6
6
  /** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
7
7
  export declare function appendKey(text: string, key: string, value: unknown): string;
8
+ /**
9
+ * `text`, a JSON object, without its top-level `key`, every copy of it when the key repeats; the
10
+ * members around it keep their layout.
11
+ */
12
+ export declare function removeKey(text: string, key: string): string;
package/dist/json-text.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The text of a JSON object file as the setup commands read and write it: parsed only when it holds an
2
- // object, rendered two spaces in, and a key appended so a hand-formatted file keeps its layout.
2
+ // object, rendered two spaces in, and a key appended or removed so a hand-formatted file keeps its layout.
3
3
  export function isObject(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
5
  }
@@ -24,3 +24,76 @@ export function appendKey(text, key, value) {
24
24
  const rendered = JSON.stringify(value, null, 2).replaceAll("\n", "\n ");
25
25
  return `${body}${comma}\n ${JSON.stringify(key)}: ${rendered}\n${text.slice(close)}`;
26
26
  }
27
+ /** The index just past the JSON string that opens at `start`, or the text's end when it never closes. */
28
+ function stringEnd(text, start) {
29
+ let index = start + 1;
30
+ while (index < text.length && text[index] !== '"') {
31
+ index += text[index] === "\\" ? 2 : 1;
32
+ }
33
+ return Math.min(index + 1, text.length);
34
+ }
35
+ /** The start and end of the top-level member `key` in `text`, a JSON object, from its name to its value's end. */
36
+ function memberSpan(text, key) {
37
+ let depth = 0;
38
+ let expectKey = false;
39
+ let start;
40
+ // Just past the last character that is not whitespace: where the member's value ends once a `,` or `}` follows.
41
+ let lastValue = 0;
42
+ for (let index = 0; index < text.length;) {
43
+ const char = text.charAt(index);
44
+ if (char === '"') {
45
+ const end = stringEnd(text, index);
46
+ if (depth === 1 && expectKey) {
47
+ expectKey = false;
48
+ if (start === undefined && JSON.parse(text.slice(index, end)) === key) {
49
+ start = index;
50
+ }
51
+ }
52
+ lastValue = end;
53
+ index = end;
54
+ continue;
55
+ }
56
+ if (char === "{" || char === "[") {
57
+ depth += 1;
58
+ expectKey = char === "{" && depth === 1;
59
+ }
60
+ else if (char === "}" || char === "]") {
61
+ depth -= 1;
62
+ if (depth === 0 && start !== undefined) {
63
+ return { start, end: lastValue };
64
+ }
65
+ }
66
+ else if (char === "," && depth === 1) {
67
+ if (start !== undefined) {
68
+ return { start, end: lastValue };
69
+ }
70
+ expectKey = true;
71
+ }
72
+ if (!/\s/.test(char)) {
73
+ lastValue = index + 1;
74
+ }
75
+ index += 1;
76
+ }
77
+ return undefined;
78
+ }
79
+ /**
80
+ * `text`, a JSON object, without its top-level `key`, every copy of it when the key repeats; the
81
+ * members around it keep their layout.
82
+ */
83
+ export function removeKey(text, key) {
84
+ const span = memberSpan(text, key);
85
+ if (span === undefined) {
86
+ return text;
87
+ }
88
+ return removeKey(withoutSpan(text, span), key);
89
+ }
90
+ /** `text` without the member at `span` and the comma that joins it to its neighbour. */
91
+ function withoutSpan(text, span) {
92
+ const after = text.slice(span.end).match(/^\s*,\s*/);
93
+ if (after !== null) {
94
+ return text.slice(0, span.start) + text.slice(span.end + after[0].length);
95
+ }
96
+ const before = text.slice(0, span.start).match(/,?\s*$/);
97
+ const cut = span.start - (before?.[0].length ?? 0);
98
+ return text.slice(0, cut) + text.slice(span.end);
99
+ }
@@ -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
+ }