dw-sdlc 0.20.0 → 0.21.1
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 +13 -13
- package/config/agentic.config.schema.json +1 -1
- package/dist/agentic-config.d.ts +16 -0
- package/dist/agentic-config.js +28 -23
- package/dist/cannot-run.d.ts +27 -0
- package/dist/cannot-run.js +48 -0
- package/dist/catalogue.js +2 -1
- package/dist/checks/agents-map.js +7 -8
- package/dist/checks/check.d.ts +10 -8
- package/dist/checks/check.js +9 -7
- package/dist/checks/decision-records.js +3 -2
- package/dist/checks/file-size-ratchet.js +13 -17
- package/dist/checks/index.d.ts +6 -5
- package/dist/checks/index.js +10 -9
- package/dist/checks/markdown-refs.js +58 -6
- package/dist/checks/primitives.js +5 -5
- package/dist/checks/routed-docs.js +1 -3
- package/dist/checks/skill-sections.js +12 -20
- package/dist/checks/tree.js +7 -7
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +26 -5
- package/dist/commands/check.js +27 -42
- package/dist/commands/config.js +22 -27
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +11 -18
- package/dist/commands/gate.js +35 -40
- package/dist/commands/lead-start.js +28 -42
- package/dist/commands/pr-reviewed.js +41 -59
- package/dist/commands/pr-ship.js +72 -87
- package/dist/commands/primitives.js +38 -40
- package/dist/commands/recap.js +23 -74
- package/dist/commands/references-setup.js +28 -76
- package/dist/commands/references-sync.js +29 -53
- package/dist/commands/setup.d.ts +2 -7
- package/dist/commands/setup.js +38 -90
- package/dist/commands/sweep.js +46 -157
- package/dist/commands/worker-ls.js +15 -31
- package/dist/commands/worker-report.js +36 -52
- package/dist/commands/worker-start.js +43 -57
- package/dist/drift.js +2 -1
- package/dist/exec.d.ts +20 -0
- package/dist/exec.js +37 -4
- package/dist/gate-cache.js +2 -1
- package/dist/git.d.ts +32 -0
- package/dist/git.js +60 -0
- package/dist/json-schema.js +1 -3
- package/dist/landed.js +11 -22
- package/dist/paused-operation.d.ts +2 -0
- package/dist/paused-operation.js +17 -0
- package/dist/pins.js +2 -1
- package/dist/pr/decide.d.ts +4 -16
- package/dist/pr/decide.js +48 -83
- package/dist/pr/delta.d.ts +3 -4
- package/dist/pr/delta.js +11 -20
- package/dist/pr/read.d.ts +27 -30
- package/dist/pr/read.js +96 -158
- package/dist/pr/reply.d.ts +34 -0
- package/dist/pr/reply.js +112 -0
- package/dist/pr/route.d.ts +11 -3
- package/dist/pr/route.js +5 -5
- package/dist/pr/standing.d.ts +138 -0
- package/dist/pr/standing.js +181 -0
- package/dist/pr/state.d.ts +14 -69
- package/dist/pr/state.js +75 -148
- package/dist/pr/upstream.d.ts +1 -6
- package/dist/pr/upstream.js +2 -2
- package/dist/primitives-diff.js +1 -1
- package/dist/recap-next.js +34 -47
- package/dist/recap-primitives.js +1 -1
- package/dist/references-key.d.ts +0 -1
- package/dist/references-key.js +3 -4
- package/dist/run-command.d.ts +4 -1
- package/dist/run-command.js +32 -11
- package/dist/setup-gate.d.ts +1 -7
- package/dist/setup-rules.js +2 -6
- package/dist/setup-writer.d.ts +32 -0
- package/dist/setup-writer.js +60 -0
- package/dist/shell-line.d.ts +2 -0
- package/dist/shell-line.js +5 -0
- package/dist/sweep-removal.d.ts +31 -0
- package/dist/sweep-removal.js +133 -0
- package/dist/sweep-terminals.d.ts +1 -1
- package/dist/sweep-terminals.js +21 -57
- package/dist/sweep-trees.d.ts +19 -3
- package/dist/sweep-trees.js +67 -31
- package/dist/worker/herdr.d.ts +67 -23
- package/dist/worker/herdr.js +126 -77
- package/dist/worker/launch.js +32 -45
- package/dist/worker/origin.d.ts +1 -1
- package/dist/worker/origin.js +9 -11
- package/dist/worker/tabs.js +11 -7
- package/dist/working-tree.d.ts +0 -8
- package/dist/working-tree.js +6 -22
- package/dist/worktree-lock.js +1 -1
- package/package.json +1 -1
- package/dist/git-env.d.ts +0 -2
- package/dist/git-env.js +0 -10
package/dist/pr/read.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
// The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
|
|
2
|
-
// its state from one GraphQL request
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
super(message);
|
|
12
|
-
this.remedy = remedy;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
2
|
+
// its state from one GraphQL request and the Review's standing at its head from GitHub's compares, through
|
|
3
|
+
// gh, and beside them the default branch and the clock the decision reads.
|
|
4
|
+
import { CannotRun } from "../cannot-run.js";
|
|
5
|
+
import { firstLine, need } from "../exec.js";
|
|
6
|
+
import { behindOf, changedOf, changesOf, compareArgs } from "./delta.js";
|
|
7
|
+
import { ReplyError } from "./reply.js";
|
|
8
|
+
import { standingOf } from "./standing.js";
|
|
9
|
+
import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY } from "./state.js";
|
|
10
|
+
import { holds, renamedUpstream } from "./upstream.js";
|
|
15
11
|
const REMEDIES = {
|
|
16
12
|
missing: "install the GitHub CLI from https://cli.github.com",
|
|
17
13
|
auth: "run gh auth login, or set GH_TOKEN",
|
|
@@ -30,39 +26,18 @@ export function namedProblem(number, repo) {
|
|
|
30
26
|
}
|
|
31
27
|
return undefined;
|
|
32
28
|
}
|
|
33
|
-
function firstLine(text) {
|
|
34
|
-
return text.trim().split("\n")[0] ?? "";
|
|
35
|
-
}
|
|
36
29
|
/** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
|
|
37
30
|
export function said(stderr) {
|
|
38
|
-
return firstLine(stderr).replace(/^(?:gh: |X )/, "");
|
|
31
|
+
return (firstLine(stderr) ?? "").replace(/^(?:gh: |X )/, "");
|
|
39
32
|
}
|
|
40
33
|
/** The problem `gh auth status` reports: its line marked `X` under the host, else its first line. */
|
|
41
34
|
function authProblem(stderr) {
|
|
42
35
|
const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
|
|
43
|
-
return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
|
|
44
|
-
}
|
|
45
|
-
/** `gh args` as a child process; past its limit it stops the command as any other failed read does. */
|
|
46
|
-
function reachGh(args) {
|
|
47
|
-
try {
|
|
48
|
-
return exec("gh", args);
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
if (error instanceof TimedOut) {
|
|
52
|
-
throw new CannotRun(error.message, REMEDIES.read);
|
|
53
|
-
}
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
36
|
+
return marked === undefined ? (firstLine(stderr) ?? "") : marked.trim().slice(2);
|
|
56
37
|
}
|
|
38
|
+
/** `gh args` as a child process; not on PATH, or past its limit, it stops the command as any failed read does. */
|
|
57
39
|
export function gh(args) {
|
|
58
|
-
|
|
59
|
-
if (result.error !== undefined) {
|
|
60
|
-
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
61
|
-
throw new CannotRun("gh is not on PATH", REMEDIES.missing);
|
|
62
|
-
}
|
|
63
|
-
throw result.error;
|
|
64
|
-
}
|
|
65
|
-
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
40
|
+
return need("gh", args, { missing: REMEDIES.missing, stalled: REMEDIES.read });
|
|
66
41
|
}
|
|
67
42
|
/** What `parse` returns; a reply it cannot parse stops the command, naming the field. */
|
|
68
43
|
function parseOrCannotRun(parse) {
|
|
@@ -88,7 +63,7 @@ function targetOf({ number, repo }, command) {
|
|
|
88
63
|
const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
|
|
89
64
|
const view = gh(["pr", "view", ...named, "--json", "url"]);
|
|
90
65
|
if (view.status !== 0) {
|
|
91
|
-
throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, nameItRemedy(command));
|
|
66
|
+
throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr) ?? ""}`, nameItRemedy(command));
|
|
92
67
|
}
|
|
93
68
|
return parseOrCannotRun(() => parseView(view.stdout));
|
|
94
69
|
}
|
|
@@ -100,41 +75,17 @@ function requireAuth() {
|
|
|
100
75
|
throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
|
|
101
76
|
}
|
|
102
77
|
}
|
|
103
|
-
/**
|
|
104
|
-
* The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
|
|
105
|
-
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
106
|
-
*/
|
|
107
|
-
export function read(named, command, sized) {
|
|
108
|
-
requireAuth();
|
|
109
|
-
return query(targetOf(named, command), sized);
|
|
110
|
-
}
|
|
111
|
-
/** The pull request `gh pr view` resolves for the head branch `branch`, else the current one; null when none. */
|
|
78
|
+
/** The pull request `gh pr view` resolves for the head branch `branch`, else the current one, or what gh said of none. */
|
|
112
79
|
function viewed(branch) {
|
|
113
80
|
const view = gh(["pr", "view", ...(branch === undefined ? [] : [branch]), "--json", "url"]);
|
|
114
81
|
if (view.status === 0) {
|
|
115
82
|
return parseOrCannotRun(() => parseView(view.stdout));
|
|
116
83
|
}
|
|
117
84
|
if (said(view.stderr).startsWith("no pull requests found")) {
|
|
118
|
-
return
|
|
85
|
+
return { none: said(view.stderr) };
|
|
119
86
|
}
|
|
120
87
|
throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
|
|
121
88
|
}
|
|
122
|
-
/**
|
|
123
|
-
* The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
|
|
124
|
-
* misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
|
|
125
|
-
*/
|
|
126
|
-
export function readBranch(sized, upstreamName) {
|
|
127
|
-
requireAuth();
|
|
128
|
-
const own = viewed(undefined);
|
|
129
|
-
if (own !== null) {
|
|
130
|
-
return { pr: query(own, sized), byUpstream: false };
|
|
131
|
-
}
|
|
132
|
-
if (upstreamName === undefined) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
const found = viewed(upstreamName);
|
|
136
|
-
return found === null ? null : { pr: query(found, sized), byUpstream: true };
|
|
137
|
-
}
|
|
138
89
|
/** The default branch of the repository gh resolves from the current directory. */
|
|
139
90
|
export function defaultBranch() {
|
|
140
91
|
const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
|
|
@@ -155,100 +106,88 @@ export function now() {
|
|
|
155
106
|
}
|
|
156
107
|
return time;
|
|
157
108
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* The compares this process has read, by repository and the two commits, so reads that share one ask gh
|
|
164
|
-
* once: a compare of two commits never changes.
|
|
165
|
-
*/
|
|
166
|
-
const COMPARES = new Map();
|
|
167
|
-
/** For a test that runs the command in its own process: each case starts with no compare read. */
|
|
168
|
-
export function forgetCompares() {
|
|
169
|
-
COMPARES.clear();
|
|
170
|
-
}
|
|
171
|
-
/** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
|
|
172
|
-
function compared(pr, from, commit, what) {
|
|
173
|
-
const key = `${pr.repo} ${from}...${commit}`;
|
|
174
|
-
const known = COMPARES.get(key);
|
|
175
|
-
if (known !== undefined) {
|
|
176
|
-
return known;
|
|
177
|
-
}
|
|
178
|
-
const reply = gh(compareArgs(pr.repo, from, commit));
|
|
179
|
-
if (reply.status !== 0) {
|
|
180
|
-
throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
181
|
-
}
|
|
182
|
-
COMPARES.set(key, reply.stdout);
|
|
183
|
-
return reply.stdout;
|
|
184
|
-
}
|
|
185
|
-
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
186
|
-
export function carriesBase(before, after) {
|
|
187
|
-
const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
|
|
188
|
-
return parseOrCannotRun(() => behindOf(reply)) === 0;
|
|
189
|
-
}
|
|
190
|
-
/** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
|
|
191
|
-
function ownChanges(pr, commit) {
|
|
192
|
-
const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
|
|
193
|
-
return parseOrCannotRun(() => changesOf(reply));
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* `pr` and whether its head carries the success of the last commit a Review read: a clean read, only
|
|
197
|
-
* merges since it, and the same own changes at both. A read mark never carries. The compares run only
|
|
198
|
-
* when a Review awaits the head.
|
|
199
|
-
*/
|
|
200
|
-
function withCarried(pr) {
|
|
201
|
-
const last = pr.lastRead;
|
|
202
|
-
if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
203
|
-
return { ...pr, carried: false };
|
|
204
|
-
}
|
|
205
|
-
return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* The files whose own change differs between `from` and the head of `pr`, a rename under both names:
|
|
209
|
-
* what a fix touched, without the files a merge of the base brought in. Null when a compare cannot show
|
|
210
|
-
* the own changes whole.
|
|
211
|
-
*/
|
|
212
|
-
function ownScope(pr, from) {
|
|
213
|
-
const before = ownChanges(pr, from);
|
|
214
|
-
const after = ownChanges(pr, pr.head);
|
|
215
|
-
if (before === null || after === null) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
const moved = [
|
|
219
|
-
...after.filter((change) => !before.includes(change)),
|
|
220
|
-
...before.filter((change) => !after.includes(change))
|
|
221
|
-
];
|
|
222
|
-
const names = moved.flatMap((change) => {
|
|
223
|
-
const [path, , previous] = JSON.parse(change);
|
|
224
|
-
return previous === null ? [path] : [path, previous];
|
|
225
|
-
});
|
|
226
|
-
return [...new Set(names)].toSorted();
|
|
109
|
+
/** A commit as a reason names it: its first seven characters. */
|
|
110
|
+
function short(commit) {
|
|
111
|
+
return commit.slice(0, 7);
|
|
227
112
|
}
|
|
228
113
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* from the base, to the head, kept to the scope when there is one.
|
|
114
|
+
* The gh adapter on `target`. A compare of two commits never changes, so it asks gh for each once, for its
|
|
115
|
+
* own life; a compare gh refuses stops the command, naming it.
|
|
232
116
|
*/
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
117
|
+
function ghCompares(target) {
|
|
118
|
+
const replies = new Map();
|
|
119
|
+
const replyOf = (from, to, what) => {
|
|
120
|
+
const key = `${from}...${to}`;
|
|
121
|
+
const known = replies.get(key);
|
|
122
|
+
if (known !== undefined) {
|
|
123
|
+
return known;
|
|
124
|
+
}
|
|
125
|
+
const reply = gh(compareArgs(target.repo, from, to));
|
|
126
|
+
if (reply.status !== 0) {
|
|
127
|
+
throw new CannotRun(`${target.repo}#${target.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
128
|
+
}
|
|
129
|
+
replies.set(key, reply.stdout);
|
|
130
|
+
return reply.stdout;
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
ownChanges: (base, commit) => parseOrCannotRun(() => changesOf(replyOf(base, commit, `the compare with ${short(commit)}`))),
|
|
134
|
+
changed: (from, head) => parseOrCannotRun(() => changedOf(replyOf(from, head, `the compare of ${short(from)}...${short(head)}`))),
|
|
135
|
+
behind: (base, head) => parseOrCannotRun(() => behindOf(replyOf(base, head, `the compare with ${short(base)}`)))
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** The pull request `named` from the one GraphQL request alone, without the compares the Review's standing reads. */
|
|
139
|
+
export function pullRequestNamed(named, command) {
|
|
140
|
+
requireAuth();
|
|
141
|
+
const target = targetOf(named, command);
|
|
142
|
+
return parseOrCannotRun(() => parsePr(graphql(target), target.repo)).pr;
|
|
143
|
+
}
|
|
144
|
+
/** The reads of one command; with `sized`, as `pr.review: auto` needs, each sizes the changes a Review would read. */
|
|
145
|
+
export function ghReads(sized) {
|
|
146
|
+
const adapters = new Map();
|
|
147
|
+
const comparesOn = (target) => {
|
|
148
|
+
const key = `${target.repo}#${target.number}`;
|
|
149
|
+
const adapter = adapters.get(key) ?? ghCompares(target);
|
|
150
|
+
adapters.set(key, adapter);
|
|
151
|
+
return adapter;
|
|
152
|
+
};
|
|
153
|
+
const query = (target) => {
|
|
154
|
+
const { pr, history } = parseOrCannotRun(() => parsePr(graphql(target), target.repo));
|
|
155
|
+
const awaits = pr.state === "OPEN" && awaitsReview(pr);
|
|
156
|
+
return { ...pr, ...standingOf(history, comparesOn(target), { awaits, sized }) };
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
named: (named, command) => {
|
|
160
|
+
requireAuth();
|
|
161
|
+
return query(targetOf(named, command));
|
|
162
|
+
},
|
|
163
|
+
checkout: (root) => {
|
|
164
|
+
requireAuth();
|
|
165
|
+
const own = viewed(undefined);
|
|
166
|
+
if (!("none" in own)) {
|
|
167
|
+
return query(own);
|
|
168
|
+
}
|
|
169
|
+
const renamed = renamedUpstream(root);
|
|
170
|
+
if (renamed === undefined) {
|
|
171
|
+
return { problem: `gh pr view found no pull request: ${own.none}`, lacksHead: false };
|
|
172
|
+
}
|
|
173
|
+
const { branch, head, upstream } = renamed;
|
|
174
|
+
const named = `${upstream}, the name its upstream gives`;
|
|
175
|
+
const found = viewed(upstream);
|
|
176
|
+
if ("none" in found) {
|
|
177
|
+
return { problem: `gh pr view found no pull request for ${branch}, nor for ${named}`, lacksHead: false };
|
|
178
|
+
}
|
|
179
|
+
// A branch started from another tracks that branch until its first push: its pull request is not this one.
|
|
180
|
+
const pr = query(found);
|
|
181
|
+
return holds(pr, head)
|
|
182
|
+
? pr
|
|
183
|
+
: { problem: `${pr.url}, found by ${named}, lacks HEAD ${short(head)}`, lacksHead: true };
|
|
184
|
+
},
|
|
185
|
+
again: query,
|
|
186
|
+
carriesBase: (before, after) => comparesOn(after).behind(before.baseOid, after.head) === 0
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** The reply of the one GraphQL request for pull request `number` of `repo`. */
|
|
190
|
+
function graphql({ repo, number }) {
|
|
252
191
|
const [owner = "", name = ""] = repo.split("/");
|
|
253
192
|
const reply = gh([
|
|
254
193
|
"api",
|
|
@@ -267,6 +206,5 @@ function query({ repo, number }, sized) {
|
|
|
267
206
|
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
268
207
|
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
269
208
|
}
|
|
270
|
-
|
|
271
|
-
return withChanged(withCarried(parsed), sized);
|
|
209
|
+
return reply.stdout;
|
|
272
210
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
2
|
+
export declare class ReplyError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
|
|
5
|
+
export type Node = {
|
|
6
|
+
value: Record<string, unknown>;
|
|
7
|
+
at: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function shown(value: unknown): string;
|
|
10
|
+
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
11
|
+
export declare function rootOf(reply: string, what: string): Node;
|
|
12
|
+
export declare function fieldOf(node: Node, key: string): {
|
|
13
|
+
value: unknown;
|
|
14
|
+
at: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function object(value: unknown, at: string): Node;
|
|
17
|
+
export declare function child(node: Node, key: string): Node;
|
|
18
|
+
export declare function childOrNull(node: Node, key: string): Node | null;
|
|
19
|
+
export declare function string(node: Node, key: string): string;
|
|
20
|
+
export declare function stringOrNull(node: Node, key: string): string | null;
|
|
21
|
+
export declare function boolean(node: Node, key: string): boolean;
|
|
22
|
+
export declare function integer(node: Node, key: string): number;
|
|
23
|
+
export declare function integerOrNull(node: Node, key: string): number | null;
|
|
24
|
+
/** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
|
|
25
|
+
export declare function time(node: Node, key: string): string;
|
|
26
|
+
export declare function timeOrNull(node: Node, key: string): string | null;
|
|
27
|
+
export declare function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T;
|
|
28
|
+
export declare function oneOf<T extends string>(node: Node, key: string, values: readonly T[]): T;
|
|
29
|
+
/** The objects of a connection's `nodes`. */
|
|
30
|
+
export declare function nodes(node: Node, key: string): Node[];
|
|
31
|
+
/** Whether a connection has more nodes after the ones the reply holds. */
|
|
32
|
+
export declare function hasMore(node: Node, key: string): boolean;
|
|
33
|
+
/** The first object of a connection's `nodes`; an empty connection does not parse. */
|
|
34
|
+
export declare function firstNode(node: Node, key: string): Node;
|
package/dist/pr/reply.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The reply of a gh call read as JSON: each field checked as the type the reader asks for, and a
|
|
2
|
+
// reply that fails names the field by its path.
|
|
3
|
+
import { isObject } from "../json-text.js";
|
|
4
|
+
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
5
|
+
export class ReplyError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export function shown(value) {
|
|
8
|
+
return value === undefined ? "missing" : JSON.stringify(value);
|
|
9
|
+
}
|
|
10
|
+
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
11
|
+
export function rootOf(reply, what) {
|
|
12
|
+
let json;
|
|
13
|
+
try {
|
|
14
|
+
json = JSON.parse(reply);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new ReplyError(`${what} is not JSON`);
|
|
18
|
+
}
|
|
19
|
+
if (!isObject(json)) {
|
|
20
|
+
throw new ReplyError(`${what} is not a JSON object`);
|
|
21
|
+
}
|
|
22
|
+
return { value: json, at: "" };
|
|
23
|
+
}
|
|
24
|
+
export function fieldOf(node, key) {
|
|
25
|
+
return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
|
|
26
|
+
}
|
|
27
|
+
export function object(value, at) {
|
|
28
|
+
if (!isObject(value)) {
|
|
29
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an object`);
|
|
30
|
+
}
|
|
31
|
+
return { value, at };
|
|
32
|
+
}
|
|
33
|
+
export function child(node, key) {
|
|
34
|
+
const { value, at } = fieldOf(node, key);
|
|
35
|
+
return object(value, at);
|
|
36
|
+
}
|
|
37
|
+
export function childOrNull(node, key) {
|
|
38
|
+
const { value, at } = fieldOf(node, key);
|
|
39
|
+
return value === null ? null : object(value, at);
|
|
40
|
+
}
|
|
41
|
+
export function string(node, key) {
|
|
42
|
+
const { value, at } = fieldOf(node, key);
|
|
43
|
+
if (typeof value !== "string") {
|
|
44
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a string`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function stringOrNull(node, key) {
|
|
49
|
+
return node.value[key] === null ? null : string(node, key);
|
|
50
|
+
}
|
|
51
|
+
export function boolean(node, key) {
|
|
52
|
+
const { value, at } = fieldOf(node, key);
|
|
53
|
+
if (typeof value !== "boolean") {
|
|
54
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function integer(node, key) {
|
|
59
|
+
const { value, at } = fieldOf(node, key);
|
|
60
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
61
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function integerOrNull(node, key) {
|
|
66
|
+
const { value, at } = fieldOf(node, key);
|
|
67
|
+
if (value !== null && !Number.isInteger(value)) {
|
|
68
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
|
|
69
|
+
}
|
|
70
|
+
return typeof value === "number" ? value : null;
|
|
71
|
+
}
|
|
72
|
+
/** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
|
|
73
|
+
export function time(node, key) {
|
|
74
|
+
const value = string(node, key);
|
|
75
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
76
|
+
throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
export function timeOrNull(node, key) {
|
|
81
|
+
return node.value[key] === null ? null : time(node, key);
|
|
82
|
+
}
|
|
83
|
+
export function isOneOf(values, value) {
|
|
84
|
+
return values.some((known) => known === value);
|
|
85
|
+
}
|
|
86
|
+
export function oneOf(node, key, values) {
|
|
87
|
+
const { value, at } = fieldOf(node, key);
|
|
88
|
+
if (!isOneOf(values, value)) {
|
|
89
|
+
throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
/** The objects of a connection's `nodes`. */
|
|
94
|
+
export function nodes(node, key) {
|
|
95
|
+
const { value, at } = fieldOf(child(node, key), "nodes");
|
|
96
|
+
if (!Array.isArray(value)) {
|
|
97
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a list`);
|
|
98
|
+
}
|
|
99
|
+
return value.map((item, index) => object(item, `${at}[${index}]`));
|
|
100
|
+
}
|
|
101
|
+
/** Whether a connection has more nodes after the ones the reply holds. */
|
|
102
|
+
export function hasMore(node, key) {
|
|
103
|
+
return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
|
|
104
|
+
}
|
|
105
|
+
/** The first object of a connection's `nodes`; an empty connection does not parse. */
|
|
106
|
+
export function firstNode(node, key) {
|
|
107
|
+
const [first] = nodes(node, key);
|
|
108
|
+
if (first === undefined) {
|
|
109
|
+
throw new ReplyError(`${node.at}.${key}.nodes is empty`);
|
|
110
|
+
}
|
|
111
|
+
return first;
|
|
112
|
+
}
|
package/dist/pr/route.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { ReviewDepth } from "../agentic-config.ts";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
|
|
4
|
+
* null when the compare shows no patch for it, as for a binary file.
|
|
5
|
+
*/
|
|
6
|
+
export type ChangedFile = {
|
|
7
|
+
path: string;
|
|
8
|
+
previous: string | null;
|
|
9
|
+
lines: number | null;
|
|
10
|
+
};
|
|
3
11
|
/** A routed depth, and under off why no one reads the changes, as the status the script posts says. */
|
|
4
12
|
export type Routed = {
|
|
5
13
|
depth: Exclude<ReviewDepth, "off">;
|
|
@@ -9,6 +17,6 @@ export type Routed = {
|
|
|
9
17
|
};
|
|
10
18
|
/**
|
|
11
19
|
* The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
|
|
12
|
-
* outside lockfiles the compare shows no patch for.
|
|
20
|
+
* outside lockfiles the compare shows no patch for. With `findingsOpen` someone reads even lockfiles alone.
|
|
13
21
|
*/
|
|
14
|
-
export declare function route(changed: ChangedFile[] | "unsized" | null): Routed;
|
|
22
|
+
export declare function route(changed: ChangedFile[] | "unsized" | null, findingsOpen: boolean): Routed;
|
package/dist/pr/route.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The built-in rules `pr.review: auto` routes a Review depth by, from the files the Review would read:
|
|
2
|
-
// lockfiles alone are off; a workflow file, more than 100 changed lines outside
|
|
3
|
-
// the compare cannot size is full; the rest is light. No config key moves them.
|
|
2
|
+
// lockfiles alone are off while no Finding is open; a workflow file, more than 100 changed lines outside
|
|
3
|
+
// lockfiles, or a change the compare cannot size is full; the rest is light. No config key moves them.
|
|
4
4
|
/** The lockfiles a package manager writes, by file name in any directory. */
|
|
5
5
|
const LOCKFILES = new Set([
|
|
6
6
|
"pnpm-lock.yaml",
|
|
@@ -33,14 +33,14 @@ function isWorkflow({ path, previous }) {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
|
|
36
|
-
* outside lockfiles the compare shows no patch for.
|
|
36
|
+
* outside lockfiles the compare shows no patch for. With `findingsOpen` someone reads even lockfiles alone.
|
|
37
37
|
*/
|
|
38
|
-
export function route(changed) {
|
|
38
|
+
export function route(changed, findingsOpen) {
|
|
39
39
|
if (changed === null || changed === "unsized") {
|
|
40
40
|
return { depth: "full" };
|
|
41
41
|
}
|
|
42
42
|
const code = changed.filter((file) => !isLockfile(file.path));
|
|
43
|
-
if (changed.length > 0 && code.length === 0) {
|
|
43
|
+
if (!findingsOpen && changed.length > 0 && code.length === 0) {
|
|
44
44
|
return { depth: "off", why: "lockfiles only" };
|
|
45
45
|
}
|
|
46
46
|
const lines = code.map((file) => file.lines);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { PrReview, ReviewDepth } from "../agentic-config.ts";
|
|
2
|
+
import { type ChangedFile } from "./route.ts";
|
|
3
|
+
/** What a commit's review status says: a success, the read mark, a success posted under off, or none. */
|
|
4
|
+
export type Posted = "clean" | "findings" | "off" | null;
|
|
5
|
+
/** A commit of the history and the review status it carries; `merge` when it has more than one parent. */
|
|
6
|
+
export type Commit = {
|
|
7
|
+
oid: string;
|
|
8
|
+
posted: Posted;
|
|
9
|
+
merge: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A commit a force push left behind and its review status; `forksAt` the index in the history of the
|
|
13
|
+
* commit its line was pushed on, as under an amend, -1 when its line holds none, as after a rebase.
|
|
14
|
+
*/
|
|
15
|
+
export type LeftBehind = {
|
|
16
|
+
oid: string;
|
|
17
|
+
posted: Posted;
|
|
18
|
+
forksAt: number;
|
|
19
|
+
};
|
|
20
|
+
/** What the standing is read from. */
|
|
21
|
+
export type History = {
|
|
22
|
+
/** The pull request's last 100 commits, oldest first, the head last. */
|
|
23
|
+
commits: Commit[];
|
|
24
|
+
/** The commits the last force pushes left behind, newest push first, none of them among `commits`. */
|
|
25
|
+
leftBehind: LeftBehind[];
|
|
26
|
+
head: string;
|
|
27
|
+
/** The commit the base branch points at, the one the own changes are compared with. */
|
|
28
|
+
base: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* A file's own change: its name, its status, its name before a rename, and the lines its patch adds and
|
|
32
|
+
* removes, in order, without the hunk headers and context a moved base shifts.
|
|
33
|
+
*/
|
|
34
|
+
export type OwnChange = {
|
|
35
|
+
path: string;
|
|
36
|
+
status: string;
|
|
37
|
+
previous: string | null;
|
|
38
|
+
lines: string[];
|
|
39
|
+
};
|
|
40
|
+
/** GitHub's compares as the standing asks them. A compare of two commits never changes: an adapter may keep it. */
|
|
41
|
+
export type Compares = {
|
|
42
|
+
/** The pull request's own changes at `commit`, from `base`; null when the compare cannot show them whole. */
|
|
43
|
+
ownChanges: (base: string, commit: string) => OwnChange[] | null;
|
|
44
|
+
/** The files from `from` to `head` and their changed lines; `unsized` when the compare may hold some back. */
|
|
45
|
+
changed: (from: string, head: string) => ChangedFile[] | "unsized";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* A commit a Review read, `clean` when it left a success there and not a read mark; `baseOnly` when every
|
|
49
|
+
* change after it is the base's, the head included: only merges of the base, or a rebase past no other
|
|
50
|
+
* read; `baseMoved` when a merge of the base or a rebase came after it.
|
|
51
|
+
*/
|
|
52
|
+
export type LastRead = {
|
|
53
|
+
commit: string;
|
|
54
|
+
clean: boolean;
|
|
55
|
+
baseOnly: boolean;
|
|
56
|
+
baseMoved: boolean;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The Reviews that left a Finding open since the last review success, a success under off and the dev's
|
|
60
|
+
* acceptance included: `count` their read marks, the head's own among them, and `since` that success's
|
|
61
|
+
* commit, null when no commit read holds one.
|
|
62
|
+
*/
|
|
63
|
+
export type ReadMarks = {
|
|
64
|
+
count: number;
|
|
65
|
+
since: string | null;
|
|
66
|
+
};
|
|
67
|
+
/** The Review's standing at the head, as the decision reads it and `pr ship --verbose` shows it. */
|
|
68
|
+
export type Standing = {
|
|
69
|
+
/**
|
|
70
|
+
* A read mark force pushes left behind after every read of the history, else the last commit before the
|
|
71
|
+
* head a Review read, else a commit force pushes left behind that one read.
|
|
72
|
+
*/
|
|
73
|
+
lastRead: LastRead | null;
|
|
74
|
+
/**
|
|
75
|
+
* A read mark whose Findings no later commit answers: the head's own, or one the commits since leave with
|
|
76
|
+
* no fix showing, merges of the base and rebases among them. Then `lastRead` is null and nothing carries
|
|
77
|
+
* (ADR 0023).
|
|
78
|
+
*/
|
|
79
|
+
open: string | null;
|
|
80
|
+
/** The read marks posted since the last success, on the history and on the commits force pushes left behind. */
|
|
81
|
+
marks: ReadMarks;
|
|
82
|
+
/** Whether the head carries the success of `lastRead`, which two compares decide. */
|
|
83
|
+
carried: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
|
|
86
|
+
* auto` to route on: `unsized` when the compare cannot list them all, null when not read.
|
|
87
|
+
*/
|
|
88
|
+
changed: ChangedFile[] | "unsized" | null;
|
|
89
|
+
/**
|
|
90
|
+
* The files whose own change moved since `lastRead`, when a merge of the base or a rebase came after it:
|
|
91
|
+
* the files a fix touched, the base's own left out. Null with neither, or when a compare cannot show them.
|
|
92
|
+
*/
|
|
93
|
+
scope: string[] | null;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The standing at the head of `history`. The compares run only when a Review `awaits` the head, and the
|
|
97
|
+
* files that Review would read are read only when `sized`, as `pr.review: auto` routes on them.
|
|
98
|
+
*/
|
|
99
|
+
export declare function standingOf(history: History, compares: Compares, { awaits, sized }: {
|
|
100
|
+
awaits: boolean;
|
|
101
|
+
sized: boolean;
|
|
102
|
+
}): Standing;
|
|
103
|
+
/** Who set the depth: the dev's `--review` for this call, or `pr.review`, a depth or `auto`. */
|
|
104
|
+
export type ReviewPolicy = {
|
|
105
|
+
depth: ReviewDepth;
|
|
106
|
+
source: "flag";
|
|
107
|
+
} | {
|
|
108
|
+
depth: PrReview;
|
|
109
|
+
source: "config";
|
|
110
|
+
};
|
|
111
|
+
/** Where a Review depth comes from: the dev's word, `pr.review`, or the rules `pr.review: auto` routes by. */
|
|
112
|
+
export type DepthSource = "flag" | "config" | "auto";
|
|
113
|
+
/** A depth as the answer names it, and under off why no one reads the head. */
|
|
114
|
+
export type Depth = {
|
|
115
|
+
depth: Exclude<ReviewDepth, "off">;
|
|
116
|
+
source: DepthSource;
|
|
117
|
+
} | {
|
|
118
|
+
depth: "off";
|
|
119
|
+
source: DepthSource;
|
|
120
|
+
why: string;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The Review the head gets: the success of the read it carries; a Review at a depth that reads from `since`,
|
|
124
|
+
* kept to `paths`, over the read mark left `open`, where under off the script posts the success; or the park.
|
|
125
|
+
*/
|
|
126
|
+
export type ReviewCall = {
|
|
127
|
+
kind: "carry";
|
|
128
|
+
from: string;
|
|
129
|
+
} | (Depth & {
|
|
130
|
+
kind: "review";
|
|
131
|
+
since: string | null;
|
|
132
|
+
paths: string[] | null;
|
|
133
|
+
open: string | null;
|
|
134
|
+
}) | (ReadMarks & {
|
|
135
|
+
kind: "limit";
|
|
136
|
+
});
|
|
137
|
+
/** The Review the head gets at `standing` under `review`; the dev's word goes over a carry. */
|
|
138
|
+
export declare function reviewCall(standing: Standing, review: ReviewPolicy): ReviewCall;
|