dw-sdlc 0.9.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 +6 -3
- package/config/agentic.config.schema.json +23 -2
- package/dist/agentic-config.d.ts +22 -4
- package/dist/agentic-config.js +15 -5
- package/dist/commands/pr-reviewed.js +1 -1
- package/dist/commands/pr-ship.js +92 -23
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +17 -9
- package/dist/pr/decide.d.ts +14 -9
- package/dist/pr/decide.js +63 -16
- 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/setup-worker.d.ts +11 -0
- package/dist/setup-worker.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,9 +56,12 @@ the delete command for the dev.
|
|
|
56
56
|
|
|
57
57
|
`pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
|
|
58
58
|
dev under `ask`, the default. `pr.review` says how closely a head no one has reviewed is read: the
|
|
59
|
-
agent reviews it under `full`,
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
agent reviews it under `full`, one `sdlc-reviewer` subagent of the plugin does under `light`, and no
|
|
60
|
+
one does under `off`. Under `auto`, the default, the script routes the depth from the changes:
|
|
61
|
+
lockfiles alone `off`; a workflow file, more than 100 changed lines outside lockfiles, or a change
|
|
62
|
+
too large to size `full`; the rest `light`. The local layer may tighten either and never loosen it;
|
|
63
|
+
`--merge` acts as `auto` and `--review full|light|off` sets the depth for one call. A pull request
|
|
64
|
+
the `gh` account did not open is always the dev's to merge.
|
|
62
65
|
|
|
63
66
|
```json
|
|
64
67
|
{ "pr": { "merge": "auto", "review": "full" } }
|
|
@@ -395,8 +395,8 @@
|
|
|
395
395
|
"enum": ["ask", "auto"]
|
|
396
396
|
},
|
|
397
397
|
"review": {
|
|
398
|
-
"description": "The Review depth of a head the Ship script finds unreviewed, which the dev's `--review` overrides for one call. `full`: the agent
|
|
399
|
-
"enum": ["full", "off"]
|
|
398
|
+
"description": "The Review depth of a head the Ship script finds unreviewed, which the dev's `--review` overrides for one call. `full`: the agent runs the command `pr.reviewCommand` names over the changes. `light`: one `sdlc-reviewer` subagent reads the diff at medium effort. `off`: the script posts the `dw-sdlc/review` success itself and no one reads them. `auto`: built-in rules route the depth from the changes the Review would read: lockfiles alone `off`; a file under `.github/workflows/`, more than 100 changed lines outside lockfiles, or a change the script cannot size, more files than GitHub's compare lists or a file outside lockfiles with no patch, `full`; the rest `light`. Default `auto`; an invalid layer runs `full`. Strictest first: full, auto, light, off.",
|
|
399
|
+
"enum": ["full", "auto", "light", "off"]
|
|
400
400
|
},
|
|
401
401
|
"reviewCommand": {
|
|
402
402
|
"description": "The slash command a `full` Review runs over the pull request's changes, as `/pr-review-toolkit:review-pr code errors`. Default `/code-review`. No strictness order: the local override sets it freely.",
|
|
@@ -406,6 +406,27 @@
|
|
|
406
406
|
},
|
|
407
407
|
"additionalProperties": false
|
|
408
408
|
},
|
|
409
|
+
"worker": {
|
|
410
|
+
"description": "How a Worker's Claude Code session starts: the command, the model and the effort. No strictness order, as with `pr.reviewCommand`: any layer sets any key freely.",
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {
|
|
413
|
+
"command": {
|
|
414
|
+
"description": "The command a Worker starts with: the words that start its Claude Code session, before the flags of its own, such as `--model` and `--effort`. Default `claude`. It belongs to one machine, so it sits in the local layer, where `dw-sdlc setup` writes `cswap run -- --dangerously-skip-permissions` when `cswap` is on PATH.",
|
|
415
|
+
"type": "string",
|
|
416
|
+
"minLength": 1
|
|
417
|
+
},
|
|
418
|
+
"model": {
|
|
419
|
+
"description": "The model a Worker runs on, as Claude Code's `--model` takes it. Default `sonnet`.",
|
|
420
|
+
"type": "string",
|
|
421
|
+
"minLength": 1
|
|
422
|
+
},
|
|
423
|
+
"effort": {
|
|
424
|
+
"description": "The effort a Worker runs at, one of Claude Code's effort levels. Default `high`.",
|
|
425
|
+
"enum": ["low", "medium", "high", "xhigh", "max"]
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
"additionalProperties": false
|
|
429
|
+
},
|
|
409
430
|
"worktree": {
|
|
410
431
|
"type": "object",
|
|
411
432
|
"properties": {
|
package/dist/agentic-config.d.ts
CHANGED
|
@@ -17,8 +17,15 @@ export type EnvFiles = {
|
|
|
17
17
|
};
|
|
18
18
|
/** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
|
|
19
19
|
export type PrMerge = "ask" | "auto";
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* How closely a Review reads an unreviewed head: the command `pr.reviewCommand` names under `full`,
|
|
22
|
+
* one `sdlc-reviewer` subagent under `light`, no one under `off`.
|
|
23
|
+
*/
|
|
24
|
+
export type ReviewDepth = "full" | "light" | "off";
|
|
25
|
+
/** The Review depths, strictest first: what the dev's `--review` names. */
|
|
26
|
+
export declare const REVIEW_DEPTHS: readonly ["full", "light", "off"];
|
|
27
|
+
/** `pr.review`: a Review depth, or `auto`, which routes one from the changes by built-in rules. */
|
|
28
|
+
export type PrReview = ReviewDepth | "auto";
|
|
22
29
|
/**
|
|
23
30
|
* How the Ship script ends a pull request: who merges it, how closely it is reviewed, and the slash
|
|
24
31
|
* command a `full` Review runs.
|
|
@@ -28,6 +35,15 @@ export type PrConfig = {
|
|
|
28
35
|
review: PrReview;
|
|
29
36
|
reviewCommand: string;
|
|
30
37
|
};
|
|
38
|
+
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
39
|
+
declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
40
|
+
export type WorkerEffort = (typeof WORKER_EFFORTS)[number];
|
|
41
|
+
/** How a Worker's Claude Code session starts: the words that start it, its model and its effort. */
|
|
42
|
+
export type WorkerConfig = {
|
|
43
|
+
command: string;
|
|
44
|
+
model: string;
|
|
45
|
+
effort: WorkerEffort;
|
|
46
|
+
};
|
|
31
47
|
export type Guardrails = {
|
|
32
48
|
forcePush: ForcePush;
|
|
33
49
|
commitOnProtected: CommitOnProtected;
|
|
@@ -84,6 +100,7 @@ export type AgenticConfig = {
|
|
|
84
100
|
};
|
|
85
101
|
gate?: GateLayer;
|
|
86
102
|
pr?: Partial<PrConfig>;
|
|
103
|
+
worker?: Partial<WorkerConfig>;
|
|
87
104
|
worktree?: {
|
|
88
105
|
setup?: string[];
|
|
89
106
|
};
|
|
@@ -104,6 +121,7 @@ export type ResolvedConfig = {
|
|
|
104
121
|
};
|
|
105
122
|
gate: Gate;
|
|
106
123
|
pr: PrConfig;
|
|
124
|
+
worker: WorkerConfig;
|
|
107
125
|
worktree: {
|
|
108
126
|
setup: string[];
|
|
109
127
|
};
|
|
@@ -141,13 +159,13 @@ export declare const STRICTNESS: {
|
|
|
141
159
|
readonly commitOnProtected: readonly ["deny", "allow"];
|
|
142
160
|
readonly envFiles: readonly ["deny", "git"];
|
|
143
161
|
readonly merge: readonly ["ask", "auto"];
|
|
144
|
-
readonly review: readonly ["full", "off"];
|
|
162
|
+
readonly review: readonly ["full", "auto", "light", "off"];
|
|
145
163
|
};
|
|
146
164
|
/** What the guards run on while any layer is invalid. */
|
|
147
165
|
export declare const STRICTEST_GUARDRAILS: Guardrails;
|
|
148
166
|
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
149
167
|
export declare const STRICTEST_PR_MERGE: PrMerge;
|
|
150
|
-
/** The Review depth while any layer is invalid
|
|
168
|
+
/** The Review depth while any layer is invalid: the agent reads every head in full. */
|
|
151
169
|
export declare const STRICTEST_PR_REVIEW: PrReview;
|
|
152
170
|
/** The command a `full` Review runs with nothing declared; it has no strictness order. */
|
|
153
171
|
export declare const DEFAULT_REVIEW_COMMAND = "/code-review";
|
package/dist/agentic-config.js
CHANGED
|
@@ -11,13 +11,17 @@ export const LOCAL_FILE = ".ai/agentic.config.local.json";
|
|
|
11
11
|
export const SCHEMA_FILE = "config/agentic.config.schema.json";
|
|
12
12
|
export const SCHEMA_PATH = join(import.meta.dirname, "..", SCHEMA_FILE);
|
|
13
13
|
export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
|
|
14
|
+
/** The Review depths, strictest first: what the dev's `--review` names. */
|
|
15
|
+
export const REVIEW_DEPTHS = ["full", "light", "off"];
|
|
16
|
+
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
17
|
+
const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
14
18
|
/** Strictest value first; the local override may only move towards the front. */
|
|
15
19
|
export const STRICTNESS = {
|
|
16
20
|
forcePush: ["deny", "ask", "lease"],
|
|
17
21
|
commitOnProtected: ["deny", "allow"],
|
|
18
22
|
envFiles: ["deny", "git"],
|
|
19
23
|
merge: ["ask", "auto"],
|
|
20
|
-
review: ["full", "off"]
|
|
24
|
+
review: ["full", "auto", "light", "off"]
|
|
21
25
|
};
|
|
22
26
|
/** What the guards run on while any layer is invalid. */
|
|
23
27
|
export const STRICTEST_GUARDRAILS = {
|
|
@@ -28,11 +32,15 @@ export const STRICTEST_GUARDRAILS = {
|
|
|
28
32
|
};
|
|
29
33
|
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
30
34
|
export const STRICTEST_PR_MERGE = "ask";
|
|
31
|
-
/** The Review depth while any layer is invalid
|
|
35
|
+
/** The Review depth while any layer is invalid: the agent reads every head in full. */
|
|
32
36
|
export const STRICTEST_PR_REVIEW = "full";
|
|
33
37
|
const STRICTEST_PR = { merge: STRICTEST_PR_MERGE, review: STRICTEST_PR_REVIEW };
|
|
34
38
|
/** The command a `full` Review runs with nothing declared; it has no strictness order. */
|
|
35
39
|
export const DEFAULT_REVIEW_COMMAND = "/code-review";
|
|
40
|
+
/** What a repository gets with nothing declared: the merge the dev's, the depth routed from the changes. */
|
|
41
|
+
const DEFAULT_PR = { merge: STRICTEST_PR_MERGE, review: "auto", reviewCommand: DEFAULT_REVIEW_COMMAND };
|
|
42
|
+
/** What a Worker starts with when no layer says otherwise; no key has a strictness order. */
|
|
43
|
+
const DEFAULT_WORKER = { command: "claude", model: "sonnet", effort: "high" };
|
|
36
44
|
/** What a repository gets with nothing declared. */
|
|
37
45
|
export const DEFAULT_GUARDRAILS = {
|
|
38
46
|
forcePush: "lease",
|
|
@@ -202,7 +210,8 @@ export function inferConfig(root) {
|
|
|
202
210
|
guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
|
|
203
211
|
commit: { convention: "conventional" },
|
|
204
212
|
gate: inferGate(root),
|
|
205
|
-
pr: { ...
|
|
213
|
+
pr: { ...DEFAULT_PR },
|
|
214
|
+
worker: { ...DEFAULT_WORKER },
|
|
206
215
|
worktree: { setup: [] }
|
|
207
216
|
};
|
|
208
217
|
}
|
|
@@ -219,6 +228,7 @@ function applyLayer(base, layer) {
|
|
|
219
228
|
commit: { ...base.commit, ...layer.commit },
|
|
220
229
|
gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
|
|
221
230
|
pr: { ...base.pr, ...layer.pr },
|
|
231
|
+
worker: { ...base.worker, ...layer.worker },
|
|
222
232
|
worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
|
|
223
233
|
};
|
|
224
234
|
}
|
|
@@ -277,7 +287,7 @@ function loosenings(base, local) {
|
|
|
277
287
|
}
|
|
278
288
|
return problems;
|
|
279
289
|
}
|
|
280
|
-
/** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over full. */
|
|
290
|
+
/** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over light over auto over full. */
|
|
281
291
|
function prLoosenings(base, local) {
|
|
282
292
|
const keys = ["merge", "review"];
|
|
283
293
|
return keys.flatMap((key) => {
|
|
@@ -323,7 +333,7 @@ export function resolveConfig(root) {
|
|
|
323
333
|
}
|
|
324
334
|
}
|
|
325
335
|
if (problems.length > 0) {
|
|
326
|
-
// `reviewCommand`
|
|
336
|
+
// `reviewCommand` and `worker` have no strictest value: they keep what the valid layers name.
|
|
327
337
|
config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { ...config.pr, ...STRICTEST_PR } };
|
|
328
338
|
}
|
|
329
339
|
return {
|
|
@@ -54,7 +54,7 @@ function parseCli(argv) {
|
|
|
54
54
|
}
|
|
55
55
|
/** Posts the review status on `head` once the read shows it is still the pull request's head. */
|
|
56
56
|
function post({ number, repo, head }) {
|
|
57
|
-
const pr = read({ number, repo }, "pr reviewed");
|
|
57
|
+
const pr = read({ number, repo }, "pr reviewed", false);
|
|
58
58
|
if (pr.head !== head) {
|
|
59
59
|
throw new CannotRun(`${pr.repo}#${number}: the head is ${pr.head}, not ${head}: a push landed since the review`, "review the new head, then call again with its sha");
|
|
60
60
|
}
|
package/dist/commands/pr-ship.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// needs next, performs it when it is the script's and reads again, then answers the first Action that
|
|
3
3
|
// is not, as a line or one JSON document, with an exit code that names whose turn it is.
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { repositoryRoot, resolveConfig,
|
|
5
|
+
import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
|
|
6
6
|
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
|
|
7
|
-
import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
7
|
+
import { CannotRun, carriesBase, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
8
8
|
export const summary = "decide the Action one pull request needs next, as a line or JSON";
|
|
9
|
-
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
|
|
9
|
+
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
|
|
10
10
|
|
|
11
11
|
Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
|
|
12
12
|
is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
|
|
@@ -14,16 +14,39 @@ The script performs its own Actions, each at most once and then reads again: it
|
|
|
14
14
|
behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
|
|
15
15
|
request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
|
|
16
16
|
under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
|
|
17
|
-
gets the Review depth --review names for this call, else pr.review: under full
|
|
18
|
-
|
|
19
|
-
script posts the success itself, its description saying
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
|
|
18
|
+
with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
|
|
19
|
+
subagent reads the diff; under off the script posts the success itself, its description saying
|
|
20
|
+
why. Under auto, the default, the script routes the depth from the changes the Review would read:
|
|
21
|
+
lockfiles alone off; a file under .github/workflows/, more than 100 changed lines outside
|
|
22
|
+
lockfiles, or a change it cannot size, more files than GitHub's compare lists or a file outside
|
|
23
|
+
lockfiles with no patch, full; the rest light. After a push it names
|
|
24
|
+
the last reviewed commit under details.since, where the Review reads from and auto routes from. It
|
|
25
|
+
carries that commit's success over a head whose only commits since are merges of the base, unless
|
|
26
|
+
--review names a depth for the call.
|
|
27
|
+
Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
|
|
28
|
+
Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
|
|
29
|
+
false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
|
|
30
|
+
the head carries the base it read. When the read after an Action fails, the call exits 2 and names
|
|
31
|
+
each Action that ran with gh's exit, under performed too with --json: it may have landed, so do not
|
|
32
|
+
run it blind again. --dry-run performs nothing: it parks at the first Action the script would
|
|
33
|
+
perform, under performed with ok null.
|
|
34
|
+
One line, or one document with --json; --verbose adds the state as read to the document under
|
|
35
|
+
facts. Needs an authenticated gh.
|
|
23
36
|
|
|
24
37
|
Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
|
|
38
|
+
/** The depths --review takes, strictest first, as a sentence lists them. */
|
|
39
|
+
const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
|
|
25
40
|
const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
|
|
26
41
|
const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
|
|
42
|
+
/** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
|
|
43
|
+
class Interrupted extends CannotRun {
|
|
44
|
+
performed;
|
|
45
|
+
constructor({ message, remedy }, performed) {
|
|
46
|
+
super(message, remedy);
|
|
47
|
+
this.performed = performed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
27
50
|
function parseCli(argv) {
|
|
28
51
|
try {
|
|
29
52
|
const { values, positionals } = parseArgs({
|
|
@@ -41,7 +64,7 @@ function parseCli(argv) {
|
|
|
41
64
|
});
|
|
42
65
|
const [number, ...more] = positionals;
|
|
43
66
|
const { repo } = values;
|
|
44
|
-
const review =
|
|
67
|
+
const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
|
|
45
68
|
if (values.help !== true) {
|
|
46
69
|
if (more.length > 0) {
|
|
47
70
|
return { error: `name one pull request, not ${positionals.length}` };
|
|
@@ -51,7 +74,7 @@ function parseCli(argv) {
|
|
|
51
74
|
return { error: problem };
|
|
52
75
|
}
|
|
53
76
|
if (values.review !== undefined && review === undefined) {
|
|
54
|
-
return { error: `--review takes ${
|
|
77
|
+
return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
|
|
55
78
|
}
|
|
56
79
|
}
|
|
57
80
|
return {
|
|
@@ -92,6 +115,33 @@ const NAMED = {
|
|
|
92
115
|
},
|
|
93
116
|
merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
|
|
94
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Whether the read after a script Action shows it took effect. gh's exit status cannot tell: a reply
|
|
120
|
+
* lost on the way back exits non-zero after GitHub applied the Action.
|
|
121
|
+
*/
|
|
122
|
+
const TOOK = {
|
|
123
|
+
// A moved head alone may be a push: the update took effect when the head carries the base it read.
|
|
124
|
+
"update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
|
|
125
|
+
"mark-ready": (_, after) => !after.draft,
|
|
126
|
+
// The post names the head it read: a head moved since carries none of it.
|
|
127
|
+
"mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
|
|
128
|
+
merge: (_, after) => after.state === "MERGED"
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Why an Action that ran comes round again. A head that moved after a mark-reviewed names both heads;
|
|
132
|
+
* otherwise gh refused it, or accepted it and the read shows nothing yet, or the effect is gone again.
|
|
133
|
+
*/
|
|
134
|
+
function reasonOf({ action, ok, exit, stderr = "" }, pr, ranOn, { act, still }) {
|
|
135
|
+
if (action === "mark-reviewed" && exit === 0 && ranOn !== undefined && ranOn !== pr.head) {
|
|
136
|
+
return `${act} posted on ${ranOn.slice(0, 7)}, and the head moved to ${pr.head.slice(0, 7)}`;
|
|
137
|
+
}
|
|
138
|
+
if (ok) {
|
|
139
|
+
return `${act} ran, and ${still}`;
|
|
140
|
+
}
|
|
141
|
+
return exit === 0
|
|
142
|
+
? `gh accepted ${act}; the read does not show it yet`
|
|
143
|
+
: `gh refused ${act}: ${said(stderr) || `gh exited ${exit}`}`;
|
|
144
|
+
}
|
|
95
145
|
/** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
|
|
96
146
|
function handedOver(pr, policy, answer, action, reason) {
|
|
97
147
|
if (action === "merge") {
|
|
@@ -104,9 +154,12 @@ function handedOver(pr, policy, answer, action, reason) {
|
|
|
104
154
|
* pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
|
|
105
155
|
*/
|
|
106
156
|
function ship(cli, policy) {
|
|
107
|
-
|
|
157
|
+
// Only pr.review auto routes the depth from the changes, so only it reads them.
|
|
158
|
+
const sized = policy.review.depth === "auto";
|
|
159
|
+
let pr = read(cli, "pr ship", sized);
|
|
108
160
|
const performed = [];
|
|
109
|
-
|
|
161
|
+
// The head each Action ran on.
|
|
162
|
+
const postedOn = new Map();
|
|
110
163
|
for (;;) {
|
|
111
164
|
const answer = decide(pr, now(), policy);
|
|
112
165
|
const { action } = answer;
|
|
@@ -116,7 +169,7 @@ function ship(cli, policy) {
|
|
|
116
169
|
// -R names the repository, so the checkout the script runs in plays no part.
|
|
117
170
|
const args = hostArgs(pr, action, policy, true);
|
|
118
171
|
const command = shellLine(["gh", ...args]);
|
|
119
|
-
const { would
|
|
172
|
+
const { would } = NAMED[action];
|
|
120
173
|
if (cli.dryRun) {
|
|
121
174
|
performed.push({ action, command, ok: null });
|
|
122
175
|
return {
|
|
@@ -125,17 +178,28 @@ function ship(cli, policy) {
|
|
|
125
178
|
performed
|
|
126
179
|
};
|
|
127
180
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
const done = performed.find((earlier) => earlier.action === action);
|
|
182
|
+
// --dry-run returns before any Action runs, so an earlier one always carries gh's exit.
|
|
183
|
+
if (done !== undefined && done.ok !== null) {
|
|
184
|
+
const reason = reasonOf(done, pr, postedOn.get(action), NAMED[action]);
|
|
131
185
|
return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
|
|
132
186
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
187
|
+
const { status, stderr } = gh(args);
|
|
188
|
+
const before = pr;
|
|
189
|
+
const message = stderr.trim();
|
|
190
|
+
const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
|
|
191
|
+
postedOn.set(action, before.head);
|
|
192
|
+
try {
|
|
193
|
+
pr = readAgain(pr, sized);
|
|
194
|
+
performed.push({ ...record, ok: TOOK[action](before, pr) });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof CannotRun) {
|
|
198
|
+
// The Action may have landed: the caller must not run it blind again.
|
|
199
|
+
throw new Interrupted(error, [...performed, { ...record, ok: null }]);
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
137
202
|
}
|
|
138
|
-
pr = readAgain(pr);
|
|
139
203
|
}
|
|
140
204
|
}
|
|
141
205
|
/** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
|
|
@@ -171,7 +235,12 @@ export function run(argv) {
|
|
|
171
235
|
}
|
|
172
236
|
catch (error) {
|
|
173
237
|
if (error instanceof CannotRun) {
|
|
174
|
-
|
|
238
|
+
const ran = error instanceof Interrupted ? error.performed : [];
|
|
239
|
+
const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
|
|
240
|
+
process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
|
|
241
|
+
if (cli.json && ran.length > 0) {
|
|
242
|
+
process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
|
|
243
|
+
}
|
|
175
244
|
return 2;
|
|
176
245
|
}
|
|
177
246
|
throw error;
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type SetupOptions = {
|
|
|
21
21
|
commitOnProtected?: CommitOnProtected | undefined;
|
|
22
22
|
/** The owner's yes to pinning dw-sdlc as a devDependency where package.json lacks it. */
|
|
23
23
|
addPackage?: boolean;
|
|
24
|
+
/** The PATH the setup looks for `cswap` on; `$PATH` when absent. */
|
|
25
|
+
path?: string | undefined;
|
|
24
26
|
};
|
|
25
27
|
export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
26
28
|
/**
|
package/dist/commands/setup.js
CHANGED
|
@@ -11,6 +11,7 @@ import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCH
|
|
|
11
11
|
import { isObject, parseObject, render } from "../json-text.js";
|
|
12
12
|
import { withGate, withPackage } from "../setup-gate.js";
|
|
13
13
|
import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
|
|
14
|
+
import { keptWorker, pinnedWorker } from "../setup-worker.js";
|
|
14
15
|
export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
15
16
|
const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
|
|
16
17
|
[--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
|
|
@@ -25,7 +26,9 @@ written only when it would change; --convention ticket-key | conventional pins c
|
|
|
25
26
|
the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
|
|
26
27
|
is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
|
|
27
28
|
loosen what the committed config sets; --force overwrites a local config that differs in anything
|
|
28
|
-
else; --dry-run reports without writing.
|
|
29
|
+
else; --dry-run reports without writing. Where cswap is on PATH, the local config also gets
|
|
30
|
+
worker.command, the command a Worker starts with: cswap run -- --dangerously-skip-permissions. A
|
|
31
|
+
worker block the file holds is kept as it is, and without cswap nothing is written.
|
|
29
32
|
|
|
30
33
|
Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
|
|
31
34
|
there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
|
|
@@ -99,12 +102,13 @@ function keptGuardrails(existing) {
|
|
|
99
102
|
}
|
|
100
103
|
/**
|
|
101
104
|
* A layer without the keys the setup owns: `$schema`, which moves with the clone, a pinned
|
|
102
|
-
* convention, which `--convention` may replace, and a `guardrails` object, which the
|
|
103
|
-
*
|
|
105
|
+
* convention, which `--convention` may replace, and a `guardrails` and a `worker` object, which the
|
|
106
|
+
* setup carries and adds keys to. Everything left is someone's content.
|
|
104
107
|
*/
|
|
105
108
|
function unowned(layer) {
|
|
106
|
-
const { $schema: _schema, commit,
|
|
107
|
-
const
|
|
109
|
+
const { $schema: _schema, commit, ...other } = layer;
|
|
110
|
+
const blocks = ["guardrails", "worker"];
|
|
111
|
+
const rest = Object.fromEntries(Object.entries(other).filter(([key, value]) => !blocks.includes(key) || !isObject(value)));
|
|
108
112
|
if (!isObject(commit) || pinnedConvention(layer) === undefined) {
|
|
109
113
|
return commit === undefined ? rest : { ...rest, commit };
|
|
110
114
|
}
|
|
@@ -144,9 +148,9 @@ function sameContent(existing, config) {
|
|
|
144
148
|
}
|
|
145
149
|
/**
|
|
146
150
|
* The machine's layer: what inference finds, minus every key the committed config already sets, which an
|
|
147
|
-
* inferred key here would override, plus the `guardrails`
|
|
151
|
+
* inferred key here would override, plus the `guardrails` and `worker` blocks and the convention asked for.
|
|
148
152
|
*/
|
|
149
|
-
function localConfig(root, claudeDir, convention, guardrails) {
|
|
153
|
+
function localConfig(root, claudeDir, convention, guardrails, worker) {
|
|
150
154
|
const inferred = inferConfig(root);
|
|
151
155
|
const committed = committedConfig(root);
|
|
152
156
|
const config = { $schema: schemaReference(claudeDir) };
|
|
@@ -160,6 +164,9 @@ function localConfig(root, claudeDir, convention, guardrails) {
|
|
|
160
164
|
if (guardrails !== undefined) {
|
|
161
165
|
config.guardrails = guardrails;
|
|
162
166
|
}
|
|
167
|
+
if (worker !== undefined) {
|
|
168
|
+
config.worker = worker;
|
|
169
|
+
}
|
|
163
170
|
// Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
|
|
164
171
|
// pinning it here would override a convention the repository commits later.
|
|
165
172
|
if (convention !== undefined) {
|
|
@@ -255,12 +262,13 @@ export function setup(options) {
|
|
|
255
262
|
writer.refuse(LOCAL_FILE, pinned.refusal);
|
|
256
263
|
}
|
|
257
264
|
else {
|
|
258
|
-
const
|
|
265
|
+
const { worker, detail } = pinnedWorker(keptWorker(existing), options.path ?? process.env.PATH);
|
|
266
|
+
const local = localConfig(checkout, claudeDir, convention, pinned.guardrails, worker);
|
|
259
267
|
if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
|
|
260
268
|
writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
|
|
261
269
|
}
|
|
262
270
|
else {
|
|
263
|
-
writer.put(LOCAL_FILE, localPath, render(local));
|
|
271
|
+
writer.put(LOCAL_FILE, localPath, render(local), detail);
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
// 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
|
package/dist/pr/decide.d.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
import type { PrMerge, PrReview } 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
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
8
|
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
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
17
|
*/
|
|
14
18
|
export type Policy = {
|
|
15
19
|
merge: PrMerge;
|
|
16
20
|
review: {
|
|
21
|
+
depth: ReviewDepth;
|
|
22
|
+
source: "flag";
|
|
23
|
+
} | {
|
|
17
24
|
depth: PrReview;
|
|
18
|
-
source:
|
|
25
|
+
source: "config";
|
|
19
26
|
};
|
|
20
27
|
reviewCommand: string;
|
|
21
28
|
};
|
|
@@ -34,8 +41,6 @@ export type Answer = Blocker & {
|
|
|
34
41
|
recheckAfter?: number;
|
|
35
42
|
blockers: Blocker[];
|
|
36
43
|
};
|
|
37
|
-
/** The commit status a clean review of the head leaves on it. */
|
|
38
|
-
export declare const REVIEW = "dw-sdlc/review";
|
|
39
44
|
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
40
45
|
export declare const REVIEW_STATE = "success";
|
|
41
46
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
package/dist/pr/decide.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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
|
-
/** The commit status a clean review of the head leaves on it. */
|
|
11
|
-
export const REVIEW = "dw-sdlc/review";
|
|
12
13
|
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
13
14
|
export const REVIEW_STATE = "success";
|
|
14
15
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
@@ -44,15 +45,49 @@ export function reviewStatusArgs(pr, description) {
|
|
|
44
45
|
const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
|
|
45
46
|
return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
|
|
46
47
|
}
|
|
47
|
-
/**
|
|
48
|
-
function
|
|
49
|
-
return
|
|
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}`;
|
|
50
85
|
}
|
|
51
86
|
/** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
|
|
52
87
|
const HOST = {
|
|
53
88
|
"update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
|
|
54
89
|
"mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
|
|
55
|
-
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr,
|
|
90
|
+
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, markedDescription(pr, policy)),
|
|
56
91
|
merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
|
|
57
92
|
};
|
|
58
93
|
export function isScriptAction(action) {
|
|
@@ -141,17 +176,29 @@ const ROWS = [
|
|
|
141
176
|
answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
|
|
142
177
|
},
|
|
143
178
|
{
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
when:
|
|
147
|
-
answer: (pr, _,
|
|
148
|
-
const
|
|
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";
|
|
149
191
|
return {
|
|
150
|
-
|
|
151
|
-
action:
|
|
152
|
-
actor:
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
202
|
};
|
|
156
203
|
}
|
|
157
204
|
},
|
|
@@ -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 {};
|
package/dist/pr/state.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
|
|
2
2
|
// state the decision reads. Every field the state needs is checked; a reply that fails names the field.
|
|
3
|
+
/** The commit status a clean review of the head leaves on it. */
|
|
4
|
+
export const REVIEW = "dw-sdlc/review";
|
|
5
|
+
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
6
|
+
export const OFF_PREFIX = "off:";
|
|
3
7
|
export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
4
8
|
repository(owner: $owner, name: $name) {
|
|
5
9
|
pullRequest(number: $number) {
|
|
6
|
-
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
|
|
10
|
+
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
|
|
7
11
|
viewerDidAuthor mergeCommit { oid }
|
|
8
12
|
reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
|
|
9
13
|
comments(first: 1) { nodes { author { login } body } }
|
|
@@ -12,6 +16,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
|
12
16
|
contexts(first: 50) { nodes {
|
|
13
17
|
... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
|
|
14
18
|
... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
|
|
19
|
+
history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
}`;
|
|
@@ -24,11 +29,11 @@ export class ReplyError extends Error {
|
|
|
24
29
|
function isRecord(value) {
|
|
25
30
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
31
|
}
|
|
27
|
-
function shown(value) {
|
|
32
|
+
export function shown(value) {
|
|
28
33
|
return value === undefined ? "missing" : JSON.stringify(value);
|
|
29
34
|
}
|
|
30
35
|
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
31
|
-
function rootOf(reply, what) {
|
|
36
|
+
export function rootOf(reply, what) {
|
|
32
37
|
let json;
|
|
33
38
|
try {
|
|
34
39
|
json = JSON.parse(reply);
|
|
@@ -41,10 +46,10 @@ function rootOf(reply, what) {
|
|
|
41
46
|
}
|
|
42
47
|
return { value: json, at: "" };
|
|
43
48
|
}
|
|
44
|
-
function fieldOf(node, key) {
|
|
49
|
+
export function fieldOf(node, key) {
|
|
45
50
|
return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
|
|
46
51
|
}
|
|
47
|
-
function object(value, at) {
|
|
52
|
+
export function object(value, at) {
|
|
48
53
|
if (!isRecord(value)) {
|
|
49
54
|
throw new ReplyError(`${at} is ${shown(value)}, not an object`);
|
|
50
55
|
}
|
|
@@ -58,7 +63,7 @@ function childOrNull(node, key) {
|
|
|
58
63
|
const { value, at } = fieldOf(node, key);
|
|
59
64
|
return value === null ? null : object(value, at);
|
|
60
65
|
}
|
|
61
|
-
function string(node, key) {
|
|
66
|
+
export function string(node, key) {
|
|
62
67
|
const { value, at } = fieldOf(node, key);
|
|
63
68
|
if (typeof value !== "string") {
|
|
64
69
|
throw new ReplyError(`${at} is ${shown(value)}, not a string`);
|
|
@@ -75,7 +80,7 @@ function boolean(node, key) {
|
|
|
75
80
|
}
|
|
76
81
|
return value;
|
|
77
82
|
}
|
|
78
|
-
function integer(node, key) {
|
|
83
|
+
export function integer(node, key) {
|
|
79
84
|
const { value, at } = fieldOf(node, key);
|
|
80
85
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
81
86
|
throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
|
|
@@ -182,6 +187,31 @@ function threadOf(thread) {
|
|
|
182
187
|
lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
|
|
183
188
|
};
|
|
184
189
|
}
|
|
190
|
+
/** The viewer's own pull request without a review success on its head: the one a Review reads. */
|
|
191
|
+
export function awaitsReview(pr) {
|
|
192
|
+
return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
|
|
193
|
+
}
|
|
194
|
+
/** A commit whose review success says someone read it: a status posted under off never does. */
|
|
195
|
+
function isReviewed(commit) {
|
|
196
|
+
const status = childOrNull(commit, "status");
|
|
197
|
+
const context = status === null ? null : childOrNull(status, "context");
|
|
198
|
+
if (context === null || string(context, "state") !== "SUCCESS") {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
|
|
202
|
+
}
|
|
203
|
+
/** The last commit before `head` that `isReviewed` takes, the history oldest first. */
|
|
204
|
+
function lastReviewedOf(pr, head) {
|
|
205
|
+
const commits = nodes(pr, "history").map((node) => child(node, "commit"));
|
|
206
|
+
const index = commits.findLastIndex((commit) => string(commit, "oid") !== head && isReviewed(commit));
|
|
207
|
+
const reviewed = commits[index];
|
|
208
|
+
if (reviewed === undefined) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const after = commits.slice(index + 1);
|
|
212
|
+
const mergesOnly = after.every((commit) => integer(child(commit, "parents"), "totalCount") > 1);
|
|
213
|
+
return { commit: string(reviewed, "oid"), mergesOnly };
|
|
214
|
+
}
|
|
185
215
|
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
186
216
|
export function parseView(reply) {
|
|
187
217
|
const url = string(rootOf(reply, "the reply of gh pr view"), "url");
|
|
@@ -197,6 +227,7 @@ export function parsePr(reply, repo) {
|
|
|
197
227
|
const commit = child(firstNode(pr, "commits"), "commit");
|
|
198
228
|
const rollup = childOrNull(commit, "statusCheckRollup");
|
|
199
229
|
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
230
|
+
const head = string(pr, "headRefOid");
|
|
200
231
|
return {
|
|
201
232
|
url: string(pr, "url"),
|
|
202
233
|
repo,
|
|
@@ -207,14 +238,16 @@ export function parsePr(reply, repo) {
|
|
|
207
238
|
mergeStateStatus: string(pr, "mergeStateStatus"),
|
|
208
239
|
reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
|
|
209
240
|
viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
|
|
210
|
-
head
|
|
241
|
+
head,
|
|
211
242
|
headCommittedAt: time(commit, "committedDate"),
|
|
212
243
|
branch: string(pr, "headRefName"),
|
|
213
244
|
base: string(pr, "baseRefName"),
|
|
245
|
+
baseOid: string(pr, "baseRefOid"),
|
|
214
246
|
mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
|
|
215
247
|
checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
|
|
216
248
|
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
217
249
|
.filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
|
|
218
|
-
.map(threadOf)
|
|
250
|
+
.map(threadOf),
|
|
251
|
+
lastReviewed: lastReviewedOf(pr, head)
|
|
219
252
|
};
|
|
220
253
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type JsonObject } from "./json-text.ts";
|
|
2
|
+
/** The `worker` block a local file holds, carried through on every run, `--force` included. */
|
|
3
|
+
export declare function keptWorker(existing: unknown): JsonObject | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
|
|
6
|
+
* is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pinnedWorker(kept: JsonObject | undefined, pathVariable: string | undefined): {
|
|
9
|
+
worker: JsonObject | undefined;
|
|
10
|
+
detail?: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The `worker` block of the machine's local layer: the command a Worker starts with, which the setup
|
|
2
|
+
// writes once when `cswap` is on PATH so no prompt ever carries it.
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { isObject } from "./json-text.js";
|
|
6
|
+
/** The command that starts a Worker on the account `cswap` maps to the repository, prompts bypassed. */
|
|
7
|
+
const CSWAP_WORKER_COMMAND = "cswap run -- --dangerously-skip-permissions";
|
|
8
|
+
/** Whether an executable file `name` sits in a directory of `pathVariable`, a PATH as the shell reads it. */
|
|
9
|
+
function onPath(name, pathVariable) {
|
|
10
|
+
return (pathVariable ?? "").split(delimiter).some((dir) => {
|
|
11
|
+
if (dir === "") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const file = join(dir, name);
|
|
16
|
+
accessSync(file, constants.X_OK);
|
|
17
|
+
return statSync(file).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** The `worker` block a local file holds, carried through on every run, `--force` included. */
|
|
25
|
+
export function keptWorker(existing) {
|
|
26
|
+
return isObject(existing) && isObject(existing.worker) ? existing.worker : undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
|
|
30
|
+
* is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
|
|
31
|
+
*/
|
|
32
|
+
export function pinnedWorker(kept, pathVariable) {
|
|
33
|
+
if ((kept !== undefined && "command" in kept) || !onPath("cswap", pathVariable)) {
|
|
34
|
+
return { worker: kept };
|
|
35
|
+
}
|
|
36
|
+
return { worker: { ...kept, command: CSWAP_WORKER_COMMAND }, detail: `worker.command: ${CSWAP_WORKER_COMMAND}` };
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|