dw-sdlc 0.5.1 → 0.7.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 +27 -2
- package/config/agentic.config.schema.json +12 -1
- package/config/references.schema.json +65 -51
- package/dist/agentic-config.d.ts +11 -0
- package/dist/agentic-config.js +19 -3
- package/dist/cli.js +5 -1
- package/dist/commands/pr-reviewed.d.ts +2 -0
- package/dist/commands/pr-reviewed.js +108 -0
- package/dist/commands/pr-ship.d.ts +2 -0
- package/dist/commands/pr-ship.js +163 -0
- package/dist/commands/references-setup.js +57 -24
- package/dist/commands/references-sync.js +5 -5
- package/dist/json-text.d.ts +5 -0
- package/dist/json-text.js +74 -1
- package/dist/pr/decide.d.ts +36 -0
- package/dist/pr/decide.js +216 -0
- package/dist/pr/read.d.ts +24 -0
- package/dist/pr/read.js +112 -0
- package/dist/pr/state.d.ts +54 -0
- package/dist/pr/state.js +220 -0
- package/dist/references-key.d.ts +22 -3
- package/dist/references-key.js +69 -19
- package/dist/router-block.d.ts +1 -1
- package/dist/router-block.js +4 -4
- package/package.json +2 -2
- package/templates/router.md +4 -4
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# dw-sdlc
|
|
2
2
|
|
|
3
3
|
The Gate and the agentic config of a repository. `.ai/agentic.config.json` declares how a repository
|
|
4
|
-
works for an agent: its package manager, protected branches, env-file policy, commit convention
|
|
5
|
-
|
|
4
|
+
works for an agent: its package manager, protected branches, env-file policy, commit convention, the
|
|
5
|
+
Gate and who merges a pull request. The package ships the `dw-sdlc` bin that runs on that file, its JSON Schema, and the
|
|
6
6
|
resolver of its layers.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
@@ -38,6 +38,29 @@ Under `gate.checks`, a Check the package ships is `true` for its defaults or an
|
|
|
38
38
|
`comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
|
|
39
39
|
its `roots` to `maxProseLines` lines of prose, 3 by default.
|
|
40
40
|
|
|
41
|
+
## The Ship script
|
|
42
|
+
|
|
43
|
+
`dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
|
|
44
|
+
answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
|
|
45
|
+
whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
|
|
46
|
+
performs its own Actions: it updates a branch behind its base, marks a draft ready once nothing else
|
|
47
|
+
holds it back, and squash merges a pull request that is green, reviewed and opened by the `gh`
|
|
48
|
+
account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
|
|
49
|
+
script would perform as the dev's. It never deletes a branch; a merged pull request answers with
|
|
50
|
+
the delete command for the dev.
|
|
51
|
+
|
|
52
|
+
`pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
|
|
53
|
+
dev under `ask`, the default. The local layer may tighten it and never loosen it, and `--merge` acts
|
|
54
|
+
as `auto` for one call. A pull request the `gh` account did not open is always the dev's to merge.
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "pr": { "merge": "auto" } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`pr ship` answers `review` until the head carries the commit status `dw-sdlc/review`, which
|
|
61
|
+
`dw-sdlc pr reviewed <n> --head <sha>` posts after a clean review. It posts nothing and exits 2 once
|
|
62
|
+
a push moved the head, so a new head needs a fresh review.
|
|
63
|
+
|
|
41
64
|
## Commands
|
|
42
65
|
|
|
43
66
|
| command | what it does |
|
|
@@ -50,6 +73,8 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
|
|
|
50
73
|
| `config` | the resolved agentic config, or one file validated against the schema |
|
|
51
74
|
| `references sync` | every declared Reference brought to its ref under `references/` |
|
|
52
75
|
| `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
|
|
76
|
+
| `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
|
|
77
|
+
| `pr reviewed <n>` | a clean review pinned to the head it read, as a commit status |
|
|
53
78
|
|
|
54
79
|
`dw-sdlc <command> --help` gives each one's options.
|
|
55
80
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/plugins/guardrails/config/agentic.config.schema.json",
|
|
4
4
|
"title": "agentic.config",
|
|
5
|
-
"description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` but never loosen
|
|
5
|
+
"description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` and `pr.merge` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
8
8
|
"$schema": {
|
|
@@ -357,6 +357,17 @@
|
|
|
357
357
|
},
|
|
358
358
|
"additionalProperties": false
|
|
359
359
|
},
|
|
360
|
+
"pr": {
|
|
361
|
+
"description": "How the Ship script, `dw-sdlc pr ship`, ends a pull request. The local override may tighten it and never loosen it.",
|
|
362
|
+
"type": "object",
|
|
363
|
+
"properties": {
|
|
364
|
+
"merge": {
|
|
365
|
+
"description": "Who merges a pull request that is green, reviewed and authored by the account `gh` runs as. `auto`: the script squash merges it, naming the head it read. `ask`: it parks with the merge command for the dev. Default `ask`, which an invalid layer runs too. Strictest first: ask, auto.",
|
|
366
|
+
"enum": ["ask", "auto"]
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
"additionalProperties": false
|
|
370
|
+
},
|
|
360
371
|
"worktree": {
|
|
361
372
|
"type": "object",
|
|
362
373
|
"properties": {
|
|
@@ -1,56 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/packages/dw-sdlc/config/references.schema.json",
|
|
4
4
|
"title": "references",
|
|
5
|
-
"description": "
|
|
6
|
-
"type": "
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
|
|
14
|
-
},
|
|
15
|
-
"repository": {
|
|
16
|
-
"description": "A URL `git` can fetch. A `https://github.com/` one fetches with the GitHub CLI's credentials. No whitespace, and no leading `-`, which `git` would read as an option.",
|
|
17
|
-
"type": "string",
|
|
18
|
-
"pattern": "^[^\\s-]\\S*$"
|
|
19
|
-
},
|
|
20
|
-
"ref": {
|
|
21
|
-
"description": "A branch, whose head the clone moves to, or a tag. `{name}` in it stands for the exact version the root `package.json` pins for package `name`, read through the default catalog of `pnpm-workspace.yaml` when the pin says `catalog:` or `catalog:default`. No whitespace, and no leading `-`.",
|
|
22
|
-
"type": "string",
|
|
23
|
-
"pattern": "^[^\\s-]\\S*$"
|
|
24
|
-
},
|
|
25
|
-
"only": {
|
|
26
|
-
"description": "Paths the sparse checkout holds beside the repository's top-level files, relative to the clone and inside it; a directory ends in `/`. Absent, the whole tree is checked out. Present, it names at least one path, and a path the ref's tree lacks fails the Reference.",
|
|
27
|
-
"type": "array",
|
|
28
|
-
"minItems": 1,
|
|
29
|
-
"items": {
|
|
30
|
-
"type": "string",
|
|
31
|
-
"pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
"skip": {
|
|
35
|
-
"description": "Paths the sparse checkout leaves out, relative to the clone and inside it. Empty or absent, nothing is left out. With `only`, each lies inside one of its paths, which this schema cannot say and the Sync refuses.",
|
|
36
|
-
"type": "array",
|
|
37
|
-
"items": {
|
|
38
|
-
"type": "string",
|
|
39
|
-
"pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
"what": {
|
|
43
|
-
"description": "What the Reference is.",
|
|
44
|
-
"type": "string",
|
|
45
|
-
"minLength": 1
|
|
46
|
-
},
|
|
47
|
-
"when": {
|
|
48
|
-
"description": "When an agent reaches for it.",
|
|
49
|
-
"type": "string",
|
|
50
|
-
"minLength": 1
|
|
51
|
-
}
|
|
5
|
+
"description": "`.ai/references.json`: the repositories an agent reads to see how a problem was solved before, each brought to its ref under `references/<dir>` by the Sync. Only the committed file is read. A `references` key in `.ai/agentic.config.json` is refused: `dw-sdlc references setup` moves it here. A file the Sync refuses syncs nothing.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"version": {
|
|
12
|
+
"const": 1
|
|
52
13
|
},
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
14
|
+
"references": {
|
|
15
|
+
"description": "The References, in the order the Router lists them. Every `dir` is unique in the list, which this schema cannot say and the Sync refuses.",
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"dir": {
|
|
21
|
+
"description": "The one directory under `references/` the clone lives in: letters, digits, `_`, `.` and `-`, not starting with `.` or `-`.",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"description": "A URL `git` can fetch. A `https://github.com/` one fetches with the GitHub CLI's credentials. No whitespace, and no leading `-`, which `git` would read as an option.",
|
|
27
|
+
"type": "string",
|
|
28
|
+
"pattern": "^[^\\s-]\\S*$"
|
|
29
|
+
},
|
|
30
|
+
"ref": {
|
|
31
|
+
"description": "A branch, whose head the clone moves to, or a tag. `{name}` in it stands for the exact version the root `package.json` pins for package `name`, read through the default catalog of `pnpm-workspace.yaml` when the pin says `catalog:` or `catalog:default`. No whitespace, and no leading `-`.",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"pattern": "^[^\\s-]\\S*$"
|
|
34
|
+
},
|
|
35
|
+
"only": {
|
|
36
|
+
"description": "Paths the sparse checkout holds beside the repository's top-level files, relative to the clone and inside it; a directory ends in `/`. Absent, the whole tree is checked out. Present, it names at least one path, and a path the ref's tree lacks fails the Reference.",
|
|
37
|
+
"type": "array",
|
|
38
|
+
"minItems": 1,
|
|
39
|
+
"items": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"skip": {
|
|
45
|
+
"description": "Paths the sparse checkout leaves out, relative to the clone and inside it. Empty or absent, nothing is left out. With `only`, each lies inside one of its paths, which this schema cannot say and the Sync refuses.",
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"what": {
|
|
53
|
+
"description": "What the Reference is.",
|
|
54
|
+
"type": "string",
|
|
55
|
+
"minLength": 1
|
|
56
|
+
},
|
|
57
|
+
"when": {
|
|
58
|
+
"description": "When an agent reaches for it.",
|
|
59
|
+
"type": "string",
|
|
60
|
+
"minLength": 1
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": ["dir", "repository", "ref", "what", "when"],
|
|
64
|
+
"additionalProperties": false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"required": ["version", "references"],
|
|
69
|
+
"additionalProperties": false
|
|
56
70
|
}
|
package/dist/agentic-config.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type EnvFiles = {
|
|
|
15
15
|
allow: string[];
|
|
16
16
|
deny: string[];
|
|
17
17
|
};
|
|
18
|
+
/** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
|
|
19
|
+
export type PrMerge = "ask" | "auto";
|
|
18
20
|
export type Guardrails = {
|
|
19
21
|
forcePush: ForcePush;
|
|
20
22
|
commitOnProtected: CommitOnProtected;
|
|
@@ -66,6 +68,9 @@ export type AgenticConfig = {
|
|
|
66
68
|
trailer?: string;
|
|
67
69
|
};
|
|
68
70
|
gate?: GateLayer;
|
|
71
|
+
pr?: {
|
|
72
|
+
merge?: PrMerge;
|
|
73
|
+
};
|
|
69
74
|
worktree?: {
|
|
70
75
|
setup?: string[];
|
|
71
76
|
};
|
|
@@ -85,6 +90,9 @@ export type ResolvedConfig = {
|
|
|
85
90
|
trailer?: string;
|
|
86
91
|
};
|
|
87
92
|
gate: Gate;
|
|
93
|
+
pr: {
|
|
94
|
+
merge: PrMerge;
|
|
95
|
+
};
|
|
88
96
|
worktree: {
|
|
89
97
|
setup: string[];
|
|
90
98
|
};
|
|
@@ -121,9 +129,12 @@ export declare const STRICTNESS: {
|
|
|
121
129
|
readonly forcePush: readonly ["deny", "ask", "lease"];
|
|
122
130
|
readonly commitOnProtected: readonly ["deny", "allow"];
|
|
123
131
|
readonly envFiles: readonly ["deny", "git"];
|
|
132
|
+
readonly merge: readonly ["ask", "auto"];
|
|
124
133
|
};
|
|
125
134
|
/** What the guards run on while any layer is invalid. */
|
|
126
135
|
export declare const STRICTEST_GUARDRAILS: Guardrails;
|
|
136
|
+
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
137
|
+
export declare const STRICTEST_PR_MERGE: PrMerge;
|
|
127
138
|
/** What a repository gets with nothing declared. */
|
|
128
139
|
export declare const DEFAULT_GUARDRAILS: Guardrails;
|
|
129
140
|
/**
|
package/dist/agentic-config.js
CHANGED
|
@@ -15,7 +15,8 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
|
|
|
15
15
|
export const STRICTNESS = {
|
|
16
16
|
forcePush: ["deny", "ask", "lease"],
|
|
17
17
|
commitOnProtected: ["deny", "allow"],
|
|
18
|
-
envFiles: ["deny", "git"]
|
|
18
|
+
envFiles: ["deny", "git"],
|
|
19
|
+
merge: ["ask", "auto"]
|
|
19
20
|
};
|
|
20
21
|
/** What the guards run on while any layer is invalid. */
|
|
21
22
|
export const STRICTEST_GUARDRAILS = {
|
|
@@ -24,6 +25,8 @@ export const STRICTEST_GUARDRAILS = {
|
|
|
24
25
|
envFiles: { policy: "deny", allow: [], deny: [] },
|
|
25
26
|
protectedBranches: ["master", "main", "production"]
|
|
26
27
|
};
|
|
28
|
+
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
29
|
+
export const STRICTEST_PR_MERGE = "ask";
|
|
27
30
|
/** What a repository gets with nothing declared. */
|
|
28
31
|
export const DEFAULT_GUARDRAILS = {
|
|
29
32
|
forcePush: "lease",
|
|
@@ -190,6 +193,7 @@ export function inferConfig(root) {
|
|
|
190
193
|
guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
|
|
191
194
|
commit: { convention: "conventional" },
|
|
192
195
|
gate: inferGate(root),
|
|
196
|
+
pr: { merge: STRICTEST_PR_MERGE },
|
|
193
197
|
worktree: { setup: [] }
|
|
194
198
|
};
|
|
195
199
|
}
|
|
@@ -205,6 +209,7 @@ function applyLayer(base, layer) {
|
|
|
205
209
|
},
|
|
206
210
|
commit: { ...base.commit, ...layer.commit },
|
|
207
211
|
gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
|
|
212
|
+
pr: { ...base.pr, ...layer.pr },
|
|
208
213
|
worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
|
|
209
214
|
};
|
|
210
215
|
}
|
|
@@ -263,6 +268,14 @@ function loosenings(base, local) {
|
|
|
263
268
|
}
|
|
264
269
|
return problems;
|
|
265
270
|
}
|
|
271
|
+
/** A local `pr.merge` less strict than the one it would override: `auto` over `ask`. */
|
|
272
|
+
function prLoosenings(base, local) {
|
|
273
|
+
if (local === undefined || rank("merge", local) <= rank("merge", base)) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
const message = `${show(local)} loosens ${show(base)}; the local file may tighten pr.merge, never loosen it`;
|
|
277
|
+
return [{ file: LOCAL_FILE, path: "pr.merge", message }];
|
|
278
|
+
}
|
|
266
279
|
/** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
|
|
267
280
|
function finalize(merged) {
|
|
268
281
|
const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
|
|
@@ -284,7 +297,10 @@ export function resolveConfig(root) {
|
|
|
284
297
|
config = applyLayer(config, committed.value);
|
|
285
298
|
}
|
|
286
299
|
if (local.value !== undefined) {
|
|
287
|
-
const loosened =
|
|
300
|
+
const loosened = [
|
|
301
|
+
...loosenings(config.guardrails, local.value.guardrails),
|
|
302
|
+
...prLoosenings(config.pr.merge, local.value.pr?.merge)
|
|
303
|
+
];
|
|
288
304
|
if (loosened.length > 0) {
|
|
289
305
|
problems.push(...loosened);
|
|
290
306
|
localState = "invalid";
|
|
@@ -294,7 +310,7 @@ export function resolveConfig(root) {
|
|
|
294
310
|
}
|
|
295
311
|
}
|
|
296
312
|
if (problems.length > 0) {
|
|
297
|
-
config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS) };
|
|
313
|
+
config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { merge: STRICTEST_PR_MERGE } };
|
|
298
314
|
}
|
|
299
315
|
return {
|
|
300
316
|
config: finalize(config),
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as check from "./commands/check.js";
|
|
|
5
5
|
import * as config from "./commands/config.js";
|
|
6
6
|
import * as doctor from "./commands/doctor.js";
|
|
7
7
|
import * as gate from "./commands/gate.js";
|
|
8
|
+
import * as prReviewed from "./commands/pr-reviewed.js";
|
|
9
|
+
import * as prShip from "./commands/pr-ship.js";
|
|
8
10
|
import * as primitives from "./commands/primitives.js";
|
|
9
11
|
import * as referencesSetup from "./commands/references-setup.js";
|
|
10
12
|
import * as referencesSync from "./commands/references-sync.js";
|
|
@@ -17,7 +19,9 @@ const COMMANDS = new Map([
|
|
|
17
19
|
["doctor", doctor],
|
|
18
20
|
["config", config],
|
|
19
21
|
["references sync", referencesSync],
|
|
20
|
-
["references setup", referencesSetup]
|
|
22
|
+
["references setup", referencesSetup],
|
|
23
|
+
["pr ship", prShip],
|
|
24
|
+
["pr reviewed", prReviewed]
|
|
21
25
|
]);
|
|
22
26
|
function usage() {
|
|
23
27
|
const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// `dw-sdlc pr reviewed <n> --head <sha>`: pins a clean review to the head it read, as the commit status
|
|
2
|
+
// `dw-sdlc/review` in state success, so a push after the review leaves the new head unreviewed.
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
|
|
5
|
+
export const summary = "pin a clean review to the head it read, as a commit status";
|
|
6
|
+
const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--json]
|
|
7
|
+
|
|
8
|
+
Reads pull request <n> through gh, the read pr ship makes, and when its head is still <sha> posts
|
|
9
|
+
the commit status dw-sdlc/review in state success on it, the pull request's URL as its target. When
|
|
10
|
+
a push moved the head since the review, it posts nothing. One line, or one document with --json.
|
|
11
|
+
Needs an authenticated gh.
|
|
12
|
+
|
|
13
|
+
Exit 0 posted, 2 when it cannot run or the head moved.`;
|
|
14
|
+
const CONTEXT = "dw-sdlc/review";
|
|
15
|
+
/** The one state it posts: a failure or a pending status would turn the pull request unstable. */
|
|
16
|
+
const STATE = "success";
|
|
17
|
+
const DESCRIPTION = "a clean review of this head";
|
|
18
|
+
function parseCli(argv) {
|
|
19
|
+
try {
|
|
20
|
+
const { values, positionals } = parseArgs({
|
|
21
|
+
args: argv,
|
|
22
|
+
allowPositionals: true,
|
|
23
|
+
options: {
|
|
24
|
+
help: { type: "boolean", short: "h" },
|
|
25
|
+
json: { type: "boolean" },
|
|
26
|
+
repo: { type: "string", short: "R" },
|
|
27
|
+
head: { type: "string" }
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
if (values.help === true) {
|
|
31
|
+
return "help";
|
|
32
|
+
}
|
|
33
|
+
const [number, ...more] = positionals;
|
|
34
|
+
const { repo, head } = values;
|
|
35
|
+
if (number === undefined) {
|
|
36
|
+
return { error: "name the pull request: dw-sdlc pr reviewed <n> --head <sha>" };
|
|
37
|
+
}
|
|
38
|
+
if (more.length > 0) {
|
|
39
|
+
return { error: `name one pull request, not ${positionals.length}` };
|
|
40
|
+
}
|
|
41
|
+
const problem = namedProblem(number, repo);
|
|
42
|
+
if (problem !== undefined) {
|
|
43
|
+
return { error: problem };
|
|
44
|
+
}
|
|
45
|
+
if (head === undefined) {
|
|
46
|
+
return { error: "name the head the review read: --head <sha>" };
|
|
47
|
+
}
|
|
48
|
+
if (!/^[0-9a-f]{40}$/.test(head)) {
|
|
49
|
+
return { error: `--head takes the full 40-character sha, not ${JSON.stringify(head)}` };
|
|
50
|
+
}
|
|
51
|
+
return { json: values.json === true, number: Number(number), repo, head };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Posts the review status on `head` once the read shows it is still the pull request's head. */
|
|
58
|
+
function post({ number, repo, head }) {
|
|
59
|
+
const pr = read({ number, repo }, "pr reviewed");
|
|
60
|
+
if (pr.head !== head) {
|
|
61
|
+
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");
|
|
62
|
+
}
|
|
63
|
+
const posted = gh([
|
|
64
|
+
"api",
|
|
65
|
+
`repos/${pr.repo}/statuses/${head}`,
|
|
66
|
+
"--method",
|
|
67
|
+
"POST",
|
|
68
|
+
"--silent",
|
|
69
|
+
"-f",
|
|
70
|
+
`state=${STATE}`,
|
|
71
|
+
"-f",
|
|
72
|
+
`context=${CONTEXT}`,
|
|
73
|
+
"-f",
|
|
74
|
+
`target_url=${pr.url}`,
|
|
75
|
+
"-f",
|
|
76
|
+
`description=${DESCRIPTION}`
|
|
77
|
+
]);
|
|
78
|
+
if (posted.status !== 0) {
|
|
79
|
+
throw new CannotRun(`${pr.repo}#${number}: posting ${CONTEXT} failed: ${said(posted.stderr)}`, `check that gh's token may write commit statuses to ${pr.repo}, then call again`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function run(argv) {
|
|
83
|
+
const cli = parseCli(argv);
|
|
84
|
+
if (cli === "help") {
|
|
85
|
+
process.stdout.write(`${USAGE}\n`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if ("error" in cli) {
|
|
89
|
+
process.stderr.write(`dw-sdlc pr reviewed: ${cli.error}\n\n${USAGE}\n`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
post(cli);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof CannotRun) {
|
|
97
|
+
process.stderr.write(`dw-sdlc pr reviewed: ${error.message} — ${error.remedy}\n`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
const { head } = cli;
|
|
103
|
+
const answer = cli.json
|
|
104
|
+
? JSON.stringify({ head, context: CONTEXT, state: STATE }, null, 2)
|
|
105
|
+
: `posted ${CONTEXT} ${STATE} on ${head}`;
|
|
106
|
+
process.stdout.write(`${answer}\n`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `dw-sdlc pr ship [<n>]`, the Ship script: reads one pull request through gh, decides the Action it
|
|
2
|
+
// needs next, performs it when it is the script's and reads again, then answers the first Action that
|
|
3
|
+
// is not, as a line or one JSON document, with an exit code that names whose turn it is.
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
6
|
+
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, shellLine } from "../pr/decide.js";
|
|
7
|
+
import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
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] [--dry-run] [--json] [--verbose]
|
|
10
|
+
|
|
11
|
+
Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
|
|
12
|
+
is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
|
|
13
|
+
The script performs its own Actions, each at most once and then reads again: it updates a branch
|
|
14
|
+
behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
|
|
15
|
+
request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
|
|
16
|
+
under ask, the default, it parks with the merge command. --dry-run performs nothing: it parks at the
|
|
17
|
+
first Action the script would perform, listed under performed. One line, or one document with
|
|
18
|
+
--json; --verbose adds the state as read to the document under facts. Needs an authenticated gh.
|
|
19
|
+
|
|
20
|
+
Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
|
|
21
|
+
const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
|
|
22
|
+
const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
|
|
23
|
+
function parseCli(argv) {
|
|
24
|
+
try {
|
|
25
|
+
const { values, positionals } = parseArgs({
|
|
26
|
+
args: argv,
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
help: { type: "boolean", short: "h" },
|
|
30
|
+
json: { type: "boolean" },
|
|
31
|
+
verbose: { type: "boolean" },
|
|
32
|
+
merge: { type: "boolean" },
|
|
33
|
+
"dry-run": { type: "boolean" },
|
|
34
|
+
repo: { type: "string", short: "R" }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const [number, ...more] = positionals;
|
|
38
|
+
const { repo } = values;
|
|
39
|
+
if (values.help !== true) {
|
|
40
|
+
if (more.length > 0) {
|
|
41
|
+
return { error: `name one pull request, not ${positionals.length}` };
|
|
42
|
+
}
|
|
43
|
+
const problem = namedProblem(number, repo);
|
|
44
|
+
if (problem !== undefined) {
|
|
45
|
+
return { error: problem };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
help: values.help === true,
|
|
50
|
+
json: values.json === true,
|
|
51
|
+
verbose: values.verbose === true,
|
|
52
|
+
merge: values.merge === true,
|
|
53
|
+
dryRun: values["dry-run"] === true,
|
|
54
|
+
number: number === undefined ? undefined : Number(number),
|
|
55
|
+
repo
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
|
|
63
|
+
function now() {
|
|
64
|
+
const given = process.env.DW_SDLC_NOW?.trim();
|
|
65
|
+
if (given === undefined || given === "") {
|
|
66
|
+
return Date.now();
|
|
67
|
+
}
|
|
68
|
+
const time = Date.parse(given);
|
|
69
|
+
if (Number.isNaN(time)) {
|
|
70
|
+
throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
|
|
71
|
+
}
|
|
72
|
+
return time;
|
|
73
|
+
}
|
|
74
|
+
/** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
|
|
75
|
+
const NAMED = {
|
|
76
|
+
"update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
|
|
77
|
+
"mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
|
|
78
|
+
merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
|
|
79
|
+
};
|
|
80
|
+
/** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
|
|
81
|
+
function handedOver(pr, answer, action, reason) {
|
|
82
|
+
if (action === "merge") {
|
|
83
|
+
return { ...readyToMerge(pr, reason), blockers: answer.blockers };
|
|
84
|
+
}
|
|
85
|
+
return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action) };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
|
|
89
|
+
* pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
|
|
90
|
+
*/
|
|
91
|
+
function ship(cli, merge) {
|
|
92
|
+
let pr = read(cli, "pr ship");
|
|
93
|
+
const performed = [];
|
|
94
|
+
const refusals = {};
|
|
95
|
+
for (;;) {
|
|
96
|
+
const answer = decide(pr, now(), merge);
|
|
97
|
+
const { action } = answer;
|
|
98
|
+
if (!isScriptAction(action)) {
|
|
99
|
+
return { pr, answer, performed };
|
|
100
|
+
}
|
|
101
|
+
// -R names the repository, so the checkout the script runs in plays no part.
|
|
102
|
+
const args = hostArgs(pr, action, true);
|
|
103
|
+
const command = shellLine(["gh", ...args]);
|
|
104
|
+
const { would, act, still } = NAMED[action];
|
|
105
|
+
if (cli.dryRun) {
|
|
106
|
+
performed.push({ action, command, ok: null });
|
|
107
|
+
return {
|
|
108
|
+
pr,
|
|
109
|
+
answer: handedOver(pr, answer, action, `the script would ${would}; --dry-run performs nothing`),
|
|
110
|
+
performed
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (performed.some((done) => done.action === action)) {
|
|
114
|
+
const refusal = refusals[action];
|
|
115
|
+
const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
|
|
116
|
+
return { pr, answer: handedOver(pr, answer, action, reason), performed };
|
|
117
|
+
}
|
|
118
|
+
const result = gh(args);
|
|
119
|
+
performed.push({ action, command, ok: result.status === 0 });
|
|
120
|
+
if (result.status !== 0) {
|
|
121
|
+
refusals[action] = said(result.stderr) || `gh exited ${result.status}`;
|
|
122
|
+
}
|
|
123
|
+
pr = readAgain(pr);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
|
|
127
|
+
function lineOf({ status, action, reason, command, recheckAfter }) {
|
|
128
|
+
const label = status === action ? status : `${status} ${action}`;
|
|
129
|
+
const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
|
|
130
|
+
return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
|
|
131
|
+
}
|
|
132
|
+
function documentOf(pr, answer, performed, verbose) {
|
|
133
|
+
const facts = verbose ? { facts: pr } : {};
|
|
134
|
+
return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
|
|
135
|
+
}
|
|
136
|
+
export function run(argv) {
|
|
137
|
+
const cli = parseCli(argv);
|
|
138
|
+
if ("error" in cli) {
|
|
139
|
+
process.stderr.write(`dw-sdlc pr ship: ${cli.error}\n\n${USAGE}\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
if (cli.help) {
|
|
143
|
+
process.stdout.write(`${USAGE}\n`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
// An invalid config layer resolves to ask, like every other strictest setting.
|
|
147
|
+
const merge = cli.merge ? "auto" : resolveConfig(repositoryRoot(process.cwd())).config.pr.merge;
|
|
148
|
+
let shipped;
|
|
149
|
+
try {
|
|
150
|
+
shipped = ship(cli, merge);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof CannotRun) {
|
|
154
|
+
process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n`);
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const { pr, answer, performed } = shipped;
|
|
160
|
+
const output = cli.json ? JSON.stringify(documentOf(pr, answer, performed, cli.verbose), null, 2) : lineOf(answer);
|
|
161
|
+
process.stdout.write(`${output}\n`);
|
|
162
|
+
return EXIT[answer.status];
|
|
163
|
+
}
|