dw-sdlc 0.9.0 → 0.11.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 +7 -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/cli.js +3 -1
- package/dist/commands/doctor.js +54 -15
- package/dist/commands/pr-reviewed.js +1 -1
- package/dist/commands/pr-ship.js +96 -46
- package/dist/commands/recap.d.ts +2 -0
- package/dist/commands/recap.js +310 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +17 -9
- package/dist/pr/decide.d.ts +20 -9
- package/dist/pr/decide.js +77 -16
- package/dist/pr/delta.d.ts +11 -0
- package/dist/pr/delta.js +60 -0
- package/dist/pr/read.d.ts +15 -4
- package/dist/pr/read.js +95 -13
- package/dist/pr/route.d.ts +14 -0
- package/dist/pr/route.js +52 -0
- package/dist/pr/state.d.ts +52 -2
- package/dist/pr/state.js +47 -9
- package/dist/setup-worker.d.ts +11 -0
- package/dist/setup-worker.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// `dw-sdlc recap`: the facts a Recap rests on and whether the session can be closed, read from git and
|
|
2
|
+
// gh. It writes nothing: the Action of the branch's pull request is the Ship script's decision, read.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
|
|
8
|
+
import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
|
|
9
|
+
import { git } from "../working-tree.js";
|
|
10
|
+
export const summary = "the facts a Recap rests on and whether the session can be closed";
|
|
11
|
+
const USAGE = `Usage: dw-sdlc recap [--json]
|
|
12
|
+
|
|
13
|
+
Reads the current branch, its uncommitted changes, the commits that reach no remote, its pull request
|
|
14
|
+
and whether the session sits in a linked worktree, and answers whether the session can be closed, with
|
|
15
|
+
every reason. It writes nothing: the pull request's Action is the one dw-sdlc pr ship would answer, read
|
|
16
|
+
and never performed.
|
|
17
|
+
|
|
18
|
+
The session cannot be closed over any of these:
|
|
19
|
+
a paused rebase, merge, cherry-pick, revert or bisect;
|
|
20
|
+
an uncommitted change;
|
|
21
|
+
a commit pushed nowhere or only part of the way;
|
|
22
|
+
an open pull request, whatever its Action;
|
|
23
|
+
commits on top of the base that no pull request holds. The default branch and a branch
|
|
24
|
+
guardrails.protectedBranches names need no pull request.
|
|
25
|
+
|
|
26
|
+
A pull request closed without merging merges nothing, and a detached HEAD names no pull request. There a
|
|
27
|
+
commit counts as pushed only on a remote branch, and the command asks the remote which branches still
|
|
28
|
+
stand. A branch force-pushed since the last fetch can still read as holding it, so fetch first to be exact.
|
|
29
|
+
|
|
30
|
+
A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
|
|
31
|
+
directory it runs in.
|
|
32
|
+
|
|
33
|
+
One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read. A
|
|
34
|
+
detached HEAD or a repository with no remote asks gh nothing, and the pull request fact says so.
|
|
35
|
+
|
|
36
|
+
Exit 0 the session can be closed, 1 it cannot, 2 when it cannot run.`;
|
|
37
|
+
function plural(howMany, word) {
|
|
38
|
+
return `${howMany} ${word}${howMany === 1 ? "" : "s"}`;
|
|
39
|
+
}
|
|
40
|
+
/** `git <args>` at `root`; what it prints, or a stop naming its problem. */
|
|
41
|
+
function must(root, args) {
|
|
42
|
+
const result = git(root, args);
|
|
43
|
+
if ("error" in result) {
|
|
44
|
+
throw new CannotRun(result.error, "check the repository with git status");
|
|
45
|
+
}
|
|
46
|
+
return result.out;
|
|
47
|
+
}
|
|
48
|
+
/** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
|
|
49
|
+
function asked(root, args) {
|
|
50
|
+
const result = git(root, args);
|
|
51
|
+
return "out" in result ? result.out : undefined;
|
|
52
|
+
}
|
|
53
|
+
function count(root, args) {
|
|
54
|
+
return Number(must(root, ["rev-list", "--count", ...args]));
|
|
55
|
+
}
|
|
56
|
+
function remotesOf(root) {
|
|
57
|
+
return must(root, ["remote"])
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter((remote) => remote !== "");
|
|
60
|
+
}
|
|
61
|
+
/** The remote-tracking upstream of `branch`, as `--remotes` names it, unless git reports its ref gone. */
|
|
62
|
+
function upstreamOf(root, branch) {
|
|
63
|
+
const prefix = "refs/remotes/";
|
|
64
|
+
const format = "%(upstream) %(upstream:track)";
|
|
65
|
+
const [ref = "", track] = must(root, ["for-each-ref", `--format=${format}`, `refs/heads/${branch}`]).split(" ");
|
|
66
|
+
return ref.startsWith(prefix) && track !== "[gone]" ? ref.slice(prefix.length) : null;
|
|
67
|
+
}
|
|
68
|
+
/** Git's own record of each paused operation, in the git directory a linked worktree keeps apart. */
|
|
69
|
+
const PAUSES = [
|
|
70
|
+
{ record: "rebase-merge", name: "rebase" },
|
|
71
|
+
{ record: "rebase-apply", name: "rebase or am" },
|
|
72
|
+
{ record: "MERGE_HEAD", name: "merge" },
|
|
73
|
+
{ record: "CHERRY_PICK_HEAD", name: "cherry-pick" },
|
|
74
|
+
{ record: "REVERT_HEAD", name: "revert" },
|
|
75
|
+
{ record: "sequencer", name: "cherry-pick or revert" },
|
|
76
|
+
{ record: "BISECT_LOG", name: "bisect" }
|
|
77
|
+
];
|
|
78
|
+
function pausedIn(root) {
|
|
79
|
+
const gitDir = must(root, ["rev-parse", "--absolute-git-dir"]);
|
|
80
|
+
return PAUSES.find(({ record }) => existsSync(join(gitDir, record)))?.name ?? null;
|
|
81
|
+
}
|
|
82
|
+
function readLocal(root) {
|
|
83
|
+
const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) ?? null;
|
|
84
|
+
const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) ?? null;
|
|
85
|
+
// Without the flag, git status refreshes the index, a write under a session working in this checkout.
|
|
86
|
+
const status = must(root, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=normal"]);
|
|
87
|
+
const upstream = branch === null ? null : upstreamOf(root, branch);
|
|
88
|
+
const pushed = upstream === null ? ["HEAD", "--not", "--remotes"] : ["@{upstream}..HEAD"];
|
|
89
|
+
return {
|
|
90
|
+
branch,
|
|
91
|
+
head,
|
|
92
|
+
uncommitted: status === "" ? 0 : status.split("\n").length,
|
|
93
|
+
upstream,
|
|
94
|
+
unpushed: head === null ? 0 : count(root, pushed),
|
|
95
|
+
paused: pausedIn(root),
|
|
96
|
+
worktree: { linked: mainCheckout(root) !== undefined, path: root }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
|
|
100
|
+
function readShipped(root) {
|
|
101
|
+
// An invalid config layer resolves to ask and full, as in the Ship script.
|
|
102
|
+
const policy = declaredPolicy(resolveConfig(root).config.pr);
|
|
103
|
+
const pr = readBranch(policy.review.depth === "auto");
|
|
104
|
+
return pr === null ? null : { pr, answer: decide(pr, now(), policy) };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The tracking refs, as `--remotes` names them, that the remote contradicts, which only a fetch would show:
|
|
108
|
+
* a branch it no longer has, its `HEAD`, and the session's own branch where it moved. Any other branch that
|
|
109
|
+
* moved, such as the base, counts as holding what it held; plugins/sdlc-sessions/README.md says why.
|
|
110
|
+
*/
|
|
111
|
+
function staleTracking(root, { branch, upstream }) {
|
|
112
|
+
return remotesOf(root).flatMap((remote) => {
|
|
113
|
+
const live = new Map(must(root, ["ls-remote", "--heads", remote])
|
|
114
|
+
.split("\n")
|
|
115
|
+
.map((line) => {
|
|
116
|
+
const [oid = "", ref = ""] = line.split("\t");
|
|
117
|
+
return [ref.replace("refs/heads/", `${remote}/`), oid];
|
|
118
|
+
}));
|
|
119
|
+
return must(root, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", `refs/remotes/${remote}`])
|
|
120
|
+
.split("\n")
|
|
121
|
+
.map((line) => line.split(" "))
|
|
122
|
+
.filter(([tracking = "", oid]) => {
|
|
123
|
+
const own = tracking === upstream || tracking === `${remote}/${branch}`;
|
|
124
|
+
return tracking !== "" && (!live.has(tracking) || (own && live.get(tracking) !== oid));
|
|
125
|
+
})
|
|
126
|
+
.map(([tracking = ""]) => tracking);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Where the head stands once the pull request is read. A commit an open or merged one holds is on GitHub,
|
|
131
|
+
* whatever became of the upstream. A closed one merges nothing and a detached HEAD names none, so there only
|
|
132
|
+
* a remote branch that still stands counts for the head. A paused operation has answered already.
|
|
133
|
+
*/
|
|
134
|
+
function standing(root, read, shipped, held) {
|
|
135
|
+
const asksRemote = read.paused === null && (read.branch === null || shipped?.pr.state === "CLOSED");
|
|
136
|
+
if (!asksRemote || read.head === null) {
|
|
137
|
+
return held ? { ...read, unpushed: 0 } : read;
|
|
138
|
+
}
|
|
139
|
+
const stale = staleTracking(root, read);
|
|
140
|
+
if (read.upstream !== null && !stale.includes(read.upstream)) {
|
|
141
|
+
return read;
|
|
142
|
+
}
|
|
143
|
+
const unpushed = count(root, ["HEAD", ...notOnRemotes(stale)]);
|
|
144
|
+
return { ...read, upstream: null, unpushed };
|
|
145
|
+
}
|
|
146
|
+
/** `rev-list` arguments that leave out what a remote branch holds, by the tracking refs still true. */
|
|
147
|
+
function notOnRemotes(stale) {
|
|
148
|
+
return ["--not", ...stale.map((tracking) => `--exclude=${tracking}`), "--remotes"];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The commits of a closed pull request that left the branch, as by a reset, and that no remote branch holds.
|
|
152
|
+
* A head this checkout never had was never this session's to lose.
|
|
153
|
+
*/
|
|
154
|
+
function orphanedOf(root, read, shipped, held) {
|
|
155
|
+
if (shipped?.pr.state !== "CLOSED" || held || read.paused !== null) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
return Number(asked(root, ["rev-list", "--count", shipped.pr.head, ...notOnRemotes(staleTracking(root, read))]) ?? 0);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The commits on top of the base that no pull request holds. A branch the config protects takes changes
|
|
162
|
+
* through pull requests rather than making one, and so does the default branch: neither needs one.
|
|
163
|
+
*/
|
|
164
|
+
function aheadOf(root, branch, shipped) {
|
|
165
|
+
if (resolveConfig(root).config.guardrails.protectedBranches.includes(branch)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const base = shipped?.pr.base ?? defaultBranch();
|
|
169
|
+
if (branch === base) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const refs = remotesOf(root)
|
|
173
|
+
.map((remote) => `refs/remotes/${remote}/${base}`)
|
|
174
|
+
.filter((ref) => asked(root, ["rev-parse", "--verify", "--quiet", ref]) !== undefined);
|
|
175
|
+
return { base, commits: count(root, ["HEAD", "--not", ...refs]) };
|
|
176
|
+
}
|
|
177
|
+
function unpushedReason({ branch, upstream, unpushed }) {
|
|
178
|
+
const commits = plural(unpushed, "commit");
|
|
179
|
+
if (upstream !== null) {
|
|
180
|
+
return { id: "unpushed", blocks: true, text: `${commits} not pushed to ${upstream}` };
|
|
181
|
+
}
|
|
182
|
+
const where = branch === null ? "on a detached HEAD, on no remote" : "on a branch with no upstream";
|
|
183
|
+
return { id: "no-upstream", blocks: true, text: `${commits} ${where}` };
|
|
184
|
+
}
|
|
185
|
+
function pullRequestReason({ pr, answer }) {
|
|
186
|
+
const name = `pull request #${pr.number}`;
|
|
187
|
+
if (pr.state === "OPEN") {
|
|
188
|
+
return { id: "open-pull-request", blocks: true, text: `${name} is open: ${labelOf(answer)}: ${answer.reason}` };
|
|
189
|
+
}
|
|
190
|
+
return { id: pr.state === "MERGED" ? "merged" : "closed", blocks: false, text: `${name} ${answer.reason}` };
|
|
191
|
+
}
|
|
192
|
+
/** The reason when none blocks; `askedGh` false says what the verdict never read, so it claims no more. */
|
|
193
|
+
function cleanReason(askedGh) {
|
|
194
|
+
const text = askedGh
|
|
195
|
+
? "nothing is uncommitted, unpushed or open"
|
|
196
|
+
: "nothing is uncommitted or unpushed; gh was not asked";
|
|
197
|
+
return { id: "clean", blocks: false, text };
|
|
198
|
+
}
|
|
199
|
+
function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
|
|
200
|
+
const all = [];
|
|
201
|
+
if (local.paused !== null) {
|
|
202
|
+
all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
|
|
203
|
+
}
|
|
204
|
+
if (local.uncommitted > 0) {
|
|
205
|
+
all.push({ id: "uncommitted", blocks: true, text: plural(local.uncommitted, "uncommitted change") });
|
|
206
|
+
}
|
|
207
|
+
if (local.unpushed > 0) {
|
|
208
|
+
all.push(unpushedReason(local));
|
|
209
|
+
}
|
|
210
|
+
if (ahead !== null && ahead.commits > 0) {
|
|
211
|
+
const text = `${plural(ahead.commits, "commit")} on top of ${ahead.base} in no pull request`;
|
|
212
|
+
all.push({ id: "no-pull-request", blocks: true, text });
|
|
213
|
+
}
|
|
214
|
+
if (shipped !== null && orphaned > 0) {
|
|
215
|
+
const text = `${plural(orphaned, "commit")} of pull request #${shipped.pr.number} on no remote branch`;
|
|
216
|
+
all.push({ id: "orphaned", blocks: true, text });
|
|
217
|
+
}
|
|
218
|
+
if (shipped !== null) {
|
|
219
|
+
all.push(pullRequestReason(shipped));
|
|
220
|
+
}
|
|
221
|
+
if (!all.some((reason) => reason.blocks)) {
|
|
222
|
+
all.unshift(cleanReason(askedGh));
|
|
223
|
+
}
|
|
224
|
+
if (local.worktree.linked) {
|
|
225
|
+
all.push({ id: "worktree", blocks: false, text: `the worktree at ${local.worktree.path} is left to remove` });
|
|
226
|
+
}
|
|
227
|
+
return all;
|
|
228
|
+
}
|
|
229
|
+
/** The verdict and the line a Recap quotes: after "no" only what blocks, after "yes" every reason. */
|
|
230
|
+
function closeOf(reasons) {
|
|
231
|
+
const blocking = reasons.filter((reason) => reason.blocks);
|
|
232
|
+
const verdict = blocking.length > 0 ? "no" : "yes";
|
|
233
|
+
const shown = blocking.length > 0 ? blocking : reasons;
|
|
234
|
+
const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
|
|
235
|
+
return { verdict, line, reasons };
|
|
236
|
+
}
|
|
237
|
+
function linesOf(local, shipped, askedGh, line) {
|
|
238
|
+
const none = askedGh ? "none" : "gh not asked";
|
|
239
|
+
return [
|
|
240
|
+
`branch: ${local.branch ?? "detached HEAD"}`,
|
|
241
|
+
`uncommitted: ${local.uncommitted}`,
|
|
242
|
+
`upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
|
|
243
|
+
`pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
|
|
244
|
+
`worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
|
|
245
|
+
line
|
|
246
|
+
].join("\n");
|
|
247
|
+
}
|
|
248
|
+
function parseCli(argv) {
|
|
249
|
+
try {
|
|
250
|
+
const { values, positionals } = parseArgs({
|
|
251
|
+
args: argv,
|
|
252
|
+
allowPositionals: true,
|
|
253
|
+
options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
|
|
254
|
+
});
|
|
255
|
+
if (positionals.length > 0 && values.help !== true) {
|
|
256
|
+
return { error: `it takes no argument, not ${JSON.stringify(positionals[0])}` };
|
|
257
|
+
}
|
|
258
|
+
return { help: values.help === true, json: values.json === true };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function report(json) {
|
|
265
|
+
const root = findRepository(process.cwd());
|
|
266
|
+
if (root === undefined) {
|
|
267
|
+
throw new CannotRun(`no git repository holds ${process.cwd()}`, "run it from a checkout");
|
|
268
|
+
}
|
|
269
|
+
const read = readLocal(root);
|
|
270
|
+
const { branch, head } = read;
|
|
271
|
+
// A detached HEAD names no branch for gh to look up, and a repository with no remote has no GitHub.
|
|
272
|
+
const asksGh = branch !== null && remotesOf(root).length > 0;
|
|
273
|
+
const shipped = asksGh ? readShipped(root) : null;
|
|
274
|
+
const held = shipped !== null && head !== null && shipped.pr.commits.includes(head);
|
|
275
|
+
const local = standing(root, read, shipped, held);
|
|
276
|
+
// An open pull request can still take the head, and a commit-less repository has nothing to merge.
|
|
277
|
+
const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
|
|
278
|
+
const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
|
|
279
|
+
const orphaned = orphanedOf(root, read, shipped, held);
|
|
280
|
+
const close = closeOf(reasonsOf(local, shipped, ahead, orphaned, asksGh));
|
|
281
|
+
const pullRequest = shipped === null
|
|
282
|
+
? null
|
|
283
|
+
: { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
|
|
284
|
+
const output = json
|
|
285
|
+
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, close }, null, 2)
|
|
286
|
+
: linesOf(local, shipped, asksGh, close.line);
|
|
287
|
+
process.stdout.write(`${output}\n`);
|
|
288
|
+
return close.verdict === "yes" ? 0 : 1;
|
|
289
|
+
}
|
|
290
|
+
export function run(argv) {
|
|
291
|
+
const cli = parseCli(argv);
|
|
292
|
+
if ("error" in cli) {
|
|
293
|
+
process.stderr.write(`dw-sdlc recap: ${cli.error}\n\n${USAGE}\n`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
if (cli.help) {
|
|
297
|
+
process.stdout.write(`${USAGE}\n`);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return report(cli.json);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error instanceof CannotRun) {
|
|
305
|
+
process.stderr.write(`dw-sdlc recap: ${error.message} — ${error.remedy}\n`);
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
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,24 +1,33 @@
|
|
|
1
|
-
import type { PrMerge, PrReview } from "../agentic-config.ts";
|
|
2
|
-
import type
|
|
1
|
+
import type { PrMerge, PrReview, ResolvedConfig, 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
|
};
|
|
29
|
+
/** The policy a repository's `pr` key declares; a flag for one call goes over it. */
|
|
30
|
+
export declare function declaredPolicy({ merge, review, reviewCommand }: ResolvedConfig["pr"]): Policy;
|
|
22
31
|
/** An Action the pull request needs beside the one its answer names, so one push can fix several. */
|
|
23
32
|
export type Blocker = {
|
|
24
33
|
action: Action;
|
|
@@ -34,8 +43,6 @@ export type Answer = Blocker & {
|
|
|
34
43
|
recheckAfter?: number;
|
|
35
44
|
blockers: Blocker[];
|
|
36
45
|
};
|
|
37
|
-
/** The commit status a clean review of the head leaves on it. */
|
|
38
|
-
export declare const REVIEW = "dw-sdlc/review";
|
|
39
46
|
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
40
47
|
export declare const REVIEW_STATE = "success";
|
|
41
48
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
@@ -55,5 +62,9 @@ export declare function hostArgs(pr: PrState, action: ScriptAction, policy: Poli
|
|
|
55
62
|
export declare function devCommand(pr: PrState, action: ScriptAction, policy: Policy): string;
|
|
56
63
|
/** The merge left to the dev, `reason` saying why it is theirs. */
|
|
57
64
|
export declare function readyToMerge(pr: PrState, policy: Policy, reason: string): Omit<Answer, "blockers">;
|
|
65
|
+
/** `<status> <action>`, the action dropped when it repeats the status. */
|
|
66
|
+
export declare function labelOf({ status, action }: Pick<Answer, "status" | "action">): string;
|
|
67
|
+
/** The label and the reason, then the recheck and the command. */
|
|
68
|
+
export declare function lineOf(answer: Answer): string;
|
|
58
69
|
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
59
70
|
export declare function decide(pr: PrState, now: number, policy: Policy): Answer;
|
package/dist/pr/decide.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
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 };
|
|
7
|
+
/** The policy a repository's `pr` key declares; a flag for one call goes over it. */
|
|
8
|
+
export function declaredPolicy({ merge, review, reviewCommand }) {
|
|
9
|
+
return { merge, review: { depth: review, source: "config" }, reviewCommand };
|
|
10
|
+
}
|
|
4
11
|
/** How long a head may carry no check before it parks as unverified, from its commit. */
|
|
5
12
|
const GRACE_SECONDS = 120;
|
|
6
13
|
/** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
|
|
7
14
|
const BUDGET_MINUTES = 40;
|
|
8
15
|
/** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
|
|
9
16
|
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
17
|
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
13
18
|
export const REVIEW_STATE = "success";
|
|
14
19
|
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
@@ -44,15 +49,49 @@ export function reviewStatusArgs(pr, description) {
|
|
|
44
49
|
const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
|
|
45
50
|
return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
|
|
46
51
|
}
|
|
47
|
-
/**
|
|
48
|
-
function
|
|
49
|
-
return
|
|
52
|
+
/** Whether the dev named a depth for a head that would carry the status: their word beats the carry. */
|
|
53
|
+
function flagOverCarry(pr, { review }) {
|
|
54
|
+
return pr.carried && review.source === "flag";
|
|
55
|
+
}
|
|
56
|
+
/** The reviewed commit whose success the head carries, if it does and the dev named no depth. */
|
|
57
|
+
function carriedFrom(pr, policy) {
|
|
58
|
+
return pr.carried && !flagOverCarry(pr, policy) ? (pr.lastReviewed?.commit ?? null) : null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The commit the Review reads from: the last reviewed one. Over a carry the dev's depth reads the whole
|
|
62
|
+
* diff, since the changes after that commit are only the base's.
|
|
63
|
+
*/
|
|
64
|
+
function sinceOf(pr, policy) {
|
|
65
|
+
return flagOverCarry(pr, policy) ? null : (pr.lastReviewed?.commit ?? null);
|
|
66
|
+
}
|
|
67
|
+
/** The depth a Review reads the head at: the dev's or the one `pr.review` sets, else the one the rules route. */
|
|
68
|
+
function depthOf(pr, { review }) {
|
|
69
|
+
const { depth, source } = review;
|
|
70
|
+
if (depth === "off") {
|
|
71
|
+
return { depth, source, why: source === "flag" ? "--review off" : "pr.review" };
|
|
72
|
+
}
|
|
73
|
+
if (depth !== "auto") {
|
|
74
|
+
return { depth, source };
|
|
75
|
+
}
|
|
76
|
+
return { ...route(pr.changed), source: "auto" };
|
|
77
|
+
}
|
|
78
|
+
/** Why the head carries the success the script posts: the Review it carries, else why its depth is off. */
|
|
79
|
+
function markedDescription(pr, policy) {
|
|
80
|
+
const carried = carriedFrom(pr, policy);
|
|
81
|
+
if (carried !== null) {
|
|
82
|
+
return `carried from ${carried.slice(0, 7)}`;
|
|
83
|
+
}
|
|
84
|
+
const depth = depthOf(pr, policy);
|
|
85
|
+
if (depth.depth !== "off") {
|
|
86
|
+
throw new Error(`the script marks ${pr.url} reviewed at ${depth.depth}, neither off nor a carry`);
|
|
87
|
+
}
|
|
88
|
+
return `${OFF_PREFIX} ${depth.why}`;
|
|
50
89
|
}
|
|
51
90
|
/** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
|
|
52
91
|
const HOST = {
|
|
53
92
|
"update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
|
|
54
93
|
"mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
|
|
55
|
-
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr,
|
|
94
|
+
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, markedDescription(pr, policy)),
|
|
56
95
|
merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
|
|
57
96
|
};
|
|
58
97
|
export function isScriptAction(action) {
|
|
@@ -141,17 +180,29 @@ const ROWS = [
|
|
|
141
180
|
answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
|
|
142
181
|
},
|
|
143
182
|
{
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
when:
|
|
147
|
-
answer: (pr, _,
|
|
148
|
-
const
|
|
183
|
+
// Another author's pull request needs no Review: the dev merges it. The script posts the success
|
|
184
|
+
// under off, and over a carry, which reads at no depth.
|
|
185
|
+
when: awaitsReview,
|
|
186
|
+
answer: (pr, _, policy) => {
|
|
187
|
+
const since = sinceOf(pr, policy);
|
|
188
|
+
const head = { status: "act", reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}` };
|
|
189
|
+
const after = since === null ? {} : { since };
|
|
190
|
+
if (carriedFrom(pr, policy) !== null) {
|
|
191
|
+
return { ...head, action: "mark-reviewed", actor: "script", details: after };
|
|
192
|
+
}
|
|
193
|
+
const { depth, source } = depthOf(pr, policy);
|
|
194
|
+
const byScript = depth === "off";
|
|
149
195
|
return {
|
|
150
|
-
|
|
151
|
-
action:
|
|
152
|
-
actor:
|
|
153
|
-
|
|
154
|
-
|
|
196
|
+
...head,
|
|
197
|
+
action: byScript ? "mark-reviewed" : "review",
|
|
198
|
+
actor: byScript ? "script" : "agent",
|
|
199
|
+
details: {
|
|
200
|
+
depth,
|
|
201
|
+
source,
|
|
202
|
+
// The agent may raise a routed depth to full, so auto names the command at any depth.
|
|
203
|
+
...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
|
|
204
|
+
...after
|
|
205
|
+
}
|
|
155
206
|
};
|
|
156
207
|
}
|
|
157
208
|
},
|
|
@@ -232,6 +283,16 @@ const ROWS = [
|
|
|
232
283
|
merging: true
|
|
233
284
|
}
|
|
234
285
|
];
|
|
286
|
+
/** `<status> <action>`, the action dropped when it repeats the status. */
|
|
287
|
+
export function labelOf({ status, action }) {
|
|
288
|
+
return status === action ? status : `${status} ${action}`;
|
|
289
|
+
}
|
|
290
|
+
/** The label and the reason, then the recheck and the command. */
|
|
291
|
+
export function lineOf(answer) {
|
|
292
|
+
const { reason, command, recheckAfter } = answer;
|
|
293
|
+
const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
|
|
294
|
+
return `${labelOf(answer)}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
|
|
295
|
+
}
|
|
235
296
|
function blockerOf({ action, actor, reason, details }) {
|
|
236
297
|
return { action, actor, reason, ...(details === undefined ? {} : { details }) };
|
|
237
298
|
}
|
|
@@ -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;
|