dw-sdlc 0.6.0 → 0.9.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 +38 -5
- package/config/agentic.config.schema.json +67 -18
- package/dist/agentic-config.d.ts +28 -1
- package/dist/agentic-config.js +35 -5
- package/dist/checks/agents-map.d.ts +9 -1
- package/dist/checks/agents-map.js +60 -17
- package/dist/checks/decision-records.d.ts +5 -0
- package/dist/checks/decision-records.js +121 -16
- package/dist/checks/file-size-ratchet.js +7 -5
- package/dist/checks/index.js +2 -0
- package/dist/checks/markdown-budget.d.ts +2 -5
- package/dist/checks/markdown-budget.js +6 -15
- package/dist/checks/markdown-refs.js +1 -11
- package/dist/checks/routed-docs.d.ts +6 -0
- package/dist/checks/routed-docs.js +49 -0
- package/dist/checks/tree.d.ts +2 -0
- package/dist/checks/tree.js +13 -3
- package/dist/cli.js +5 -1
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +6 -4
- package/dist/commands/gate.d.ts +10 -4
- package/dist/commands/gate.js +202 -86
- package/dist/commands/pr-reviewed.d.ts +2 -0
- package/dist/commands/pr-reviewed.js +92 -0
- package/dist/commands/pr-ship.d.ts +2 -0
- package/dist/commands/pr-ship.js +183 -0
- package/dist/commands/references-sync.js +5 -6
- package/dist/drift.d.ts +17 -0
- package/dist/drift.js +68 -0
- package/dist/gate-cache.d.ts +13 -0
- package/dist/gate-cache.js +39 -0
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.js +10 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +46 -2
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +13 -0
- package/dist/pr/decide.d.ts +59 -0
- package/dist/pr/decide.js +250 -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/router-block.d.ts +4 -1
- package/dist/router-block.js +31 -7
- package/dist/run-command.d.ts +24 -0
- package/dist/run-command.js +121 -0
- package/dist/setup-gate.js +3 -12
- package/dist/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +2 -2
- package/templates/router.md +1 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// The decision of the Ship script: one ordered list of rows, and the first row whose `when` holds for
|
|
2
|
+
// the pull request answers its Action at the time given; every later row that holds rides in its
|
|
3
|
+
// blockers. The last rows say who merges what no row before them holds back.
|
|
4
|
+
/** How long a head may carry no check before it parks as unverified, from its commit. */
|
|
5
|
+
const GRACE_SECONDS = 120;
|
|
6
|
+
/** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
|
|
7
|
+
const BUDGET_MINUTES = 40;
|
|
8
|
+
/** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
|
|
9
|
+
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
|
+
/** Its one state: a failure or a pending status would turn the pull request unstable. */
|
|
13
|
+
export const REVIEW_STATE = "success";
|
|
14
|
+
/** The words as one POSIX shell command line, each word that needs it single-quoted. */
|
|
15
|
+
export function shellLine(words) {
|
|
16
|
+
return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
|
|
17
|
+
}
|
|
18
|
+
function ciOf(pr) {
|
|
19
|
+
return pr.checks.filter((check) => !check.name.startsWith(OWN_PREFIX));
|
|
20
|
+
}
|
|
21
|
+
function pendingOf(pr) {
|
|
22
|
+
return ciOf(pr).filter((check) => check.bucket === "pending");
|
|
23
|
+
}
|
|
24
|
+
function failingOf(pr) {
|
|
25
|
+
return ciOf(pr).filter((check) => check.bucket === "fail");
|
|
26
|
+
}
|
|
27
|
+
/** A failing check as the agent reads it: its link, and the command that prints a GitHub Actions job's log. */
|
|
28
|
+
function failure({ name, link, log }) {
|
|
29
|
+
return { name, link, ...(log === null ? {} : { log: shellLine(log) }) };
|
|
30
|
+
}
|
|
31
|
+
/** The unresolved threads whose last comment is the viewer's, or with `false` someone else's. */
|
|
32
|
+
function threads(pr, lastByViewer) {
|
|
33
|
+
return pr.unresolvedThreads.filter((thread) => thread.lastByViewer === lastByViewer);
|
|
34
|
+
}
|
|
35
|
+
function listed({ id, path, line, author, body }) {
|
|
36
|
+
return { id, path, line, author, body };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
|
|
40
|
+
* read. The path names the repository, so the call reads the same from any checkout.
|
|
41
|
+
*/
|
|
42
|
+
export function reviewStatusArgs(pr, description) {
|
|
43
|
+
const fields = { state: REVIEW_STATE, context: REVIEW, target_url: pr.url, description };
|
|
44
|
+
const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
|
|
45
|
+
return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
|
|
46
|
+
}
|
|
47
|
+
/** Why a head no one reads carries the success: the dev's word or the config. */
|
|
48
|
+
function offDescription(policy) {
|
|
49
|
+
return `off: ${policy.review.source === "flag" ? "--review off" : "pr.review"}`;
|
|
50
|
+
}
|
|
51
|
+
/** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
|
|
52
|
+
const HOST = {
|
|
53
|
+
"update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
|
|
54
|
+
"mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
|
|
55
|
+
"mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, offDescription(policy)),
|
|
56
|
+
merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
|
|
57
|
+
};
|
|
58
|
+
export function isScriptAction(action) {
|
|
59
|
+
return Object.hasOwn(HOST, action);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
|
|
63
|
+
* base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
|
|
64
|
+
*/
|
|
65
|
+
export function hostArgs(pr, action, policy, named) {
|
|
66
|
+
return HOST[action](pr, named ? ["-R", pr.repo] : [], policy);
|
|
67
|
+
}
|
|
68
|
+
/** The command a script Action leaves the dev to run, from a checkout of the repository. */
|
|
69
|
+
export function devCommand(pr, action, policy) {
|
|
70
|
+
return shellLine(["gh", ...hostArgs(pr, action, policy, false)]);
|
|
71
|
+
}
|
|
72
|
+
/** The merge left to the dev, `reason` saying why it is theirs. */
|
|
73
|
+
export function readyToMerge(pr, policy, reason) {
|
|
74
|
+
return { status: "park", action: "ready-to-merge", actor: "dev", reason, command: devCommand(pr, "merge", policy) };
|
|
75
|
+
}
|
|
76
|
+
const ROWS = [
|
|
77
|
+
{
|
|
78
|
+
when: (pr) => pr.state === "MERGED",
|
|
79
|
+
answer: (pr) => ({
|
|
80
|
+
status: "done",
|
|
81
|
+
action: "done",
|
|
82
|
+
actor: "dev",
|
|
83
|
+
reason: pr.mergeCommit === null ? "merged" : `merged as ${pr.mergeCommit.slice(0, 7)}`,
|
|
84
|
+
command: shellLine(["git", "push", "origin", "--delete", pr.branch]),
|
|
85
|
+
...(pr.mergeCommit === null ? {} : { details: { mergeCommit: pr.mergeCommit } })
|
|
86
|
+
}),
|
|
87
|
+
alone: true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
when: (pr) => pr.state === "CLOSED",
|
|
91
|
+
answer: () => ({ status: "park", action: "stop", actor: "dev", reason: "closed without merging" }),
|
|
92
|
+
alone: true
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
when: (pr) => pr.mergeable === "UNKNOWN",
|
|
96
|
+
answer: () => ({
|
|
97
|
+
status: "wait",
|
|
98
|
+
action: "wait",
|
|
99
|
+
actor: "script",
|
|
100
|
+
reason: "mergeability-unknown",
|
|
101
|
+
recheckAfter: 15
|
|
102
|
+
}),
|
|
103
|
+
alone: true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
when: (pr) => pr.mergeable === "CONFLICTING",
|
|
107
|
+
answer: (pr) => ({
|
|
108
|
+
status: "act",
|
|
109
|
+
action: "fix-conflicts",
|
|
110
|
+
actor: "agent",
|
|
111
|
+
reason: `conflicts with ${pr.base}`,
|
|
112
|
+
details: { base: pr.base }
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
when: (pr) => pr.mergeStateStatus === "BEHIND",
|
|
117
|
+
answer: (pr) => ({ status: "act", action: "update-branch", actor: "script", reason: `behind ${pr.base}` })
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
when: (pr) => failingOf(pr).length > 0,
|
|
121
|
+
answer: (pr) => ({
|
|
122
|
+
status: "act",
|
|
123
|
+
action: "fix-ci",
|
|
124
|
+
actor: "agent",
|
|
125
|
+
reason: `${failingOf(pr).length} check(s) failing`,
|
|
126
|
+
details: { checks: failingOf(pr).map(failure) }
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
when: (pr) => threads(pr, false).length > 0,
|
|
131
|
+
answer: (pr) => ({
|
|
132
|
+
status: "act",
|
|
133
|
+
action: "address-review",
|
|
134
|
+
actor: "agent",
|
|
135
|
+
reason: `${threads(pr, false).length} unresolved thread(s)`,
|
|
136
|
+
details: { threads: threads(pr, false).map(listed) }
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED",
|
|
141
|
+
answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
// The dev merges another author's pull request, so it needs no Review. Under off no one reads
|
|
145
|
+
// the head: the script posts the success itself.
|
|
146
|
+
when: (pr) => pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
|
|
147
|
+
answer: (pr, _, { review, reviewCommand }) => {
|
|
148
|
+
const off = review.depth === "off";
|
|
149
|
+
return {
|
|
150
|
+
status: "act",
|
|
151
|
+
action: off ? "mark-reviewed" : "review",
|
|
152
|
+
actor: off ? "script" : "agent",
|
|
153
|
+
reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}`,
|
|
154
|
+
details: { ...review, ...(review.depth === "full" ? { reviewCommand } : {}) }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
when: (pr) => ciOf(pr).length === 0,
|
|
160
|
+
answer: (pr, now, policy) => now - Date.parse(pr.headCommittedAt) <= GRACE_SECONDS * 1000
|
|
161
|
+
? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
|
|
162
|
+
: {
|
|
163
|
+
status: "park",
|
|
164
|
+
action: "unverified",
|
|
165
|
+
actor: "dev",
|
|
166
|
+
reason: `no check reported on ${pr.head.slice(0, 7)} within ${GRACE_SECONDS} s of its commit`,
|
|
167
|
+
command: devCommand(pr, "merge", policy)
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
when: (pr) => pendingOf(pr).length > 0,
|
|
172
|
+
answer: (pr, now) => {
|
|
173
|
+
const pending = pendingOf(pr);
|
|
174
|
+
// A check queued and not yet started counts from the head's commit.
|
|
175
|
+
const start = Math.min(...pending.map((check) => Date.parse(check.startedAt ?? pr.headCommittedAt)));
|
|
176
|
+
const minutes = Math.floor((now - start) / 60_000);
|
|
177
|
+
return now - start <= BUDGET_MINUTES * 60_000
|
|
178
|
+
? { status: "wait", action: "wait", actor: "script", reason: "ci-pending", recheckAfter: 60 }
|
|
179
|
+
: {
|
|
180
|
+
status: "park",
|
|
181
|
+
action: "ci-stuck",
|
|
182
|
+
actor: "dev",
|
|
183
|
+
reason: `${pending.length} check(s) pending for ${minutes} min, past the ${BUDGET_MINUTES} min budget`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
when: (pr) => threads(pr, true).length > 0,
|
|
189
|
+
answer: (pr) => ({
|
|
190
|
+
status: "park",
|
|
191
|
+
action: "awaiting-reply",
|
|
192
|
+
actor: "dev",
|
|
193
|
+
reason: `${threads(pr, true).length} thread(s) await a reply`,
|
|
194
|
+
details: { threads: threads(pr, true).map(listed) }
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
when: (pr) => pr.draft && pr.viewerDidAuthor,
|
|
199
|
+
answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
when: (pr) => pr.draft && !pr.viewerDidAuthor,
|
|
203
|
+
answer: () => ({
|
|
204
|
+
status: "park",
|
|
205
|
+
action: "awaiting-author",
|
|
206
|
+
actor: "dev",
|
|
207
|
+
reason: "another author's draft: marking it ready is the author's"
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
when: (pr) => pr.reviewDecision === "REVIEW_REQUIRED",
|
|
212
|
+
answer: () => ({
|
|
213
|
+
status: "park",
|
|
214
|
+
action: "awaiting-approval",
|
|
215
|
+
actor: "dev",
|
|
216
|
+
reason: "an approving review is required"
|
|
217
|
+
})
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
when: (pr) => !pr.viewerDidAuthor,
|
|
221
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "another author's pull request: the merge is the dev's"),
|
|
222
|
+
merging: true
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
when: (_, { merge }) => merge === "auto",
|
|
226
|
+
answer: () => ({ status: "act", action: "merge", actor: "script", reason: "green, reviewed and the viewer's own" }),
|
|
227
|
+
merging: true
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
when: (_, { merge }) => merge === "ask",
|
|
231
|
+
answer: (pr, _, policy) => readyToMerge(pr, policy, "pr.merge is ask: the merge is the dev's"),
|
|
232
|
+
merging: true
|
|
233
|
+
}
|
|
234
|
+
];
|
|
235
|
+
function blockerOf({ action, actor, reason, details }) {
|
|
236
|
+
return { action, actor, reason, ...(details === undefined ? {} : { details }) };
|
|
237
|
+
}
|
|
238
|
+
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
239
|
+
export function decide(pr, now, policy) {
|
|
240
|
+
const index = ROWS.findIndex((row) => row.when(pr, policy));
|
|
241
|
+
const row = ROWS[index];
|
|
242
|
+
if (row === undefined) {
|
|
243
|
+
throw new Error(`no row of the decision matches ${pr.url} under pr.merge ${policy.merge}`);
|
|
244
|
+
}
|
|
245
|
+
const later = row.alone === true ? [] : ROWS.slice(index + 1).filter((other) => !other.merging && other.when(pr, policy));
|
|
246
|
+
return {
|
|
247
|
+
...row.answer(pr, now, policy),
|
|
248
|
+
blockers: later.map((other) => blockerOf(other.answer(pr, now, policy)))
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type PrState } from "./state.ts";
|
|
2
|
+
/** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
|
|
3
|
+
export declare class CannotRun extends Error {
|
|
4
|
+
remedy: string;
|
|
5
|
+
constructor(message: string, remedy: string);
|
|
6
|
+
}
|
|
7
|
+
/** The pull request a command line names; what it leaves out, `gh pr view` resolves. */
|
|
8
|
+
export type Named = {
|
|
9
|
+
number: number | undefined;
|
|
10
|
+
repo: string | undefined;
|
|
11
|
+
};
|
|
12
|
+
/** The problem with `<n>` or `-R` as given on a command line, if there is one. */
|
|
13
|
+
export declare function namedProblem(number: string | undefined, repo: string | undefined): string | undefined;
|
|
14
|
+
/** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
|
|
15
|
+
export declare function said(stderr: string): string;
|
|
16
|
+
export declare function gh(args: string[]): {
|
|
17
|
+
status: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
};
|
|
21
|
+
/** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
|
|
22
|
+
export declare function read(named: Named, command: string): PrState;
|
|
23
|
+
/** 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;
|
package/dist/pr/read.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { parsePr, parseView, QUERY, ReplyError } from "./state.js";
|
|
5
|
+
/** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
|
|
6
|
+
export class CannotRun extends Error {
|
|
7
|
+
remedy;
|
|
8
|
+
constructor(message, remedy) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.remedy = remedy;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const REMEDIES = {
|
|
14
|
+
missing: "install the GitHub CLI from https://cli.github.com",
|
|
15
|
+
auth: "run gh auth login, or set GH_TOKEN",
|
|
16
|
+
target: "check the number, or name the repository with -R owner/name",
|
|
17
|
+
read: "check gh auth status and the network, then call again",
|
|
18
|
+
reply: "gh answered a shape dw-sdlc does not read; report it with the output of gh --version"
|
|
19
|
+
};
|
|
20
|
+
/** The problem with `<n>` or `-R` as given on a command line, if there is one. */
|
|
21
|
+
export function namedProblem(number, repo) {
|
|
22
|
+
if (number !== undefined && !/^[1-9]\d*$/.test(number)) {
|
|
23
|
+
return `${JSON.stringify(number)} is not a pull request number`;
|
|
24
|
+
}
|
|
25
|
+
if (repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
26
|
+
return `-R takes owner/name, not ${JSON.stringify(repo)}`;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
function firstLine(text) {
|
|
31
|
+
return text.trim().split("\n")[0] ?? "";
|
|
32
|
+
}
|
|
33
|
+
/** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
|
|
34
|
+
export function said(stderr) {
|
|
35
|
+
return firstLine(stderr).replace(/^(?:gh: |X )/, "");
|
|
36
|
+
}
|
|
37
|
+
/** The problem `gh auth status` reports: its line marked `X` under the host, else its first line. */
|
|
38
|
+
function authProblem(stderr) {
|
|
39
|
+
const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
|
|
40
|
+
return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
|
|
41
|
+
}
|
|
42
|
+
export function gh(args) {
|
|
43
|
+
const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
44
|
+
if (result.error !== undefined) {
|
|
45
|
+
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
46
|
+
throw new CannotRun("gh is not on PATH", REMEDIES.missing);
|
|
47
|
+
}
|
|
48
|
+
throw result.error;
|
|
49
|
+
}
|
|
50
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
51
|
+
}
|
|
52
|
+
/** What `parse` returns; a reply it cannot parse stops the command, naming the field. */
|
|
53
|
+
function parseOrCannotRun(parse) {
|
|
54
|
+
try {
|
|
55
|
+
return parse();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof ReplyError) {
|
|
59
|
+
throw new CannotRun(`the reply does not parse: ${error.message}`, REMEDIES.reply);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The pull request named in full, or the one `gh pr view` resolves from what is given. */
|
|
65
|
+
function targetOf({ number, repo }, command) {
|
|
66
|
+
if (number !== undefined && repo !== undefined) {
|
|
67
|
+
return { repo, number };
|
|
68
|
+
}
|
|
69
|
+
const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
|
|
70
|
+
const view = gh(["pr", "view", ...named, "--json", "url"]);
|
|
71
|
+
if (view.status !== 0) {
|
|
72
|
+
const remedy = `name it: dw-sdlc ${command} <n> -R owner/name`;
|
|
73
|
+
throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, remedy);
|
|
74
|
+
}
|
|
75
|
+
return parseOrCannotRun(() => parseView(view.stdout));
|
|
76
|
+
}
|
|
77
|
+
/** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
|
|
78
|
+
export function read(named, command) {
|
|
79
|
+
// The host gh api reads from: an account on any other host has no say in this call.
|
|
80
|
+
const host = process.env.GH_HOST?.trim() || "github.com";
|
|
81
|
+
const auth = gh(["auth", "status", "--active", "--hostname", host]);
|
|
82
|
+
if (auth.status !== 0) {
|
|
83
|
+
throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
|
|
84
|
+
}
|
|
85
|
+
return query(targetOf(named, command));
|
|
86
|
+
}
|
|
87
|
+
/** 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);
|
|
90
|
+
}
|
|
91
|
+
/** The state of pull request `number` of `repo`, from one GraphQL request. */
|
|
92
|
+
function query({ repo, number }) {
|
|
93
|
+
const [owner = "", name = ""] = repo.split("/");
|
|
94
|
+
const reply = gh([
|
|
95
|
+
"api",
|
|
96
|
+
"graphql",
|
|
97
|
+
"-f",
|
|
98
|
+
`query=${QUERY}`,
|
|
99
|
+
"-f",
|
|
100
|
+
`owner=${owner}`,
|
|
101
|
+
"-f",
|
|
102
|
+
`name=${name}`,
|
|
103
|
+
"-F",
|
|
104
|
+
`number=${number}`
|
|
105
|
+
]);
|
|
106
|
+
if (reply.status !== 0) {
|
|
107
|
+
const problem = said(reply.stderr);
|
|
108
|
+
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
109
|
+
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
110
|
+
}
|
|
111
|
+
return parseOrCannotRun(() => parsePr(reply.stdout, repo));
|
|
112
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
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\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 }\n }\n}";
|
|
2
|
+
export type Bucket = "pass" | "fail" | "pending" | "skipping";
|
|
3
|
+
export type CiCheck = {
|
|
4
|
+
name: string;
|
|
5
|
+
bucket: Bucket;
|
|
6
|
+
required: boolean;
|
|
7
|
+
link: string | null;
|
|
8
|
+
/** The command that prints a failed GitHub Actions job's log; null for any other CI. */
|
|
9
|
+
log: string[] | null;
|
|
10
|
+
startedAt: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type Thread = {
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
line: number | null;
|
|
16
|
+
author: string;
|
|
17
|
+
body: string;
|
|
18
|
+
/** Whether the viewer, the account gh reads as, wrote the thread's last comment. */
|
|
19
|
+
lastByViewer: boolean;
|
|
20
|
+
};
|
|
21
|
+
declare const STATES: readonly ["OPEN", "CLOSED", "MERGED"];
|
|
22
|
+
declare const MERGEABLE: readonly ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
|
|
23
|
+
declare const REVIEW_DECISIONS: readonly ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
|
|
24
|
+
export type PrState = {
|
|
25
|
+
url: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
number: number;
|
|
28
|
+
state: (typeof STATES)[number];
|
|
29
|
+
draft: boolean;
|
|
30
|
+
mergeable: (typeof MERGEABLE)[number];
|
|
31
|
+
/** BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN or UNSTABLE, as GitHub reports it. */
|
|
32
|
+
mergeStateStatus: string;
|
|
33
|
+
reviewDecision: (typeof REVIEW_DECISIONS)[number] | null;
|
|
34
|
+
viewerDidAuthor: boolean;
|
|
35
|
+
head: string;
|
|
36
|
+
headCommittedAt: string;
|
|
37
|
+
branch: string;
|
|
38
|
+
base: string;
|
|
39
|
+
mergeCommit: string | null;
|
|
40
|
+
checks: CiCheck[];
|
|
41
|
+
/** The threads neither resolved nor outdated. */
|
|
42
|
+
unresolvedThreads: Thread[];
|
|
43
|
+
};
|
|
44
|
+
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
45
|
+
export declare class ReplyError extends Error {
|
|
46
|
+
}
|
|
47
|
+
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
48
|
+
export declare function parseView(reply: string): {
|
|
49
|
+
repo: string;
|
|
50
|
+
number: number;
|
|
51
|
+
};
|
|
52
|
+
/** 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;
|
|
54
|
+
export {};
|
package/dist/pr/state.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
|
|
2
|
+
// state the decision reads. Every field the state needs is checked; a reply that fails names the field.
|
|
3
|
+
export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
4
|
+
repository(owner: $owner, name: $name) {
|
|
5
|
+
pullRequest(number: $number) {
|
|
6
|
+
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
|
|
7
|
+
viewerDidAuthor mergeCommit { oid }
|
|
8
|
+
reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
|
|
9
|
+
comments(first: 1) { nodes { author { login } body } }
|
|
10
|
+
latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
|
|
11
|
+
commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
|
|
12
|
+
contexts(first: 50) { nodes {
|
|
13
|
+
... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
|
|
14
|
+
... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}`;
|
|
18
|
+
const STATES = ["OPEN", "CLOSED", "MERGED"];
|
|
19
|
+
const MERGEABLE = ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
|
|
20
|
+
const REVIEW_DECISIONS = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
|
|
21
|
+
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
22
|
+
export class ReplyError extends Error {
|
|
23
|
+
}
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function shown(value) {
|
|
28
|
+
return value === undefined ? "missing" : JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
31
|
+
function rootOf(reply, what) {
|
|
32
|
+
let json;
|
|
33
|
+
try {
|
|
34
|
+
json = JSON.parse(reply);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new ReplyError(`${what} is not JSON`);
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(json)) {
|
|
40
|
+
throw new ReplyError(`${what} is not a JSON object`);
|
|
41
|
+
}
|
|
42
|
+
return { value: json, at: "" };
|
|
43
|
+
}
|
|
44
|
+
function fieldOf(node, key) {
|
|
45
|
+
return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
|
|
46
|
+
}
|
|
47
|
+
function object(value, at) {
|
|
48
|
+
if (!isRecord(value)) {
|
|
49
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an object`);
|
|
50
|
+
}
|
|
51
|
+
return { value, at };
|
|
52
|
+
}
|
|
53
|
+
function child(node, key) {
|
|
54
|
+
const { value, at } = fieldOf(node, key);
|
|
55
|
+
return object(value, at);
|
|
56
|
+
}
|
|
57
|
+
function childOrNull(node, key) {
|
|
58
|
+
const { value, at } = fieldOf(node, key);
|
|
59
|
+
return value === null ? null : object(value, at);
|
|
60
|
+
}
|
|
61
|
+
function string(node, key) {
|
|
62
|
+
const { value, at } = fieldOf(node, key);
|
|
63
|
+
if (typeof value !== "string") {
|
|
64
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a string`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function stringOrNull(node, key) {
|
|
69
|
+
return node.value[key] === null ? null : string(node, key);
|
|
70
|
+
}
|
|
71
|
+
function boolean(node, key) {
|
|
72
|
+
const { value, at } = fieldOf(node, key);
|
|
73
|
+
if (typeof value !== "boolean") {
|
|
74
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function integer(node, key) {
|
|
79
|
+
const { value, at } = fieldOf(node, key);
|
|
80
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
81
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function integerOrNull(node, key) {
|
|
86
|
+
const { value, at } = fieldOf(node, key);
|
|
87
|
+
if (value !== null && !Number.isInteger(value)) {
|
|
88
|
+
throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
|
|
89
|
+
}
|
|
90
|
+
return typeof value === "number" ? value : null;
|
|
91
|
+
}
|
|
92
|
+
/** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
|
|
93
|
+
function time(node, key) {
|
|
94
|
+
const value = string(node, key);
|
|
95
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
96
|
+
throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function timeOrNull(node, key) {
|
|
101
|
+
return node.value[key] === null ? null : time(node, key);
|
|
102
|
+
}
|
|
103
|
+
function isOneOf(values, value) {
|
|
104
|
+
return values.some((known) => known === value);
|
|
105
|
+
}
|
|
106
|
+
function oneOf(node, key, values) {
|
|
107
|
+
const { value, at } = fieldOf(node, key);
|
|
108
|
+
if (!isOneOf(values, value)) {
|
|
109
|
+
throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
/** The objects of a connection's `nodes`. */
|
|
114
|
+
function nodes(node, key) {
|
|
115
|
+
const { value, at } = fieldOf(child(node, key), "nodes");
|
|
116
|
+
if (!Array.isArray(value)) {
|
|
117
|
+
throw new ReplyError(`${at} is ${shown(value)}, not a list`);
|
|
118
|
+
}
|
|
119
|
+
return value.map((item, index) => object(item, `${at}[${index}]`));
|
|
120
|
+
}
|
|
121
|
+
/** The first object of a connection's `nodes`; an empty connection does not parse. */
|
|
122
|
+
function firstNode(node, key) {
|
|
123
|
+
const [first] = nodes(node, key);
|
|
124
|
+
if (first === undefined) {
|
|
125
|
+
throw new ReplyError(`${node.at}.${key}.nodes is empty`);
|
|
126
|
+
}
|
|
127
|
+
return first;
|
|
128
|
+
}
|
|
129
|
+
const FAIL = new Set(["ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
|
|
130
|
+
const SKIPPING = new Set(["SKIPPED", "NEUTRAL"]);
|
|
131
|
+
/** A check's bucket as `gh pr checks` sorts it: every word not passed, failed or skipped is pending. */
|
|
132
|
+
function bucketOf(word) {
|
|
133
|
+
if (word === "SUCCESS") {
|
|
134
|
+
return "pass";
|
|
135
|
+
}
|
|
136
|
+
if (word !== null && FAIL.has(word)) {
|
|
137
|
+
return "fail";
|
|
138
|
+
}
|
|
139
|
+
return word !== null && SKIPPING.has(word) ? "skipping" : "pending";
|
|
140
|
+
}
|
|
141
|
+
function logCommand(link, repo) {
|
|
142
|
+
const job = link?.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1];
|
|
143
|
+
return job === undefined ? null : ["gh", "run", "view", "--job", job, "-R", repo, "--log-failed"];
|
|
144
|
+
}
|
|
145
|
+
/** A CheckRun has a name and a status; a StatusContext has a context and a state. */
|
|
146
|
+
function checkOf(context, repo) {
|
|
147
|
+
if ("name" in context.value) {
|
|
148
|
+
const status = string(context, "status");
|
|
149
|
+
const link = stringOrNull(context, "detailsUrl");
|
|
150
|
+
return {
|
|
151
|
+
name: string(context, "name"),
|
|
152
|
+
bucket: bucketOf(status === "COMPLETED" ? stringOrNull(context, "conclusion") : status),
|
|
153
|
+
required: boolean(context, "isRequired"),
|
|
154
|
+
link,
|
|
155
|
+
log: logCommand(link, repo),
|
|
156
|
+
startedAt: timeOrNull(context, "startedAt")
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if ("context" in context.value) {
|
|
160
|
+
const link = stringOrNull(context, "targetUrl");
|
|
161
|
+
return {
|
|
162
|
+
name: string(context, "context"),
|
|
163
|
+
bucket: bucketOf(string(context, "state")),
|
|
164
|
+
required: boolean(context, "isRequired"),
|
|
165
|
+
link,
|
|
166
|
+
log: logCommand(link, repo),
|
|
167
|
+
startedAt: time(context, "createdAt")
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
throw new ReplyError(`${context.at} is neither a CheckRun nor a StatusContext`);
|
|
171
|
+
}
|
|
172
|
+
/** A thread by its opening comment; a deleted account's comment has no author and reads as GitHub's ghost. */
|
|
173
|
+
function threadOf(thread) {
|
|
174
|
+
const first = firstNode(thread, "comments");
|
|
175
|
+
const author = childOrNull(first, "author");
|
|
176
|
+
return {
|
|
177
|
+
id: string(thread, "id"),
|
|
178
|
+
path: string(thread, "path"),
|
|
179
|
+
line: integerOrNull(thread, "line"),
|
|
180
|
+
author: author === null ? "ghost" : string(author, "login"),
|
|
181
|
+
body: string(first, "body"),
|
|
182
|
+
lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
186
|
+
export function parseView(reply) {
|
|
187
|
+
const url = string(rootOf(reply, "the reply of gh pr view"), "url");
|
|
188
|
+
const [, repo, number] = /^https:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)$/.exec(url) ?? [];
|
|
189
|
+
if (repo === undefined || number === undefined) {
|
|
190
|
+
throw new ReplyError(`url is ${JSON.stringify(url)}, not a pull request's URL`);
|
|
191
|
+
}
|
|
192
|
+
return { repo, number: Number(number) };
|
|
193
|
+
}
|
|
194
|
+
/** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
|
|
195
|
+
export function parsePr(reply, repo) {
|
|
196
|
+
const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
|
|
197
|
+
const commit = child(firstNode(pr, "commits"), "commit");
|
|
198
|
+
const rollup = childOrNull(commit, "statusCheckRollup");
|
|
199
|
+
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
200
|
+
return {
|
|
201
|
+
url: string(pr, "url"),
|
|
202
|
+
repo,
|
|
203
|
+
number: integer(pr, "number"),
|
|
204
|
+
state: oneOf(pr, "state", STATES),
|
|
205
|
+
draft: boolean(pr, "isDraft"),
|
|
206
|
+
mergeable: oneOf(pr, "mergeable", MERGEABLE),
|
|
207
|
+
mergeStateStatus: string(pr, "mergeStateStatus"),
|
|
208
|
+
reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
|
|
209
|
+
viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
|
|
210
|
+
head: string(pr, "headRefOid"),
|
|
211
|
+
headCommittedAt: time(commit, "committedDate"),
|
|
212
|
+
branch: string(pr, "headRefName"),
|
|
213
|
+
base: string(pr, "baseRefName"),
|
|
214
|
+
mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
|
|
215
|
+
checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
|
|
216
|
+
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
217
|
+
.filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
|
|
218
|
+
.map(threadOf)
|
|
219
|
+
};
|
|
220
|
+
}
|
package/dist/router-block.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Reference } from "./references-key.ts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The block the file gives: one row per Reference in the file's order, or one line when it has none,
|
|
4
|
+
* with a blank line inside each marker and each `when` trimmed, as oxfmt leaves it.
|
|
5
|
+
*/
|
|
3
6
|
export declare function renderBlock(references: Reference[]): string;
|
|
4
7
|
/**
|
|
5
8
|
* The Router's text with `block` in place of the one between its markers: as it is when the two say
|