dw-sdlc 0.7.0 → 0.10.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 +23 -12
- package/config/agentic.config.schema.json +77 -18
- package/dist/agentic-config.d.ts +41 -7
- package/dist/agentic-config.js +36 -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 +6 -22
- package/dist/commands/pr-ship.js +119 -30
- package/dist/commands/references-sync.js +5 -6
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +17 -9
- 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 +37 -9
- package/dist/pr/decide.js +113 -32
- package/dist/pr/delta.d.ts +11 -0
- package/dist/pr/delta.js +60 -0
- package/dist/pr/read.d.ts +8 -3
- package/dist/pr/read.js +56 -10
- package/dist/pr/route.d.ts +14 -0
- package/dist/pr/route.js +52 -0
- package/dist/pr/state.d.ts +48 -2
- package/dist/pr/state.js +42 -9
- 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/setup-worker.d.ts +11 -0
- package/dist/setup-worker.js +37 -0
- 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/pr/decide.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import type { PrMerge } from "../agentic-config.ts";
|
|
2
|
-
import type
|
|
1
|
+
import type { PrMerge, PrReview, ReviewDepth } from "../agentic-config.ts";
|
|
2
|
+
import { type PrState, REVIEW } from "./state.ts";
|
|
3
|
+
export { REVIEW };
|
|
3
4
|
export type Status = "act" | "wait" | "park" | "done";
|
|
4
5
|
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
|
+
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
7
|
/** The Actions the script performs itself, each one gh command. */
|
|
7
|
-
export type ScriptAction = "update-branch" | "mark-ready" | "merge";
|
|
8
|
+
export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
|
|
9
|
+
/**
|
|
10
|
+
* Where a Review depth comes from: the dev's `--review` for this call, a depth `pr.review` sets, or the
|
|
11
|
+
* rules `pr.review: auto` routes it by.
|
|
12
|
+
*/
|
|
13
|
+
export type DepthSource = "flag" | "config" | "auto";
|
|
14
|
+
/**
|
|
15
|
+
* What the decision reads beside the pull request: who merges it, how closely a Review reads it, the
|
|
16
|
+
* dev's word or `pr.review`, and the slash command a `full` Review runs.
|
|
17
|
+
*/
|
|
18
|
+
export type Policy = {
|
|
19
|
+
merge: PrMerge;
|
|
20
|
+
review: {
|
|
21
|
+
depth: ReviewDepth;
|
|
22
|
+
source: "flag";
|
|
23
|
+
} | {
|
|
24
|
+
depth: PrReview;
|
|
25
|
+
source: "config";
|
|
26
|
+
};
|
|
27
|
+
reviewCommand: string;
|
|
28
|
+
};
|
|
8
29
|
/** An Action the pull request needs beside the one its answer names, so one push can fix several. */
|
|
9
30
|
export type Blocker = {
|
|
10
31
|
action: Action;
|
|
@@ -20,17 +41,24 @@ export type Answer = Blocker & {
|
|
|
20
41
|
recheckAfter?: number;
|
|
21
42
|
blockers: Blocker[];
|
|
22
43
|
};
|
|
44
|
+
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
45
|
+
export declare const REVIEW_STATE = "success";
|
|
23
46
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
24
47
|
export declare function shellLine(words: string[]): string;
|
|
48
|
+
/**
|
|
49
|
+
* The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
|
|
50
|
+
* read. The path names the repository, so the call reads the same from any checkout.
|
|
51
|
+
*/
|
|
52
|
+
export declare function reviewStatusArgs(pr: PrState, description: string): string[];
|
|
25
53
|
export declare function isScriptAction(action: Action): action is ScriptAction;
|
|
26
54
|
/**
|
|
27
55
|
* The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
|
|
28
56
|
* base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
|
|
29
57
|
*/
|
|
30
|
-
export declare function hostArgs(pr: PrState, action: ScriptAction, named: boolean): string[];
|
|
58
|
+
export declare function hostArgs(pr: PrState, action: ScriptAction, policy: Policy, named: boolean): string[];
|
|
31
59
|
/** 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;
|
|
60
|
+
export declare function devCommand(pr: PrState, action: ScriptAction, policy: Policy): string;
|
|
33
61
|
/** 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,
|
|
62
|
+
export declare function readyToMerge(pr: PrState, policy: Policy, reason: string): Omit<Answer, "blockers">;
|
|
63
|
+
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
64
|
+
export declare function decide(pr: PrState, now: number, policy: Policy): Answer;
|
package/dist/pr/decide.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// The decision of the Ship script: one ordered list of rows, and the first row whose `when` holds for
|
|
2
2
|
// the pull request answers its Action at the time given; every later row that holds rides in its
|
|
3
3
|
// blockers. The last rows say who merges what no row before them holds back.
|
|
4
|
+
import { route } from "./route.js";
|
|
5
|
+
import { awaitsReview, OFF_PREFIX, REVIEW } from "./state.js";
|
|
6
|
+
export { REVIEW };
|
|
4
7
|
/** How long a head may carry no check before it parks as unverified, from its commit. */
|
|
5
8
|
const GRACE_SECONDS = 120;
|
|
6
9
|
/** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
|
|
7
10
|
const BUDGET_MINUTES = 40;
|
|
8
11
|
/** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
|
|
9
12
|
const OWN_PREFIX = "dw-sdlc/";
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
13
|
+
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
14
|
+
export const REVIEW_STATE = "success";
|
|
12
15
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
13
16
|
export function shellLine(words) {
|
|
14
17
|
return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
|
|
@@ -33,10 +36,59 @@ function threads(pr, lastByViewer) {
|
|
|
33
36
|
function listed({ id, path, line, author, body }) {
|
|
34
37
|
return { id, path, line, author, body };
|
|
35
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
|
|
41
|
+
* read. The path names the repository, so the call reads the same from any checkout.
|
|
42
|
+
*/
|
|
43
|
+
export function reviewStatusArgs(pr, description) {
|
|
44
|
+
const fields = { state: REVIEW_STATE, context: REVIEW, target_url: pr.url, description };
|
|
45
|
+
const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
|
|
46
|
+
return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
|
|
47
|
+
}
|
|
48
|
+
/** Whether the dev named a depth for a head that would carry the status: their word beats the carry. */
|
|
49
|
+
function flagOverCarry(pr, { review }) {
|
|
50
|
+
return pr.carried && review.source === "flag";
|
|
51
|
+
}
|
|
52
|
+
/** The reviewed commit whose success the head carries, if it does and the dev named no depth. */
|
|
53
|
+
function carriedFrom(pr, policy) {
|
|
54
|
+
return pr.carried && !flagOverCarry(pr, policy) ? (pr.lastReviewed?.commit ?? null) : null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The commit the Review reads from: the last reviewed one. Over a carry the dev's depth reads the whole
|
|
58
|
+
* diff, since the changes after that commit are only the base's.
|
|
59
|
+
*/
|
|
60
|
+
function sinceOf(pr, policy) {
|
|
61
|
+
return flagOverCarry(pr, policy) ? null : (pr.lastReviewed?.commit ?? null);
|
|
62
|
+
}
|
|
63
|
+
/** The depth a Review reads the head at: the dev's or the one `pr.review` sets, else the one the rules route. */
|
|
64
|
+
function depthOf(pr, { review }) {
|
|
65
|
+
const { depth, source } = review;
|
|
66
|
+
if (depth === "off") {
|
|
67
|
+
return { depth, source, why: source === "flag" ? "--review off" : "pr.review" };
|
|
68
|
+
}
|
|
69
|
+
if (depth !== "auto") {
|
|
70
|
+
return { depth, source };
|
|
71
|
+
}
|
|
72
|
+
return { ...route(pr.changed), source: "auto" };
|
|
73
|
+
}
|
|
74
|
+
/** Why the head carries the success the script posts: the Review it carries, else why its depth is off. */
|
|
75
|
+
function markedDescription(pr, policy) {
|
|
76
|
+
const carried = carriedFrom(pr, policy);
|
|
77
|
+
if (carried !== null) {
|
|
78
|
+
return `carried from ${carried.slice(0, 7)}`;
|
|
79
|
+
}
|
|
80
|
+
const depth = depthOf(pr, policy);
|
|
81
|
+
if (depth.depth !== "off") {
|
|
82
|
+
throw new Error(`the script marks ${pr.url} reviewed at ${depth.depth}, neither off nor a carry`);
|
|
83
|
+
}
|
|
84
|
+
return `${OFF_PREFIX} ${depth.why}`;
|
|
85
|
+
}
|
|
86
|
+
/** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
|
|
36
87
|
const HOST = {
|
|
37
|
-
"update-branch":
|
|
38
|
-
"mark-ready":
|
|
39
|
-
|
|
88
|
+
"update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
|
|
89
|
+
"mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
|
|
90
|
+
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, markedDescription(pr, policy)),
|
|
91
|
+
merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
|
|
40
92
|
};
|
|
41
93
|
export function isScriptAction(action) {
|
|
42
94
|
return Object.hasOwn(HOST, action);
|
|
@@ -45,17 +97,16 @@ export function isScriptAction(action) {
|
|
|
45
97
|
* The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
|
|
46
98
|
* base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
|
|
47
99
|
*/
|
|
48
|
-
export function hostArgs(pr, action, named) {
|
|
49
|
-
|
|
50
|
-
return ["pr", verb, String(pr.number), ...(named ? ["-R", pr.repo] : []), ...flags(pr)];
|
|
100
|
+
export function hostArgs(pr, action, policy, named) {
|
|
101
|
+
return HOST[action](pr, named ? ["-R", pr.repo] : [], policy);
|
|
51
102
|
}
|
|
52
103
|
/** 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)]);
|
|
104
|
+
export function devCommand(pr, action, policy) {
|
|
105
|
+
return shellLine(["gh", ...hostArgs(pr, action, policy, false)]);
|
|
55
106
|
}
|
|
56
107
|
/** 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") };
|
|
108
|
+
export function readyToMerge(pr, policy, reason) {
|
|
109
|
+
return { status: "park", action: "ready-to-merge", actor: "dev", reason, command: devCommand(pr, "merge", policy) };
|
|
59
110
|
}
|
|
60
111
|
const ROWS = [
|
|
61
112
|
{
|
|
@@ -125,24 +176,42 @@ const ROWS = [
|
|
|
125
176
|
answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
|
|
126
177
|
},
|
|
127
178
|
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}`
|
|
134
|
-
|
|
179
|
+
// Another author's pull request needs no Review: the dev merges it. The script posts the success
|
|
180
|
+
// under off, and over a carry, which reads at no depth.
|
|
181
|
+
when: awaitsReview,
|
|
182
|
+
answer: (pr, _, policy) => {
|
|
183
|
+
const since = sinceOf(pr, policy);
|
|
184
|
+
const head = { status: "act", reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}` };
|
|
185
|
+
const after = since === null ? {} : { since };
|
|
186
|
+
if (carriedFrom(pr, policy) !== null) {
|
|
187
|
+
return { ...head, action: "mark-reviewed", actor: "script", details: after };
|
|
188
|
+
}
|
|
189
|
+
const { depth, source } = depthOf(pr, policy);
|
|
190
|
+
const byScript = depth === "off";
|
|
191
|
+
return {
|
|
192
|
+
...head,
|
|
193
|
+
action: byScript ? "mark-reviewed" : "review",
|
|
194
|
+
actor: byScript ? "script" : "agent",
|
|
195
|
+
details: {
|
|
196
|
+
depth,
|
|
197
|
+
source,
|
|
198
|
+
// The agent may raise a routed depth to full, so auto names the command at any depth.
|
|
199
|
+
...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
|
|
200
|
+
...after
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
135
204
|
},
|
|
136
205
|
{
|
|
137
206
|
when: (pr) => ciOf(pr).length === 0,
|
|
138
|
-
answer: (pr, now) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
|
|
207
|
+
answer: (pr, now, policy) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
|
|
139
208
|
? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
|
|
140
209
|
: {
|
|
141
210
|
status: "park",
|
|
142
211
|
action: "unverified",
|
|
143
212
|
actor: "dev",
|
|
144
213
|
reason: `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`,
|
|
145
|
-
command: devCommand(pr, "merge")
|
|
214
|
+
command: devCommand(pr, "merge", policy)
|
|
146
215
|
}
|
|
147
216
|
},
|
|
148
217
|
{
|
|
@@ -173,9 +242,18 @@ const ROWS = [
|
|
|
173
242
|
})
|
|
174
243
|
},
|
|
175
244
|
{
|
|
176
|
-
when: (pr) => pr.draft,
|
|
245
|
+
when: (pr) => pr.draft && pr.viewerDidAuthor,
|
|
177
246
|
answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
|
|
178
247
|
},
|
|
248
|
+
{
|
|
249
|
+
when: (pr) => pr.draft && !pr.viewerDidAuthor,
|
|
250
|
+
answer: () => ({
|
|
251
|
+
status: "park",
|
|
252
|
+
action: "awaiting-author",
|
|
253
|
+
actor: "dev",
|
|
254
|
+
reason: "another author's draft: marking it ready is the author's"
|
|
255
|
+
})
|
|
256
|
+
},
|
|
179
257
|
{
|
|
180
258
|
when: (pr) => pr.reviewDecision === "REVIEW_REQUIRED",
|
|
181
259
|
answer: () => ({
|
|
@@ -187,30 +265,33 @@ const ROWS = [
|
|
|
187
265
|
},
|
|
188
266
|
{
|
|
189
267
|
when: (pr) => !pr.viewerDidAuthor,
|
|
190
|
-
answer: (pr) => readyToMerge(pr, "another author's pull request: the merge is the dev's"),
|
|
268
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "another author's pull request: the merge is the dev's"),
|
|
191
269
|
merging: true
|
|
192
270
|
},
|
|
193
271
|
{
|
|
194
|
-
when: (_, merge) => merge === "auto",
|
|
272
|
+
when: (_, { merge }) => merge === "auto",
|
|
195
273
|
answer: () => ({ status: "act", action: "merge", actor: "script", reason: "green, reviewed and the viewer's own" }),
|
|
196
274
|
merging: true
|
|
197
275
|
},
|
|
198
276
|
{
|
|
199
|
-
when: (_, merge) => merge === "ask",
|
|
200
|
-
answer: (pr) => readyToMerge(pr, "pr.merge is ask: the merge is the dev's"),
|
|
277
|
+
when: (_, { merge }) => merge === "ask",
|
|
278
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "pr.merge is ask: the merge is the dev's"),
|
|
201
279
|
merging: true
|
|
202
280
|
}
|
|
203
281
|
];
|
|
204
282
|
function blockerOf({ action, actor, reason, details }) {
|
|
205
283
|
return { action, actor, reason, ...(details === undefined ? {} : { details }) };
|
|
206
284
|
}
|
|
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,
|
|
285
|
+
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
286
|
+
export function decide(pr, now, policy) {
|
|
287
|
+
const index = ROWS.findIndex((row) => row.when(pr, policy));
|
|
210
288
|
const row = ROWS[index];
|
|
211
289
|
if (row === undefined) {
|
|
212
|
-
throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${merge}`);
|
|
290
|
+
throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${policy.merge}`);
|
|
213
291
|
}
|
|
214
|
-
const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr,
|
|
215
|
-
return {
|
|
292
|
+
const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr, policy));
|
|
293
|
+
return {
|
|
294
|
+
...row.answer(pr, now, policy),
|
|
295
|
+
blockers: later.map((other) => blockerOf(other.answer(pr, now, policy)))
|
|
296
|
+
};
|
|
216
297
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ChangedFile } from "./state.ts";
|
|
2
|
+
/** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
|
|
3
|
+
export declare function compareArgs(repo: string, from: string, commit: string): string[];
|
|
4
|
+
/** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
|
|
5
|
+
export declare function behindOf(reply: string): number;
|
|
6
|
+
/** The own changes a compare reply lists, file by file; null when it cannot show them whole. */
|
|
7
|
+
export declare function changesOf(reply: string): string[] | null;
|
|
8
|
+
/** The files a compare reply lists with their changed lines; `unsized` when it may hold some back. */
|
|
9
|
+
export declare function changedOf(reply: string): ChangedFile[] | "unsized";
|
|
10
|
+
/** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
|
|
11
|
+
export declare function sameChanges(then: string[] | null, now: string[] | null): boolean;
|
package/dist/pr/delta.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// GitHub's compare of two commits, read two ways: whether a head carries the Review of an earlier commit,
|
|
2
|
+
// the pull request's own changes from the base the same at both, since a merge of the base in adds none
|
|
3
|
+
// of its own; and the files a Review would read, with their changed lines, for `pr.review: auto`.
|
|
4
|
+
import { fieldOf, integer, object, ReplyError, rootOf, shown, string } from "./state.js";
|
|
5
|
+
/** GitHub's compare lists at most this many files; one that lists as many may hold back the rest. */
|
|
6
|
+
const FILE_LIMIT = 300;
|
|
7
|
+
/** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
|
|
8
|
+
export function compareArgs(repo, from, commit) {
|
|
9
|
+
return ["api", `repos/${repo}/compare/${from}...${commit}?per_page=1`];
|
|
10
|
+
}
|
|
11
|
+
/** The files a compare reply lists. */
|
|
12
|
+
function filesOf(reply) {
|
|
13
|
+
const { value, at } = fieldOf(rootOf(reply, "the compare"), "files");
|
|
14
|
+
if (!Array.isArray(value)) {
|
|
15
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a list`);
|
|
16
|
+
}
|
|
17
|
+
return value.map((item, index) => object(item, `${at}[${index}]`));
|
|
18
|
+
}
|
|
19
|
+
/** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
|
|
20
|
+
export function behindOf(reply) {
|
|
21
|
+
return integer(rootOf(reply, "the compare"), "behind_by");
|
|
22
|
+
}
|
|
23
|
+
/** A field the compare may leave out, as a file's patch or its previous name. */
|
|
24
|
+
function optional(file, key) {
|
|
25
|
+
return file.value[key] === undefined || file.value[key] === null ? null : string(file, key);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A file's own change: its status, its names and the lines its patch adds and removes, in order,
|
|
29
|
+
* without the hunk headers and context a moved base shifts; null when the compare shows no patch.
|
|
30
|
+
*/
|
|
31
|
+
function changeOf(file) {
|
|
32
|
+
const names = [string(file, "filename"), string(file, "status"), optional(file, "previous_filename")];
|
|
33
|
+
const patch = optional(file, "patch");
|
|
34
|
+
if (patch === null) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify([...names, patch.split("\n").filter((line) => /^[-+\\]/.test(line))]);
|
|
38
|
+
}
|
|
39
|
+
/** The own changes a compare reply lists, file by file; null when it cannot show them whole. */
|
|
40
|
+
export function changesOf(reply) {
|
|
41
|
+
const changes = filesOf(reply).map(changeOf);
|
|
42
|
+
const whole = changes.filter((change) => change !== null);
|
|
43
|
+
return changes.length >= FILE_LIMIT || whole.length < changes.length ? null : whole;
|
|
44
|
+
}
|
|
45
|
+
/** The files a compare reply lists with their changed lines; `unsized` when it may hold some back. */
|
|
46
|
+
export function changedOf(reply) {
|
|
47
|
+
const files = filesOf(reply).map((file) => ({
|
|
48
|
+
path: string(file, "filename"),
|
|
49
|
+
previous: optional(file, "previous_filename"),
|
|
50
|
+
lines: optional(file, "patch") === null ? null : integer(file, "additions") + integer(file, "deletions")
|
|
51
|
+
}));
|
|
52
|
+
return files.length >= FILE_LIMIT ? "unsized" : files;
|
|
53
|
+
}
|
|
54
|
+
/** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
|
|
55
|
+
export function sameChanges(then, now) {
|
|
56
|
+
if (then === null || now === null) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(then.toSorted()) === JSON.stringify(now.toSorted());
|
|
60
|
+
}
|
package/dist/pr/read.d.ts
CHANGED
|
@@ -18,7 +18,12 @@ export declare function gh(args: string[]): {
|
|
|
18
18
|
stdout: string;
|
|
19
19
|
stderr: string;
|
|
20
20
|
};
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
|
|
23
|
+
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
24
|
+
*/
|
|
25
|
+
export declare function read(named: Named, command: string, sized: boolean): PrState;
|
|
23
26
|
/** 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;
|
|
27
|
+
export declare function readAgain(pr: PrState, sized: boolean): PrState;
|
|
28
|
+
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
29
|
+
export declare function carriesBase(before: PrState, after: PrState): boolean;
|
package/dist/pr/read.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
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
|
|
2
|
+
// pull request a command names, and that pull request's state from one GraphQL request, with the two
|
|
3
|
+
// compares that tell whether its head carries an earlier Review and the one that sizes its changes.
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import {
|
|
5
|
+
import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
|
|
6
|
+
import { awaitsReview, parsePr, parseView, QUERY, ReplyError } from "./state.js";
|
|
5
7
|
/** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
|
|
6
8
|
export class CannotRun extends Error {
|
|
7
9
|
remedy;
|
|
@@ -74,22 +76,66 @@ function targetOf({ number, repo }, command) {
|
|
|
74
76
|
}
|
|
75
77
|
return parseOrCannotRun(() => parseView(view.stdout));
|
|
76
78
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
|
|
81
|
+
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
82
|
+
*/
|
|
83
|
+
export function read(named, command, sized) {
|
|
79
84
|
// The host gh api reads from: an account on any other host has no say in this call.
|
|
80
85
|
const host = process.env.GH_HOST?.trim() || "github.com";
|
|
81
86
|
const auth = gh(["auth", "status", "--active", "--hostname", host]);
|
|
82
87
|
if (auth.status !== 0) {
|
|
83
88
|
throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
|
|
84
89
|
}
|
|
85
|
-
return query(targetOf(named, command));
|
|
90
|
+
return query(targetOf(named, command), sized);
|
|
86
91
|
}
|
|
87
92
|
/** 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);
|
|
93
|
+
export function readAgain(pr, sized) {
|
|
94
|
+
return query(pr, sized);
|
|
90
95
|
}
|
|
91
|
-
/** The
|
|
92
|
-
function
|
|
96
|
+
/** The reply of the compare from `from` to `commit`; `what` names it when gh refuses it. */
|
|
97
|
+
function compared(pr, from, commit, what) {
|
|
98
|
+
const reply = gh(compareArgs(pr.repo, from, commit));
|
|
99
|
+
if (reply.status !== 0) {
|
|
100
|
+
throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
101
|
+
}
|
|
102
|
+
return reply.stdout;
|
|
103
|
+
}
|
|
104
|
+
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
105
|
+
export function carriesBase(before, after) {
|
|
106
|
+
const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
|
|
107
|
+
return parseOrCannotRun(() => behindOf(reply)) === 0;
|
|
108
|
+
}
|
|
109
|
+
/** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
|
|
110
|
+
function ownChanges(pr, commit) {
|
|
111
|
+
const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
|
|
112
|
+
return parseOrCannotRun(() => changesOf(reply));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `pr` and whether its head carries the last reviewed commit's status: only merges since it, and the
|
|
116
|
+
* same own changes at both. The compares run only when a Review awaits the head.
|
|
117
|
+
*/
|
|
118
|
+
function withCarried(pr) {
|
|
119
|
+
const last = pr.lastReviewed;
|
|
120
|
+
if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
121
|
+
return { ...pr, carried: false };
|
|
122
|
+
}
|
|
123
|
+
return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
|
|
127
|
+
* the compare from the last reviewed commit, else from the base, to the head.
|
|
128
|
+
*/
|
|
129
|
+
function withChanged(pr, sized) {
|
|
130
|
+
if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
131
|
+
return { ...pr, changed: null };
|
|
132
|
+
}
|
|
133
|
+
const from = pr.lastReviewed?.commit ?? pr.baseOid;
|
|
134
|
+
const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
|
|
135
|
+
return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
|
|
136
|
+
}
|
|
137
|
+
/** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
|
|
138
|
+
function query({ repo, number }, sized) {
|
|
93
139
|
const [owner = "", name = ""] = repo.split("/");
|
|
94
140
|
const reply = gh([
|
|
95
141
|
"api",
|
|
@@ -108,5 +154,5 @@ function query({ repo, number }) {
|
|
|
108
154
|
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
109
155
|
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
110
156
|
}
|
|
111
|
-
return parseOrCannotRun(() => parsePr(reply.stdout, repo));
|
|
157
|
+
return withChanged(withCarried(parseOrCannotRun(() => parsePr(reply.stdout, repo))), sized);
|
|
112
158
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReviewDepth } from "../agentic-config.ts";
|
|
2
|
+
import type { ChangedFile } from "./state.ts";
|
|
3
|
+
/** A routed depth, and under off why no one reads the changes, as the status the script posts says. */
|
|
4
|
+
export type Routed = {
|
|
5
|
+
depth: Exclude<ReviewDepth, "off">;
|
|
6
|
+
} | {
|
|
7
|
+
depth: "off";
|
|
8
|
+
why: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* 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.
|
|
13
|
+
*/
|
|
14
|
+
export declare function route(changed: ChangedFile[] | "unsized" | null): Routed;
|
package/dist/pr/route.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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 lockfiles, or a change
|
|
3
|
+
// the compare cannot size is full; the rest is light. No config key moves them.
|
|
4
|
+
/** The lockfiles a package manager writes, by file name in any directory. */
|
|
5
|
+
const LOCKFILES = new Set([
|
|
6
|
+
"pnpm-lock.yaml",
|
|
7
|
+
"package-lock.json",
|
|
8
|
+
"npm-shrinkwrap.json",
|
|
9
|
+
"yarn.lock",
|
|
10
|
+
"bun.lock",
|
|
11
|
+
"bun.lockb",
|
|
12
|
+
"Gemfile.lock",
|
|
13
|
+
"Cargo.lock",
|
|
14
|
+
"poetry.lock",
|
|
15
|
+
"uv.lock",
|
|
16
|
+
"Pipfile.lock",
|
|
17
|
+
"composer.lock",
|
|
18
|
+
"go.sum",
|
|
19
|
+
"Podfile.lock",
|
|
20
|
+
"pubspec.lock",
|
|
21
|
+
"mix.lock",
|
|
22
|
+
"flake.lock"
|
|
23
|
+
]);
|
|
24
|
+
/** Where GitHub Actions reads its workflows: a change there runs with the repository's secrets. */
|
|
25
|
+
const WORKFLOWS = ".github/workflows/";
|
|
26
|
+
/** The most changed lines outside lockfiles a light Review reads. */
|
|
27
|
+
const LINE_LIMIT = 100;
|
|
28
|
+
function isLockfile(path) {
|
|
29
|
+
return LOCKFILES.has(path.slice(path.lastIndexOf("/") + 1));
|
|
30
|
+
}
|
|
31
|
+
function isWorkflow({ path, previous }) {
|
|
32
|
+
return [path, previous].some((name) => name?.startsWith(WORKFLOWS) === true);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
export function route(changed) {
|
|
39
|
+
if (changed === null || changed === "unsized") {
|
|
40
|
+
return { depth: "full" };
|
|
41
|
+
}
|
|
42
|
+
const code = changed.filter((file) => !isLockfile(file.path));
|
|
43
|
+
if (changed.length > 0 && code.length === 0) {
|
|
44
|
+
return { depth: "off", why: "lockfiles only" };
|
|
45
|
+
}
|
|
46
|
+
const lines = code.map((file) => file.lines);
|
|
47
|
+
if (changed.some(isWorkflow) || lines.includes(null)) {
|
|
48
|
+
return { depth: "full" };
|
|
49
|
+
}
|
|
50
|
+
const total = lines.reduce((sum, count) => sum + (count ?? 0), 0);
|
|
51
|
+
return { depth: total > LINE_LIMIT ? "full" : "light" };
|
|
52
|
+
}
|
package/dist/pr/state.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/** The commit status a clean review of the head leaves on it. */
|
|
2
|
+
export declare const REVIEW = "dw-sdlc/review";
|
|
3
|
+
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
4
|
+
export declare const OFF_PREFIX = "off:";
|
|
5
|
+
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
2
6
|
export type Bucket = "pass" | "fail" | "pending" | "skipping";
|
|
3
7
|
export type CiCheck = {
|
|
4
8
|
name: string;
|
|
@@ -36,19 +40,61 @@ export type PrState = {
|
|
|
36
40
|
headCommittedAt: string;
|
|
37
41
|
branch: string;
|
|
38
42
|
base: string;
|
|
43
|
+
/** The commit the base branch points at. */
|
|
44
|
+
baseOid: string;
|
|
39
45
|
mergeCommit: string | null;
|
|
40
46
|
checks: CiCheck[];
|
|
41
47
|
/** The threads neither resolved nor outdated. */
|
|
42
48
|
unresolvedThreads: Thread[];
|
|
49
|
+
/** The last commit before the head with a review success not posted under off, of the last 100. */
|
|
50
|
+
lastReviewed: LastReviewed | null;
|
|
51
|
+
/** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
|
|
52
|
+
carried: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The files a Review would read, from `lastReviewed` or the base to the head, read only for `pr.review:
|
|
55
|
+
* auto` to route on: `unsized` when the compare cannot list them all, null when not read.
|
|
56
|
+
*/
|
|
57
|
+
changed: ChangedFile[] | "unsized" | null;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
|
|
61
|
+
* null when the compare shows no patch for it, as for a binary file.
|
|
62
|
+
*/
|
|
63
|
+
export type ChangedFile = {
|
|
64
|
+
path: string;
|
|
65
|
+
previous: string | null;
|
|
66
|
+
lines: number | null;
|
|
67
|
+
};
|
|
68
|
+
/** A reviewed commit, and whether every commit after it, the head included, is a merge. */
|
|
69
|
+
export type LastReviewed = {
|
|
70
|
+
commit: string;
|
|
71
|
+
mergesOnly: boolean;
|
|
43
72
|
};
|
|
44
73
|
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
45
74
|
export declare class ReplyError extends Error {
|
|
46
75
|
}
|
|
76
|
+
/** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
|
|
77
|
+
export type Node = {
|
|
78
|
+
value: Record<string, unknown>;
|
|
79
|
+
at: string;
|
|
80
|
+
};
|
|
81
|
+
export declare function shown(value: unknown): string;
|
|
82
|
+
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
83
|
+
export declare function rootOf(reply: string, what: string): Node;
|
|
84
|
+
export declare function fieldOf(node: Node, key: string): {
|
|
85
|
+
value: unknown;
|
|
86
|
+
at: string;
|
|
87
|
+
};
|
|
88
|
+
export declare function object(value: unknown, at: string): Node;
|
|
89
|
+
export declare function string(node: Node, key: string): string;
|
|
90
|
+
export declare function integer(node: Node, key: string): number;
|
|
91
|
+
/** The viewer's own pull request without a review success on its head: the one a Review reads. */
|
|
92
|
+
export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
|
|
47
93
|
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
48
94
|
export declare function parseView(reply: string): {
|
|
49
95
|
repo: string;
|
|
50
96
|
number: number;
|
|
51
97
|
};
|
|
52
98
|
/** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
|
|
53
|
-
export declare function parsePr(reply: string, repo: string): PrState
|
|
99
|
+
export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
|
|
54
100
|
export {};
|