dw-sdlc 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/config/agentic.config.schema.json +56 -18
- package/dist/agentic-config.d.ts +23 -7
- package/dist/agentic-config.js +26 -12
- package/dist/checks/agents-map.d.ts +9 -1
- package/dist/checks/agents-map.js +60 -17
- package/dist/checks/decision-records.d.ts +5 -0
- package/dist/checks/decision-records.js +121 -16
- package/dist/checks/file-size-ratchet.js +7 -5
- package/dist/checks/index.js +2 -0
- package/dist/checks/markdown-budget.d.ts +2 -5
- package/dist/checks/markdown-budget.js +6 -15
- package/dist/checks/markdown-refs.js +1 -11
- package/dist/checks/routed-docs.d.ts +6 -0
- package/dist/checks/routed-docs.js +49 -0
- package/dist/checks/tree.d.ts +2 -0
- package/dist/checks/tree.js +13 -3
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +6 -4
- package/dist/commands/gate.d.ts +10 -4
- package/dist/commands/gate.js +202 -86
- package/dist/commands/pr-reviewed.js +5 -21
- package/dist/commands/pr-ship.js +37 -17
- package/dist/commands/references-sync.js +5 -6
- package/dist/drift.d.ts +17 -0
- package/dist/drift.js +68 -0
- package/dist/gate-cache.d.ts +13 -0
- package/dist/gate-cache.js +39 -0
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.js +10 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +46 -2
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +13 -0
- package/dist/pr/decide.d.ts +31 -8
- package/dist/pr/decide.js +65 -31
- package/dist/router-block.d.ts +4 -1
- package/dist/router-block.js +31 -7
- package/dist/run-command.d.ts +24 -0
- package/dist/run-command.js +121 -0
- package/dist/setup-gate.js +3 -12
- package/dist/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +1 -1
- package/templates/router.md +1 -0
package/dist/drift.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Gate } from "./agentic-config.ts";
|
|
2
|
+
import type { Failure } from "./report.ts";
|
|
3
|
+
import { type Status } from "./run-command.ts";
|
|
4
|
+
/** What a Step answered, and one failure per file it created, changed or deleted. */
|
|
5
|
+
export type Watched<T> = {
|
|
6
|
+
result: T;
|
|
7
|
+
failures: Failure[];
|
|
8
|
+
};
|
|
9
|
+
/** Steps run under watch, and what the watch found. */
|
|
10
|
+
export type DriftWatch = {
|
|
11
|
+
/** Runs one Step by `run` between two records of the tree. */
|
|
12
|
+
step: <T>(command: string, run: () => Promise<T>) => Promise<Watched<T>>;
|
|
13
|
+
/** `fail` once a Step drifted, `error` once the tree could not be recorded, `pass` otherwise. */
|
|
14
|
+
status: () => Status;
|
|
15
|
+
};
|
|
16
|
+
/** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
|
|
17
|
+
export declare function watchDrift(root: string, gate: Gate): DriftWatch;
|
package/dist/drift.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Drift: a file a Step creates, changes or deletes while the Gate runs. Before and after each Step the
|
|
2
|
+
// Gate records the tree of every file git tracks or would track, never against HEAD, so an ignored
|
|
3
|
+
// file never drifts and an edit made before the run sits in both records.
|
|
4
|
+
import { stoppedBy } from "./run-command.js";
|
|
5
|
+
import { git, workingTree } from "./working-tree.js";
|
|
6
|
+
const REMEDIES = {
|
|
7
|
+
drift: "commit the change, or make the Step only read",
|
|
8
|
+
cannotWatch: 'run the Gate in a git checkout, or set gate.drift to "allow"'
|
|
9
|
+
};
|
|
10
|
+
/** What a letter of `git diff-tree --name-status` did to the file; T is a change of type, as a link to a file. */
|
|
11
|
+
const CHANGES = { A: "created", D: "deleted", M: "changed", T: "changed" };
|
|
12
|
+
/** One failure per file that differs between the trees `before` and `after`, naming `command`. */
|
|
13
|
+
function drifted(root, command, before, after) {
|
|
14
|
+
if (before === after) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const diff = git(root, ["diff-tree", "-r", "-z", "--no-renames", "--name-status", before, after]);
|
|
18
|
+
if ("error" in diff) {
|
|
19
|
+
return diff;
|
|
20
|
+
}
|
|
21
|
+
// NUL-terminated pairs: the letter, then the path, which may hold any byte but NUL.
|
|
22
|
+
return [...diff.out.matchAll(/([A-Z])\0([^\0]+)\0/g)].map(([, letter = "", file = ""]) => {
|
|
23
|
+
const message = `${JSON.stringify(command)} ${CHANGES[letter] ?? "changed"} it`;
|
|
24
|
+
return { file, rule: "drift", message, remedy: REMEDIES.drift };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
|
|
28
|
+
export function watchDrift(root, gate) {
|
|
29
|
+
let status = "pass";
|
|
30
|
+
let watching = gate.drift !== "allow";
|
|
31
|
+
/** The tree after the last Step, the record before the next: nothing runs between two Steps. */
|
|
32
|
+
let last;
|
|
33
|
+
/** The one failure of a tree that cannot be recorded; the watch stops there. */
|
|
34
|
+
const cannotWatch = (command, error) => {
|
|
35
|
+
watching = false;
|
|
36
|
+
status = "error";
|
|
37
|
+
const message = `cannot watch ${JSON.stringify(command)}: ${error}`;
|
|
38
|
+
return [{ rule: "drift", message, remedy: REMEDIES.cannotWatch }];
|
|
39
|
+
};
|
|
40
|
+
const step = async (command, run) => {
|
|
41
|
+
if (!watching) {
|
|
42
|
+
return { result: await run(), failures: [] };
|
|
43
|
+
}
|
|
44
|
+
const before = last === undefined ? workingTree(root) : { tree: last };
|
|
45
|
+
const result = await run();
|
|
46
|
+
if ("error" in before) {
|
|
47
|
+
return { result, failures: cannotWatch(command, before.error) };
|
|
48
|
+
}
|
|
49
|
+
if (stoppedBy() !== undefined) {
|
|
50
|
+
// A signal cut the Step short and ends the Gate, so nothing is compared after it.
|
|
51
|
+
return { result, failures: [] };
|
|
52
|
+
}
|
|
53
|
+
const after = workingTree(root);
|
|
54
|
+
if ("error" in after) {
|
|
55
|
+
return { result, failures: cannotWatch(command, after.error) };
|
|
56
|
+
}
|
|
57
|
+
last = after.tree;
|
|
58
|
+
const found = drifted(root, command, before.tree, after.tree);
|
|
59
|
+
if ("error" in found) {
|
|
60
|
+
return { result, failures: cannotWatch(command, found.error) };
|
|
61
|
+
}
|
|
62
|
+
if (found.length > 0) {
|
|
63
|
+
status = "fail";
|
|
64
|
+
}
|
|
65
|
+
return { result, failures: found };
|
|
66
|
+
};
|
|
67
|
+
return { step, status: () => status };
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Gate } from "./agentic-config.ts";
|
|
2
|
+
/** A checkout's fingerprint and the file its last green run is stamped in. */
|
|
3
|
+
export type Fingerprint = {
|
|
4
|
+
key: string;
|
|
5
|
+
stampFile: string;
|
|
6
|
+
};
|
|
7
|
+
/** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
|
|
8
|
+
export declare function fingerprint(root: string, gate: Gate): Fingerprint | {
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/** Whether the last green run of this checkout was stamped with this fingerprint. */
|
|
12
|
+
export declare function isGreen({ key, stampFile }: Fingerprint): boolean;
|
|
13
|
+
export declare function stampGreen({ key, stampFile }: Fingerprint): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The Gate's cache. A checkout's fingerprint is its working tree's content and the Gate that judges
|
|
2
|
+
// it. A green run stamps it in the checkout's own git dir, so each worktree keeps its own stamp.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { packageVersion } from "./package-version.js";
|
|
7
|
+
import { workingTree } from "./working-tree.js";
|
|
8
|
+
const STAMP_FILE = "dw-sdlc-gate.green";
|
|
9
|
+
/** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
|
|
10
|
+
export function fingerprint(root, gate) {
|
|
11
|
+
const found = workingTree(root);
|
|
12
|
+
if ("error" in found) {
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const judge = createHash("sha256").update(JSON.stringify({ version: packageVersion(), gate }));
|
|
17
|
+
return { key: `${found.tree} ${judge.digest("hex")}`, stampFile: join(found.gitDir, STAMP_FILE) };
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Whether the last green run of this checkout was stamped with this fingerprint. */
|
|
24
|
+
export function isGreen({ key, stampFile }) {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(stampFile, "utf8").trim() === key;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function stampGreen({ key, stampFile }) {
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(stampFile, `${key}\n`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A stamp that cannot be written only means the next run does not skip.
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/git-env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The environment a git command runs under to find its repository from its own directory, as a git
|
|
2
|
+
// hook's GIT_DIR or GIT_INDEX_FILE would otherwise aim it at the hook's repository.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
let repositoryVars;
|
|
5
|
+
/** This process's environment without the variables that tie git to one repository, as git lists them. */
|
|
6
|
+
export function repositoryFreeEnv() {
|
|
7
|
+
repositoryVars ??= new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
|
|
8
|
+
const vars = repositoryVars;
|
|
9
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
|
|
10
|
+
}
|
package/dist/markdown.d.ts
CHANGED
|
@@ -25,6 +25,25 @@ export declare function codeSpans(text: string): string[];
|
|
|
25
25
|
export declare function prose(text: string): string;
|
|
26
26
|
/** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
|
|
27
27
|
export declare function links(text: string): Link[];
|
|
28
|
+
/** A value of a frontmatter, with its 1-based line. */
|
|
29
|
+
export type FrontmatterValue = {
|
|
30
|
+
value: string;
|
|
31
|
+
line: number;
|
|
32
|
+
};
|
|
33
|
+
/** A top-level key of a frontmatter: the value after its colon, and the items of the block list under it. */
|
|
34
|
+
export type FrontmatterEntry = FrontmatterValue & {
|
|
35
|
+
items: FrontmatterValue[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
|
|
39
|
+
* twice is read where it first appears.
|
|
40
|
+
*/
|
|
41
|
+
export declare function frontmatter(text: string): Map<string, FrontmatterEntry>;
|
|
42
|
+
/**
|
|
43
|
+
* The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
|
|
44
|
+
* inside a glob's `{…}` separates none, and an empty value is dropped.
|
|
45
|
+
*/
|
|
46
|
+
export declare function frontmatterList(key: FrontmatterEntry | undefined): FrontmatterValue[];
|
|
28
47
|
/** The anchors of each markdown file read so far, by path. */
|
|
29
48
|
export type Anchors = Map<string, Set<string>>;
|
|
30
49
|
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
package/dist/markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The parts of a markdown file the Checks read: its ATX headings with the anchors GitHub gives them,
|
|
2
|
-
// the rows of a table under a heading, its links,
|
|
3
|
-
// of a file on disk. A line inside a fenced code block is never any of them.
|
|
2
|
+
// the rows of a table under a heading, its links, its prose with the code masked and the keys of its
|
|
3
|
+
// frontmatter; and the anchors of a file on disk. A line inside a fenced code block is never any of them.
|
|
4
4
|
import { readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
/** A code fence at any indent, so one inside a list item counts. */
|
|
@@ -103,6 +103,50 @@ export function links(text) {
|
|
|
103
103
|
return targets.map((target) => ({ target: target.replace(/^<(.*)>$/, "$1"), line: index + 1 }));
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
/** A scalar as YAML reads it: the text between its quotes, or else the text before a ` #` comment. */
|
|
107
|
+
function scalar(text) {
|
|
108
|
+
const quoted = /^\s*(["'])(.*?)\1\s*(#.*)?$/.exec(text);
|
|
109
|
+
return quoted?.[2] ?? text.replace(/(^|\s)#.*$/, "").trim();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
|
|
113
|
+
* twice is read where it first appears.
|
|
114
|
+
*/
|
|
115
|
+
export function frontmatter(text) {
|
|
116
|
+
const keys = new Map();
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
if (lines[0]?.trim() !== "---") {
|
|
119
|
+
return keys;
|
|
120
|
+
}
|
|
121
|
+
let current;
|
|
122
|
+
for (let index = 1; index < lines.length && lines[index]?.trim() !== "---"; index++) {
|
|
123
|
+
const line = lines[index] ?? "";
|
|
124
|
+
const [, name, rest = ""] = /^([\w-]+):(.*)$/.exec(line) ?? [];
|
|
125
|
+
const item = /^\s*-(?:\s+(.*))?$/.exec(line);
|
|
126
|
+
if (name !== undefined) {
|
|
127
|
+
current = keys.has(name) ? undefined : { value: scalar(rest), line: index + 1, items: [] };
|
|
128
|
+
if (current !== undefined) {
|
|
129
|
+
keys.set(name, current);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (item !== null) {
|
|
133
|
+
current?.items.push({ value: scalar(item[1] ?? ""), line: index + 1 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return keys;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
|
|
140
|
+
* inside a glob's `{…}` separates none, and an empty value is dropped.
|
|
141
|
+
*/
|
|
142
|
+
export function frontmatterList(key) {
|
|
143
|
+
if (key === undefined) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const inline = key.value.replace(/^\[(.*)\]$/, "$1");
|
|
147
|
+
const values = inline === "" ? key.items : inline.split(/,(?![^{]*\})/).map((value) => ({ value: scalar(value), line: key.line }));
|
|
148
|
+
return values.filter(({ value }) => value !== "");
|
|
149
|
+
}
|
|
106
150
|
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
|
107
151
|
export function anchorsOf(root, path, cache) {
|
|
108
152
|
let anchors = cache.get(path);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The version of this package, read from its own manifest: one directory above `src/` in the source
|
|
2
|
+
// and above `dist/` in what npm installs.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseObject } from "./json-text.js";
|
|
6
|
+
/** This package's version; throws when its manifest names none. */
|
|
7
|
+
export function packageVersion() {
|
|
8
|
+
const manifest = parseObject(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
9
|
+
if (typeof manifest?.version !== "string") {
|
|
10
|
+
throw new Error("dw-sdlc's package.json names no version");
|
|
11
|
+
}
|
|
12
|
+
return manifest.version;
|
|
13
|
+
}
|
package/dist/pr/decide.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import type { PrMerge } from "../agentic-config.ts";
|
|
1
|
+
import type { PrMerge, PrReview } from "../agentic-config.ts";
|
|
2
2
|
import type { PrState } from "./state.ts";
|
|
3
3
|
export type Status = "act" | "wait" | "park" | "done";
|
|
4
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";
|
|
5
|
+
export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
|
|
6
6
|
/** The Actions the script performs itself, each one gh command. */
|
|
7
|
-
export type ScriptAction = "update-branch" | "mark-ready" | "merge";
|
|
7
|
+
export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
|
|
8
|
+
/** Where a Review depth comes from: the dev's `--review` for this call, or `pr.review`. */
|
|
9
|
+
export type DepthSource = "flag" | "config";
|
|
10
|
+
/**
|
|
11
|
+
* What the decision reads beside the pull request: who merges it, how closely a Review reads it, and
|
|
12
|
+
* the slash command a `full` Review runs.
|
|
13
|
+
*/
|
|
14
|
+
export type Policy = {
|
|
15
|
+
merge: PrMerge;
|
|
16
|
+
review: {
|
|
17
|
+
depth: PrReview;
|
|
18
|
+
source: DepthSource;
|
|
19
|
+
};
|
|
20
|
+
reviewCommand: string;
|
|
21
|
+
};
|
|
8
22
|
/** An Action the pull request needs beside the one its answer names, so one push can fix several. */
|
|
9
23
|
export type Blocker = {
|
|
10
24
|
action: Action;
|
|
@@ -20,17 +34,26 @@ export type Answer = Blocker & {
|
|
|
20
34
|
recheckAfter?: number;
|
|
21
35
|
blockers: Blocker[];
|
|
22
36
|
};
|
|
37
|
+
/** The commit status a clean review of the head leaves on it. */
|
|
38
|
+
export declare const REVIEW = "dw-sdlc/review";
|
|
39
|
+
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
40
|
+
export declare const REVIEW_STATE = "success";
|
|
23
41
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
24
42
|
export declare function shellLine(words: string[]): string;
|
|
43
|
+
/**
|
|
44
|
+
* The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
|
|
45
|
+
* read. The path names the repository, so the call reads the same from any checkout.
|
|
46
|
+
*/
|
|
47
|
+
export declare function reviewStatusArgs(pr: PrState, description: string): string[];
|
|
25
48
|
export declare function isScriptAction(action: Action): action is ScriptAction;
|
|
26
49
|
/**
|
|
27
50
|
* The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
|
|
28
51
|
* base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
|
|
29
52
|
*/
|
|
30
|
-
export declare function hostArgs(pr: PrState, action: ScriptAction, named: boolean): string[];
|
|
53
|
+
export declare function hostArgs(pr: PrState, action: ScriptAction, policy: Policy, named: boolean): string[];
|
|
31
54
|
/** 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;
|
|
55
|
+
export declare function devCommand(pr: PrState, action: ScriptAction, policy: Policy): string;
|
|
33
56
|
/** 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,
|
|
36
|
-
export declare function decide(pr: PrState, now: number,
|
|
57
|
+
export declare function readyToMerge(pr: PrState, policy: Policy, reason: string): Omit<Answer, "blockers">;
|
|
58
|
+
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
59
|
+
export declare function decide(pr: PrState, now: number, policy: Policy): Answer;
|
package/dist/pr/decide.js
CHANGED
|
@@ -8,7 +8,9 @@ const BUDGET_MINUTES = 40;
|
|
|
8
8
|
/** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
|
|
9
9
|
const OWN_PREFIX = "dw-sdlc/";
|
|
10
10
|
/** The commit status a clean review of the head leaves on it. */
|
|
11
|
-
const REVIEW = "dw-sdlc/review";
|
|
11
|
+
export const REVIEW = "dw-sdlc/review";
|
|
12
|
+
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
13
|
+
export const REVIEW_STATE = "success";
|
|
12
14
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
13
15
|
export function shellLine(words) {
|
|
14
16
|
return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
|
|
@@ -33,10 +35,25 @@ function threads(pr, lastByViewer) {
|
|
|
33
35
|
function listed({ id, path, line, author, body }) {
|
|
34
36
|
return { id, path, line, author, body };
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
|
|
40
|
+
* read. The path names the repository, so the call reads the same from any checkout.
|
|
41
|
+
*/
|
|
42
|
+
export function reviewStatusArgs(pr, description) {
|
|
43
|
+
const fields = { state: REVIEW_STATE, context: REVIEW, target_url: pr.url, description };
|
|
44
|
+
const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
|
|
45
|
+
return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
|
|
46
|
+
}
|
|
47
|
+
/** Why a head no one reads carries the success: the dev's word or the config. */
|
|
48
|
+
function offDescription(policy) {
|
|
49
|
+
return `off: ${policy.review.source === "flag" ? "--review off" : "pr.review"}`;
|
|
50
|
+
}
|
|
51
|
+
/** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
|
|
36
52
|
const HOST = {
|
|
37
|
-
"update-branch":
|
|
38
|
-
"mark-ready":
|
|
39
|
-
|
|
53
|
+
"update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
|
|
54
|
+
"mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
|
|
55
|
+
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, offDescription(policy)),
|
|
56
|
+
merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
|
|
40
57
|
};
|
|
41
58
|
export function isScriptAction(action) {
|
|
42
59
|
return Object.hasOwn(HOST, action);
|
|
@@ -45,17 +62,16 @@ export function isScriptAction(action) {
|
|
|
45
62
|
* The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
|
|
46
63
|
* base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
|
|
47
64
|
*/
|
|
48
|
-
export function hostArgs(pr, action, named) {
|
|
49
|
-
|
|
50
|
-
return ["pr", verb, String(pr.number), ...(named ? ["-R", pr.repo] : []), ...flags(pr)];
|
|
65
|
+
export function hostArgs(pr, action, policy, named) {
|
|
66
|
+
return HOST[action](pr, named ? ["-R", pr.repo] : [], policy);
|
|
51
67
|
}
|
|
52
68
|
/** 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)]);
|
|
69
|
+
export function devCommand(pr, action, policy) {
|
|
70
|
+
return shellLine(["gh", ...hostArgs(pr, action, policy, false)]);
|
|
55
71
|
}
|
|
56
72
|
/** 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") };
|
|
73
|
+
export function readyToMerge(pr, policy, reason) {
|
|
74
|
+
return { status: "park", action: "ready-to-merge", actor: "dev", reason, command: devCommand(pr, "merge", policy) };
|
|
59
75
|
}
|
|
60
76
|
const ROWS = [
|
|
61
77
|
{
|
|
@@ -125,24 +141,30 @@ const ROWS = [
|
|
|
125
141
|
answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
|
|
126
142
|
},
|
|
127
143
|
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
// The dev merges another author's pull request, so it needs no Review. Under off no one reads
|
|
145
|
+
// the head: the script posts the success itself.
|
|
146
|
+
when: (pr) => pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
|
|
147
|
+
answer: (pr, _, { review, reviewCommand }) => {
|
|
148
|
+
const off = review.depth === "off";
|
|
149
|
+
return {
|
|
150
|
+
status: "act",
|
|
151
|
+
action: off ? "mark-reviewed" : "review",
|
|
152
|
+
actor: off ? "script" : "agent",
|
|
153
|
+
reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}`,
|
|
154
|
+
details: { ...review, ...(review.depth === "full" ? { reviewCommand } : {}) }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
135
157
|
},
|
|
136
158
|
{
|
|
137
159
|
when: (pr) => ciOf(pr).length === 0,
|
|
138
|
-
answer: (pr, now) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
|
|
160
|
+
answer: (pr, now, policy) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
|
|
139
161
|
? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
|
|
140
162
|
: {
|
|
141
163
|
status: "park",
|
|
142
164
|
action: "unverified",
|
|
143
165
|
actor: "dev",
|
|
144
166
|
reason: `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`,
|
|
145
|
-
command: devCommand(pr, "merge")
|
|
167
|
+
command: devCommand(pr, "merge", policy)
|
|
146
168
|
}
|
|
147
169
|
},
|
|
148
170
|
{
|
|
@@ -173,9 +195,18 @@ const ROWS = [
|
|
|
173
195
|
})
|
|
174
196
|
},
|
|
175
197
|
{
|
|
176
|
-
when: (pr) => pr.draft,
|
|
198
|
+
when: (pr) => pr.draft && pr.viewerDidAuthor,
|
|
177
199
|
answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
|
|
178
200
|
},
|
|
201
|
+
{
|
|
202
|
+
when: (pr) => pr.draft && !pr.viewerDidAuthor,
|
|
203
|
+
answer: () => ({
|
|
204
|
+
status: "park",
|
|
205
|
+
action: "awaiting-author",
|
|
206
|
+
actor: "dev",
|
|
207
|
+
reason: "another author's draft: marking it ready is the author's"
|
|
208
|
+
})
|
|
209
|
+
},
|
|
179
210
|
{
|
|
180
211
|
when: (pr) => pr.reviewDecision === "REVIEW_REQUIRED",
|
|
181
212
|
answer: () => ({
|
|
@@ -187,30 +218,33 @@ const ROWS = [
|
|
|
187
218
|
},
|
|
188
219
|
{
|
|
189
220
|
when: (pr) => !pr.viewerDidAuthor,
|
|
190
|
-
answer: (pr) => readyToMerge(pr, "another author's pull request: the merge is the dev's"),
|
|
221
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "another author's pull request: the merge is the dev's"),
|
|
191
222
|
merging: true
|
|
192
223
|
},
|
|
193
224
|
{
|
|
194
|
-
when: (_, merge) => merge === "auto",
|
|
225
|
+
when: (_, { merge }) => merge === "auto",
|
|
195
226
|
answer: () => ({ status: "act", action: "merge", actor: "script", reason: "green, reviewed and the viewer's own" }),
|
|
196
227
|
merging: true
|
|
197
228
|
},
|
|
198
229
|
{
|
|
199
|
-
when: (_, merge) => merge === "ask",
|
|
200
|
-
answer: (pr) => readyToMerge(pr, "pr.merge is ask: the merge is the dev's"),
|
|
230
|
+
when: (_, { merge }) => merge === "ask",
|
|
231
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "pr.merge is ask: the merge is the dev's"),
|
|
201
232
|
merging: true
|
|
202
233
|
}
|
|
203
234
|
];
|
|
204
235
|
function blockerOf({ action, actor, reason, details }) {
|
|
205
236
|
return { action, actor, reason, ...(details === undefined ? {} : { details }) };
|
|
206
237
|
}
|
|
207
|
-
/** The Action `pr` needs at `now`, milliseconds since the epoch,
|
|
208
|
-
export function decide(pr, now,
|
|
209
|
-
const index = ROWS.findIndex((row) => row.when(pr,
|
|
238
|
+
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
239
|
+
export function decide(pr, now, policy) {
|
|
240
|
+
const index = ROWS.findIndex((row) => row.when(pr, policy));
|
|
210
241
|
const row = ROWS[index];
|
|
211
242
|
if (row === undefined) {
|
|
212
|
-
throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${merge}`);
|
|
243
|
+
throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${policy.merge}`);
|
|
213
244
|
}
|
|
214
|
-
const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr,
|
|
215
|
-
return {
|
|
245
|
+
const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr, policy));
|
|
246
|
+
return {
|
|
247
|
+
...row.answer(pr, now, policy),
|
|
248
|
+
blockers: later.map((other) => blockerOf(other.answer(pr, now, policy)))
|
|
249
|
+
};
|
|
216
250
|
}
|
package/dist/router-block.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Reference } from "./references-key.ts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The block the file gives: one row per Reference in the file's order, or one line when it has none,
|
|
4
|
+
* with a blank line inside each marker and each `when` trimmed, as oxfmt leaves it.
|
|
5
|
+
*/
|
|
3
6
|
export declare function renderBlock(references: Reference[]): string;
|
|
4
7
|
/**
|
|
5
8
|
* The Router's text with `block` in place of the one between its markers: as it is when the two say
|
package/dist/router-block.js
CHANGED
|
@@ -6,16 +6,40 @@ const END = "<!-- references:end -->";
|
|
|
6
6
|
const START_LINE = /^<!-- references:start\b.*-->\s*$/;
|
|
7
7
|
const END_LINE = /^<!-- references:end -->\s*$/;
|
|
8
8
|
const NONE = "No Reference is declared yet: `/sdlc-references-add <repository>` adds one.";
|
|
9
|
-
|
|
9
|
+
const GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
10
|
+
/** An emoji drawn as one, or a character of the East Asian wide and fullwidth ranges. */
|
|
11
|
+
const WIDE = /[\p{Emoji_Presentation}\u{FE0F}\u{1100}-\u{115F}\u{2329}\u{232A}\u{2E80}-\u{303E}\u{3040}-\u{A4CF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FE10}-\u{FE19}\u{FE30}-\u{FE6F}\u{FF00}-\u{FF60}\u{FFE0}-\u{FFE6}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}]/u;
|
|
12
|
+
/**
|
|
13
|
+
* A cell's width in columns, close to oxfmt's: two for a wide grapheme, one for any other. It matches
|
|
14
|
+
* oxfmt on Latin text, CJK, Hangul and emoji; a script with combining signs, such as Devanagari, or a
|
|
15
|
+
* rarer wide character comes out off, and the formatter pads that table again.
|
|
16
|
+
*/
|
|
17
|
+
function width(cell) {
|
|
18
|
+
let columns = 0;
|
|
19
|
+
for (const { segment } of GRAPHEMES.segment(cell)) {
|
|
20
|
+
columns += WIDE.test(segment) ? 2 : 1;
|
|
21
|
+
}
|
|
22
|
+
return columns;
|
|
23
|
+
}
|
|
24
|
+
function tableLine(cells) {
|
|
25
|
+
return `| ${cells.join(" | ")} |`;
|
|
26
|
+
}
|
|
27
|
+
/** A Markdown table, each column padded to its widest cell and three at the least. */
|
|
28
|
+
function table(header, body) {
|
|
29
|
+
const rows = [header, ...body];
|
|
30
|
+
const widths = header.map((_, column) => Math.max(3, ...rows.map((row) => width(row[column] ?? ""))));
|
|
31
|
+
const paddedLine = (row) => tableLine(row.map((cell, column) => cell + " ".repeat((widths[column] ?? 0) - width(cell))));
|
|
32
|
+
return [paddedLine(header), tableLine(widths.map((count) => "-".repeat(count))), ...body.map(paddedLine)];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The block the file gives: one row per Reference in the file's order, or one line when it has none,
|
|
36
|
+
* with a blank line inside each marker and each `when` trimmed, as oxfmt leaves it.
|
|
37
|
+
*/
|
|
10
38
|
export function renderBlock(references) {
|
|
11
39
|
const body = references.length === 0
|
|
12
40
|
? [NONE]
|
|
13
|
-
: [
|
|
14
|
-
|
|
15
|
-
"| --- | --- |",
|
|
16
|
-
...references.map((reference) => `| \`${reference.dir}\` | ${reference.when.replaceAll("|", "\\|")} |`)
|
|
17
|
-
];
|
|
18
|
-
return `${[START, ...body, END].join("\n")}\n`;
|
|
41
|
+
: table(["dir", "reach for it when"], references.map((reference) => [`\`${reference.dir}\``, reference.when.trim().replaceAll("|", "\\|")]));
|
|
42
|
+
return `${[START, "", ...body, "", END].join("\n")}\n`;
|
|
19
43
|
}
|
|
20
44
|
/** A table's delimiter row, dashes of any count. */
|
|
21
45
|
const DELIMITER = /^\|(?:\s*-+\s*\|)+$/;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Status = "pass" | "fail" | "error";
|
|
2
|
+
/**
|
|
3
|
+
* Where a command's output goes: `capture` keeps its last lines when it does not pass; `stdout` and
|
|
4
|
+
* `stderr` pass it through as it is written, onto stderr when `--json` keeps stdout for the document.
|
|
5
|
+
*/
|
|
6
|
+
export type Output = "capture" | "stdout" | "stderr";
|
|
7
|
+
export type Ran = {
|
|
8
|
+
status: Status;
|
|
9
|
+
/** Null when it never started or a signal stopped it. */
|
|
10
|
+
exitCode: number | null;
|
|
11
|
+
/** The signal that stopped it: one the Gate got while it ran, or one that ended it. */
|
|
12
|
+
signal: NodeJS.Signals | null;
|
|
13
|
+
seconds: number;
|
|
14
|
+
/** What went wrong, as `exited 3`; absent on a pass. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** Its last `TAIL_LINES` lines, both streams in the order written, when captured and not passing. */
|
|
17
|
+
tail: string[] | null;
|
|
18
|
+
};
|
|
19
|
+
export declare const TAIL_LINES = 50;
|
|
20
|
+
/** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
|
|
21
|
+
export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
|
|
22
|
+
/** The signal that stopped the run, once one has; no command starts after it. */
|
|
23
|
+
export declare function stoppedBy(): NodeJS.Signals | undefined;
|
|
24
|
+
export declare function runCommand(command: string, root: string, output: Output): Promise<Ran>;
|