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.
- package/README.md +27 -2
- package/config/agentic.config.schema.json +12 -1
- package/config/references.schema.json +65 -51
- package/dist/agentic-config.d.ts +11 -0
- package/dist/agentic-config.js +19 -3
- package/dist/cli.js +5 -1
- package/dist/commands/pr-reviewed.d.ts +2 -0
- package/dist/commands/pr-reviewed.js +108 -0
- package/dist/commands/pr-ship.d.ts +2 -0
- package/dist/commands/pr-ship.js +163 -0
- package/dist/commands/references-setup.js +57 -24
- package/dist/commands/references-sync.js +5 -5
- package/dist/json-text.d.ts +5 -0
- package/dist/json-text.js +74 -1
- package/dist/pr/decide.d.ts +36 -0
- package/dist/pr/decide.js +216 -0
- package/dist/pr/read.d.ts +24 -0
- package/dist/pr/read.js +112 -0
- package/dist/pr/state.d.ts +54 -0
- package/dist/pr/state.js +220 -0
- package/dist/references-key.d.ts +22 -3
- package/dist/references-key.js +69 -19
- package/dist/router-block.d.ts +1 -1
- package/dist/router-block.js +4 -4
- package/package.json +2 -2
- package/templates/router.md +4 -4
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// `dw-sdlc references setup` sets a repository up to declare References: the
|
|
2
|
-
// each written only when it would change.
|
|
3
|
-
//
|
|
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 {
|
|
9
|
-
import { CONFIG,
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
when
|
|
41
|
-
|
|
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
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 {
|
|
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
|
|
168
|
+
const from = source(root);
|
|
139
169
|
writer.put(ROUTER, (current) => {
|
|
140
|
-
if ("problem" in
|
|
141
|
-
return { refusal: `${
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 ${
|
|
338
|
+
console.log(`Not listed in ${REFERENCES_FILE}, left alone: ${stray.join(", ")}`);
|
|
339
339
|
}
|
|
340
340
|
return failed ? 1 : 0;
|
|
341
341
|
}
|
package/dist/json-text.d.ts
CHANGED
|
@@ -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;
|
package/dist/pr/read.js
ADDED
|
@@ -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
|
+
}
|