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.
@@ -0,0 +1,60 @@
1
+ // GitHub's compare of two commits, read two ways: whether a head carries the Review of an earlier commit,
2
+ // the pull request's own changes from the base the same at both, since a merge of the base in adds none
3
+ // of its own; and the files a Review would read, with their changed lines, for `pr.review: auto`.
4
+ import { fieldOf, integer, object, ReplyError, rootOf, shown, string } from "./state.js";
5
+ /** GitHub's compare lists at most this many files; one that lists as many may hold back the rest. */
6
+ const FILE_LIMIT = 300;
7
+ /** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
8
+ export function compareArgs(repo, from, commit) {
9
+ return ["api", `repos/${repo}/compare/${from}...${commit}?per_page=1`];
10
+ }
11
+ /** The files a compare reply lists. */
12
+ function filesOf(reply) {
13
+ const { value, at } = fieldOf(rootOf(reply, "the compare"), "files");
14
+ if (!Array.isArray(value)) {
15
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
16
+ }
17
+ return value.map((item, index) => object(item, `${at}[${index}]`));
18
+ }
19
+ /** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
20
+ export function behindOf(reply) {
21
+ return integer(rootOf(reply, "the compare"), "behind_by");
22
+ }
23
+ /** A field the compare may leave out, as a file's patch or its previous name. */
24
+ function optional(file, key) {
25
+ return file.value[key] === undefined || file.value[key] === null ? null : string(file, key);
26
+ }
27
+ /**
28
+ * A file's own change: its status, its names and the lines its patch adds and removes, in order,
29
+ * without the hunk headers and context a moved base shifts; null when the compare shows no patch.
30
+ */
31
+ function changeOf(file) {
32
+ const names = [string(file, "filename"), string(file, "status"), optional(file, "previous_filename")];
33
+ const patch = optional(file, "patch");
34
+ if (patch === null) {
35
+ return null;
36
+ }
37
+ return JSON.stringify([...names, patch.split("\n").filter((line) => /^[-+\\]/.test(line))]);
38
+ }
39
+ /** The own changes a compare reply lists, file by file; null when it cannot show them whole. */
40
+ export function changesOf(reply) {
41
+ const changes = filesOf(reply).map(changeOf);
42
+ const whole = changes.filter((change) => change !== null);
43
+ return changes.length >= FILE_LIMIT || whole.length < changes.length ? null : whole;
44
+ }
45
+ /** The files a compare reply lists with their changed lines; `unsized` when it may hold some back. */
46
+ export function changedOf(reply) {
47
+ const files = filesOf(reply).map((file) => ({
48
+ path: string(file, "filename"),
49
+ previous: optional(file, "previous_filename"),
50
+ lines: optional(file, "patch") === null ? null : integer(file, "additions") + integer(file, "deletions")
51
+ }));
52
+ return files.length >= FILE_LIMIT ? "unsized" : files;
53
+ }
54
+ /** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
55
+ export function sameChanges(then, now) {
56
+ if (then === null || now === null) {
57
+ return false;
58
+ }
59
+ return JSON.stringify(then.toSorted()) === JSON.stringify(now.toSorted());
60
+ }
package/dist/pr/read.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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. */
2
+ /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
3
3
  export declare class CannotRun extends Error {
4
4
  remedy: string;
5
5
  constructor(message: string, remedy: string);
@@ -18,7 +18,18 @@ export declare function gh(args: string[]): {
18
18
  stdout: string;
19
19
  stderr: string;
20
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;
21
+ /**
22
+ * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
23
+ * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
24
+ */
25
+ export declare function read(named: Named, command: string, sized: boolean): PrState;
26
+ /** The state of the current branch's pull request, open or not, or null when gh finds none. */
27
+ export declare function readBranch(sized: boolean): PrState | null;
28
+ /** The default branch of the repository gh resolves from the current directory. */
29
+ export declare function defaultBranch(): string;
30
+ /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
31
+ export declare function now(): number;
23
32
  /** 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;
33
+ export declare function readAgain(pr: PrState, sized: boolean): PrState;
34
+ /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
35
+ export declare function carriesBase(before: PrState, after: PrState): boolean;
package/dist/pr/read.js CHANGED
@@ -1,8 +1,10 @@
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.
1
+ // The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
2
+ // its state from one GraphQL request with the compares that tell whether its head carries an earlier Review
3
+ // and that size its changes, and beside them the default branch and the clock the decision reads.
3
4
  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. */
5
+ import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
+ import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
7
+ /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
6
8
  export class CannotRun extends Error {
7
9
  remedy;
8
10
  constructor(message, remedy) {
@@ -15,7 +17,8 @@ const REMEDIES = {
15
17
  auth: "run gh auth login, or set GH_TOKEN",
16
18
  target: "check the number, or name the repository with -R owner/name",
17
19
  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"
20
+ reply: "gh answered a shape dw-sdlc does not read; report it with the output of gh --version",
21
+ clock: "unset it: it stands in for the clock in tests"
19
22
  };
20
23
  /** The problem with `<n>` or `-R` as given on a command line, if there is one. */
21
24
  export function namedProblem(number, repo) {
@@ -74,22 +77,101 @@ function targetOf({ number, repo }, command) {
74
77
  }
75
78
  return parseOrCannotRun(() => parseView(view.stdout));
76
79
  }
77
- /** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
78
- export function read(named, command) {
80
+ function requireAuth() {
79
81
  // The host gh api reads from: an account on any other host has no say in this call.
80
82
  const host = process.env.GH_HOST?.trim() || "github.com";
81
83
  const auth = gh(["auth", "status", "--active", "--hostname", host]);
82
84
  if (auth.status !== 0) {
83
85
  throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
84
86
  }
85
- return query(targetOf(named, command));
87
+ }
88
+ /**
89
+ * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
90
+ * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
91
+ */
92
+ export function read(named, command, sized) {
93
+ requireAuth();
94
+ return query(targetOf(named, command), sized);
95
+ }
96
+ /** The state of the current branch's pull request, open or not, or null when gh finds none. */
97
+ export function readBranch(sized) {
98
+ requireAuth();
99
+ const view = gh(["pr", "view", "--json", "url"]);
100
+ if (view.status === 0) {
101
+ return query(parseOrCannotRun(() => parseView(view.stdout)), sized);
102
+ }
103
+ if (said(view.stderr).startsWith("no pull requests found")) {
104
+ return null;
105
+ }
106
+ throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
107
+ }
108
+ /** The default branch of the repository gh resolves from the current directory. */
109
+ export function defaultBranch() {
110
+ const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
111
+ if (view.status !== 0) {
112
+ throw new CannotRun(`gh repo view failed: ${said(view.stderr)}`, REMEDIES.read);
113
+ }
114
+ return parseOrCannotRun(() => parseDefaultBranch(view.stdout));
115
+ }
116
+ /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
117
+ export function now() {
118
+ const given = process.env.DW_SDLC_NOW?.trim();
119
+ if (given === undefined || given === "") {
120
+ return Date.now();
121
+ }
122
+ const time = Date.parse(given);
123
+ if (Number.isNaN(time)) {
124
+ throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
125
+ }
126
+ return time;
86
127
  }
87
128
  /** 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);
129
+ export function readAgain(pr, sized) {
130
+ return query(pr, sized);
131
+ }
132
+ /** The reply of the compare from `from` to `commit`; `what` names it when gh refuses it. */
133
+ function compared(pr, from, commit, what) {
134
+ const reply = gh(compareArgs(pr.repo, from, commit));
135
+ if (reply.status !== 0) {
136
+ throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
137
+ }
138
+ return reply.stdout;
139
+ }
140
+ /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
141
+ export function carriesBase(before, after) {
142
+ const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
143
+ return parseOrCannotRun(() => behindOf(reply)) === 0;
144
+ }
145
+ /** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
146
+ function ownChanges(pr, commit) {
147
+ const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
148
+ return parseOrCannotRun(() => changesOf(reply));
149
+ }
150
+ /**
151
+ * `pr` and whether its head carries the last reviewed commit's status: only merges since it, and the
152
+ * same own changes at both. The compares run only when a Review awaits the head.
153
+ */
154
+ function withCarried(pr) {
155
+ const last = pr.lastReviewed;
156
+ if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
157
+ return { ...pr, carried: false };
158
+ }
159
+ return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
160
+ }
161
+ /**
162
+ * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
163
+ * the compare from the last reviewed commit, else from the base, to the head.
164
+ */
165
+ function withChanged(pr, sized) {
166
+ if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
167
+ return { ...pr, changed: null };
168
+ }
169
+ const from = pr.lastReviewed?.commit ?? pr.baseOid;
170
+ const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
171
+ return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
90
172
  }
91
- /** The state of pull request `number` of `repo`, from one GraphQL request. */
92
- function query({ repo, number }) {
173
+ /** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
174
+ function query({ repo, number }, sized) {
93
175
  const [owner = "", name = ""] = repo.split("/");
94
176
  const reply = gh([
95
177
  "api",
@@ -108,5 +190,5 @@ function query({ repo, number }) {
108
190
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
109
191
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
110
192
  }
111
- return parseOrCannotRun(() => parsePr(reply.stdout, repo));
193
+ return withChanged(withCarried(parseOrCannotRun(() => parsePr(reply.stdout, repo))), sized);
112
194
  }
@@ -0,0 +1,14 @@
1
+ import type { ReviewDepth } from "../agentic-config.ts";
2
+ import type { ChangedFile } from "./state.ts";
3
+ /** A routed depth, and under off why no one reads the changes, as the status the script posts says. */
4
+ export type Routed = {
5
+ depth: Exclude<ReviewDepth, "off">;
6
+ } | {
7
+ depth: "off";
8
+ why: string;
9
+ };
10
+ /**
11
+ * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
12
+ * outside lockfiles the compare shows no patch for.
13
+ */
14
+ export declare function route(changed: ChangedFile[] | "unsized" | null): Routed;
@@ -0,0 +1,52 @@
1
+ // The built-in rules `pr.review: auto` routes a Review depth by, from the files the Review would read:
2
+ // lockfiles alone are off; a workflow file, more than 100 changed lines outside lockfiles, or a change
3
+ // the compare cannot size is full; the rest is light. No config key moves them.
4
+ /** The lockfiles a package manager writes, by file name in any directory. */
5
+ const LOCKFILES = new Set([
6
+ "pnpm-lock.yaml",
7
+ "package-lock.json",
8
+ "npm-shrinkwrap.json",
9
+ "yarn.lock",
10
+ "bun.lock",
11
+ "bun.lockb",
12
+ "Gemfile.lock",
13
+ "Cargo.lock",
14
+ "poetry.lock",
15
+ "uv.lock",
16
+ "Pipfile.lock",
17
+ "composer.lock",
18
+ "go.sum",
19
+ "Podfile.lock",
20
+ "pubspec.lock",
21
+ "mix.lock",
22
+ "flake.lock"
23
+ ]);
24
+ /** Where GitHub Actions reads its workflows: a change there runs with the repository's secrets. */
25
+ const WORKFLOWS = ".github/workflows/";
26
+ /** The most changed lines outside lockfiles a light Review reads. */
27
+ const LINE_LIMIT = 100;
28
+ function isLockfile(path) {
29
+ return LOCKFILES.has(path.slice(path.lastIndexOf("/") + 1));
30
+ }
31
+ function isWorkflow({ path, previous }) {
32
+ return [path, previous].some((name) => name?.startsWith(WORKFLOWS) === true);
33
+ }
34
+ /**
35
+ * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
36
+ * outside lockfiles the compare shows no patch for.
37
+ */
38
+ export function route(changed) {
39
+ if (changed === null || changed === "unsized") {
40
+ return { depth: "full" };
41
+ }
42
+ const code = changed.filter((file) => !isLockfile(file.path));
43
+ if (changed.length > 0 && code.length === 0) {
44
+ return { depth: "off", why: "lockfiles only" };
45
+ }
46
+ const lines = code.map((file) => file.lines);
47
+ if (changed.some(isWorkflow) || lines.includes(null)) {
48
+ return { depth: "full" };
49
+ }
50
+ const total = lines.reduce((sum, count) => sum + (count ?? 0), 0);
51
+ return { depth: total > LINE_LIMIT ? "full" : "light" };
52
+ }
@@ -1,4 +1,8 @@
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}";
1
+ /** The commit status a clean review of the head leaves on it. */
2
+ export declare const REVIEW = "dw-sdlc/review";
3
+ /** How the description of a success the script posts under off begins: no one read that head. */
4
+ export declare const OFF_PREFIX = "off:";
5
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
2
6
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
3
7
  export type CiCheck = {
4
8
  name: string;
@@ -36,19 +40,65 @@ export type PrState = {
36
40
  headCommittedAt: string;
37
41
  branch: string;
38
42
  base: string;
43
+ /** The commit the base branch points at. */
44
+ baseOid: string;
39
45
  mergeCommit: string | null;
40
46
  checks: CiCheck[];
41
47
  /** The threads neither resolved nor outdated. */
42
48
  unresolvedThreads: Thread[];
49
+ /** The oids of the pull request's last 100 commits, oldest first, the head last. */
50
+ commits: string[];
51
+ /** The last commit before the head with a review success not posted under off, of the last 100. */
52
+ lastReviewed: LastReviewed | null;
53
+ /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
54
+ carried: boolean;
55
+ /**
56
+ * The files a Review would read, from `lastReviewed` or the base to the head, read only for `pr.review:
57
+ * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
58
+ */
59
+ changed: ChangedFile[] | "unsized" | null;
60
+ };
61
+ /**
62
+ * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
63
+ * null when the compare shows no patch for it, as for a binary file.
64
+ */
65
+ export type ChangedFile = {
66
+ path: string;
67
+ previous: string | null;
68
+ lines: number | null;
69
+ };
70
+ /** A reviewed commit, and whether every commit after it, the head included, is a merge. */
71
+ export type LastReviewed = {
72
+ commit: string;
73
+ mergesOnly: boolean;
43
74
  };
44
75
  /** A reply that is not the shape the query asks for; the message names the field. */
45
76
  export declare class ReplyError extends Error {
46
77
  }
78
+ /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
79
+ export type Node = {
80
+ value: Record<string, unknown>;
81
+ at: string;
82
+ };
83
+ export declare function shown(value: unknown): string;
84
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
85
+ export declare function rootOf(reply: string, what: string): Node;
86
+ export declare function fieldOf(node: Node, key: string): {
87
+ value: unknown;
88
+ at: string;
89
+ };
90
+ export declare function object(value: unknown, at: string): Node;
91
+ export declare function string(node: Node, key: string): string;
92
+ export declare function integer(node: Node, key: string): number;
93
+ /** The viewer's own pull request without a review success on its head: the one a Review reads. */
94
+ export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
47
95
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
48
96
  export declare function parseView(reply: string): {
49
97
  repo: string;
50
98
  number: number;
51
99
  };
100
+ /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
101
+ export declare function parseDefaultBranch(reply: string): string;
52
102
  /** 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;
103
+ export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
54
104
  export {};
package/dist/pr/state.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
2
  // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
3
+ /** The commit status a clean review of the head leaves on it. */
4
+ export const REVIEW = "dw-sdlc/review";
5
+ /** How the description of a success the script posts under off begins: no one read that head. */
6
+ export const OFF_PREFIX = "off:";
3
7
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
4
8
  repository(owner: $owner, name: $name) {
5
9
  pullRequest(number: $number) {
6
- url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
10
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
7
11
  viewerDidAuthor mergeCommit { oid }
8
12
  reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
9
13
  comments(first: 1) { nodes { author { login } body } }
@@ -12,6 +16,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
12
16
  contexts(first: 50) { nodes {
13
17
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
14
18
  ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
19
+ history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
15
20
  }
16
21
  }
17
22
  }`;
@@ -24,11 +29,11 @@ export class ReplyError extends Error {
24
29
  function isRecord(value) {
25
30
  return typeof value === "object" && value !== null && !Array.isArray(value);
26
31
  }
27
- function shown(value) {
32
+ export function shown(value) {
28
33
  return value === undefined ? "missing" : JSON.stringify(value);
29
34
  }
30
35
  /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
31
- function rootOf(reply, what) {
36
+ export function rootOf(reply, what) {
32
37
  let json;
33
38
  try {
34
39
  json = JSON.parse(reply);
@@ -41,10 +46,10 @@ function rootOf(reply, what) {
41
46
  }
42
47
  return { value: json, at: "" };
43
48
  }
44
- function fieldOf(node, key) {
49
+ export function fieldOf(node, key) {
45
50
  return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
46
51
  }
47
- function object(value, at) {
52
+ export function object(value, at) {
48
53
  if (!isRecord(value)) {
49
54
  throw new ReplyError(`${at} is ${shown(value)}, not an object`);
50
55
  }
@@ -58,7 +63,7 @@ function childOrNull(node, key) {
58
63
  const { value, at } = fieldOf(node, key);
59
64
  return value === null ? null : object(value, at);
60
65
  }
61
- function string(node, key) {
66
+ export function string(node, key) {
62
67
  const { value, at } = fieldOf(node, key);
63
68
  if (typeof value !== "string") {
64
69
  throw new ReplyError(`${at} is ${shown(value)}, not a string`);
@@ -75,7 +80,7 @@ function boolean(node, key) {
75
80
  }
76
81
  return value;
77
82
  }
78
- function integer(node, key) {
83
+ export function integer(node, key) {
79
84
  const { value, at } = fieldOf(node, key);
80
85
  if (typeof value !== "number" || !Number.isInteger(value)) {
81
86
  throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
@@ -182,6 +187,31 @@ function threadOf(thread) {
182
187
  lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
183
188
  };
184
189
  }
190
+ /** The viewer's own pull request without a review success on its head: the one a Review reads. */
191
+ export function awaitsReview(pr) {
192
+ return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
193
+ }
194
+ /** A commit whose review success says someone read it: a status posted under off never does. */
195
+ function isReviewed(commit) {
196
+ const status = childOrNull(commit, "status");
197
+ const context = status === null ? null : childOrNull(status, "context");
198
+ if (context === null || string(context, "state") !== "SUCCESS") {
199
+ return false;
200
+ }
201
+ return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
202
+ }
203
+ /** The last commit before `head` that `isReviewed` takes, the history oldest first. */
204
+ function lastReviewedOf(pr, head) {
205
+ const commits = nodes(pr, "history").map((node) => child(node, "commit"));
206
+ const index = commits.findLastIndex((commit) => string(commit, "oid") !== head && isReviewed(commit));
207
+ const reviewed = commits[index];
208
+ if (reviewed === undefined) {
209
+ return null;
210
+ }
211
+ const after = commits.slice(index + 1);
212
+ const mergesOnly = after.every((commit) => integer(child(commit, "parents"), "totalCount") > 1);
213
+ return { commit: string(reviewed, "oid"), mergesOnly };
214
+ }
185
215
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
186
216
  export function parseView(reply) {
187
217
  const url = string(rootOf(reply, "the reply of gh pr view"), "url");
@@ -191,12 +221,17 @@ export function parseView(reply) {
191
221
  }
192
222
  return { repo, number: Number(number) };
193
223
  }
224
+ /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
225
+ export function parseDefaultBranch(reply) {
226
+ return string(child(rootOf(reply, "the reply of gh repo view"), "defaultBranchRef"), "name");
227
+ }
194
228
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
195
229
  export function parsePr(reply, repo) {
196
230
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
197
231
  const commit = child(firstNode(pr, "commits"), "commit");
198
232
  const rollup = childOrNull(commit, "statusCheckRollup");
199
233
  const mergeCommit = childOrNull(pr, "mergeCommit");
234
+ const head = string(pr, "headRefOid");
200
235
  return {
201
236
  url: string(pr, "url"),
202
237
  repo,
@@ -207,14 +242,17 @@ export function parsePr(reply, repo) {
207
242
  mergeStateStatus: string(pr, "mergeStateStatus"),
208
243
  reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
209
244
  viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
210
- head: string(pr, "headRefOid"),
245
+ head,
211
246
  headCommittedAt: time(commit, "committedDate"),
212
247
  branch: string(pr, "headRefName"),
213
248
  base: string(pr, "baseRefName"),
249
+ baseOid: string(pr, "baseRefOid"),
214
250
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
215
251
  checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
216
252
  unresolvedThreads: nodes(pr, "reviewThreads")
217
253
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
218
- .map(threadOf)
254
+ .map(threadOf),
255
+ commits: nodes(pr, "history").map((node) => string(child(node, "commit"), "oid")),
256
+ lastReviewed: lastReviewedOf(pr, head)
219
257
  };
220
258
  }
@@ -0,0 +1,11 @@
1
+ import { type JsonObject } from "./json-text.ts";
2
+ /** The `worker` block a local file holds, carried through on every run, `--force` included. */
3
+ export declare function keptWorker(existing: unknown): JsonObject | undefined;
4
+ /**
5
+ * The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
6
+ * is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
7
+ */
8
+ export declare function pinnedWorker(kept: JsonObject | undefined, pathVariable: string | undefined): {
9
+ worker: JsonObject | undefined;
10
+ detail?: string;
11
+ };
@@ -0,0 +1,37 @@
1
+ // The `worker` block of the machine's local layer: the command a Worker starts with, which the setup
2
+ // writes once when `cswap` is on PATH so no prompt ever carries it.
3
+ import { accessSync, constants, statSync } from "node:fs";
4
+ import { delimiter, join } from "node:path";
5
+ import { isObject } from "./json-text.js";
6
+ /** The command that starts a Worker on the account `cswap` maps to the repository, prompts bypassed. */
7
+ const CSWAP_WORKER_COMMAND = "cswap run -- --dangerously-skip-permissions";
8
+ /** Whether an executable file `name` sits in a directory of `pathVariable`, a PATH as the shell reads it. */
9
+ function onPath(name, pathVariable) {
10
+ return (pathVariable ?? "").split(delimiter).some((dir) => {
11
+ if (dir === "") {
12
+ return false;
13
+ }
14
+ try {
15
+ const file = join(dir, name);
16
+ accessSync(file, constants.X_OK);
17
+ return statSync(file).isFile();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ });
23
+ }
24
+ /** The `worker` block a local file holds, carried through on every run, `--force` included. */
25
+ export function keptWorker(existing) {
26
+ return isObject(existing) && isObject(existing.worker) ? existing.worker : undefined;
27
+ }
28
+ /**
29
+ * The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
30
+ * is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
31
+ */
32
+ export function pinnedWorker(kept, pathVariable) {
33
+ if ((kept !== undefined && "command" in kept) || !onPath("cswap", pathVariable)) {
34
+ return { worker: kept };
35
+ }
36
+ return { worker: { ...kept, command: CSWAP_WORKER_COMMAND }, detail: `worker.command: ${CSWAP_WORKER_COMMAND}` };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {