dw-sdlc 0.11.0 → 0.12.1

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/dist/pr/read.js CHANGED
@@ -93,18 +93,33 @@ export function read(named, command, sized) {
93
93
  requireAuth();
94
94
  return query(targetOf(named, command), sized);
95
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"]);
96
+ /** The pull request `gh pr view` resolves for the head branch `branch`, else the current one; null when none. */
97
+ function viewed(branch) {
98
+ const view = gh(["pr", "view", ...(branch === undefined ? [] : [branch]), "--json", "url"]);
100
99
  if (view.status === 0) {
101
- return query(parseOrCannotRun(() => parseView(view.stdout)), sized);
100
+ return parseOrCannotRun(() => parseView(view.stdout));
102
101
  }
103
102
  if (said(view.stderr).startsWith("no pull requests found")) {
104
103
  return null;
105
104
  }
106
105
  throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
107
106
  }
107
+ /**
108
+ * The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
109
+ * misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
110
+ */
111
+ export function readBranch(sized, upstreamName) {
112
+ requireAuth();
113
+ const own = viewed(undefined);
114
+ if (own !== null) {
115
+ return { pr: query(own, sized), byUpstream: false };
116
+ }
117
+ if (upstreamName === undefined) {
118
+ return null;
119
+ }
120
+ const found = viewed(upstreamName);
121
+ return found === null ? null : { pr: query(found, sized), byUpstream: true };
122
+ }
108
123
  /** The default branch of the repository gh resolves from the current directory. */
109
124
  export function defaultBranch() {
110
125
  const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
@@ -129,12 +144,23 @@ export function now() {
129
144
  export function readAgain(pr, sized) {
130
145
  return query(pr, sized);
131
146
  }
132
- /** The reply of the compare from `from` to `commit`; `what` names it when gh refuses it. */
147
+ /**
148
+ * The compares this process has read, by repository and the two commits, so reads that share one ask gh
149
+ * once: a compare of two commits never changes.
150
+ */
151
+ const COMPARES = new Map();
152
+ /** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
133
153
  function compared(pr, from, commit, what) {
154
+ const key = `${pr.repo} ${from}...${commit}`;
155
+ const known = COMPARES.get(key);
156
+ if (known !== undefined) {
157
+ return known;
158
+ }
134
159
  const reply = gh(compareArgs(pr.repo, from, commit));
135
160
  if (reply.status !== 0) {
136
161
  throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
137
162
  }
163
+ COMPARES.set(key, reply.stdout);
138
164
  return reply.stdout;
139
165
  }
140
166
  /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
@@ -142,6 +168,17 @@ export function carriesBase(before, after) {
142
168
  const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
143
169
  return parseOrCannotRun(() => behindOf(reply)) === 0;
144
170
  }
171
+ /**
172
+ * `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
173
+ * read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
174
+ */
175
+ export function withBehind(pr) {
176
+ if (pr.state !== "OPEN" || pr.mergeable !== "MERGEABLE" || pr.mergeStateStatus === "BEHIND") {
177
+ return pr;
178
+ }
179
+ const reply = compared(pr, pr.baseOid, pr.head, `the compare with ${pr.base}`);
180
+ return { ...pr, behindBy: parseOrCannotRun(() => behindOf(reply)) };
181
+ }
145
182
  /** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
146
183
  function ownChanges(pr, commit) {
147
184
  const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
@@ -190,5 +227,6 @@ function query({ repo, number }, sized) {
190
227
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
191
228
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
192
229
  }
193
- return withChanged(withCarried(parseOrCannotRun(() => parsePr(reply.stdout, repo))), sized);
230
+ const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
231
+ return withChanged(withCarried({ ...parsed, behindBy: null }), sized);
194
232
  }
@@ -2,7 +2,7 @@
2
2
  export declare const REVIEW = "dw-sdlc/review";
3
3
  /** How the description of a success the script posts under off begins: no one read that head. */
4
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}";
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 baseRef { target { oid } } 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}";
6
6
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
7
7
  export type CiCheck = {
8
8
  name: string;
@@ -40,7 +40,10 @@ export type PrState = {
40
40
  headCommittedAt: string;
41
41
  branch: string;
42
42
  base: string;
43
- /** The commit the base branch points at. */
43
+ /**
44
+ * The commit the base branch points at, read from the branch itself: GitHub's `baseRefOid` lags a
45
+ * base that just moved. A deleted base branch leaves `baseRefOid`.
46
+ */
44
47
  baseOid: string;
45
48
  mergeCommit: string | null;
46
49
  checks: CiCheck[];
@@ -57,6 +60,11 @@ export type PrState = {
57
60
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
58
61
  */
59
62
  changed: ChangedFile[] | "unsized" | null;
63
+ /**
64
+ * How many commits of the base the head lacks, from the compare of the base with the head; null when
65
+ * not read.
66
+ */
67
+ behindBy: number | null;
60
68
  };
61
69
  /**
62
70
  * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
@@ -100,5 +108,5 @@ export declare function parseView(reply: string): {
100
108
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
101
109
  export declare function parseDefaultBranch(reply: string): string;
102
110
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
103
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
111
+ export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "behindBy">;
104
112
  export {};
package/dist/pr/state.js CHANGED
@@ -8,7 +8,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
8
8
  repository(owner: $owner, name: $name) {
9
9
  pullRequest(number: $number) {
10
10
  url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
11
- viewerDidAuthor mergeCommit { oid }
11
+ baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
12
12
  reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
13
13
  comments(first: 1) { nodes { author { login } body } }
14
14
  latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
@@ -232,6 +232,7 @@ export function parsePr(reply, repo) {
232
232
  const rollup = childOrNull(commit, "statusCheckRollup");
233
233
  const mergeCommit = childOrNull(pr, "mergeCommit");
234
234
  const head = string(pr, "headRefOid");
235
+ const baseRef = childOrNull(pr, "baseRef");
235
236
  return {
236
237
  url: string(pr, "url"),
237
238
  repo,
@@ -246,7 +247,7 @@ export function parsePr(reply, repo) {
246
247
  headCommittedAt: time(commit, "committedDate"),
247
248
  branch: string(pr, "headRefName"),
248
249
  base: string(pr, "baseRefName"),
249
- baseOid: string(pr, "baseRefOid"),
250
+ baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
250
251
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
251
252
  checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
252
253
  unresolvedThreads: nodes(pr, "reviewThreads")
@@ -0,0 +1,48 @@
1
+ import { type JsonObject } from "../json-text.ts";
2
+ /** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
3
+ export declare class Refusal extends Error {
4
+ remedy: string | undefined;
5
+ constructor(message: string, remedy?: string);
6
+ }
7
+ export type Called = {
8
+ status: number;
9
+ stdout: string;
10
+ stderr: string;
11
+ };
12
+ /** The first line of a tool's output that says anything, or the exit it ended with. */
13
+ export declare function said(called: Called): string;
14
+ /** `tool args` as a child process; a tool that is not on PATH stops the command. */
15
+ export declare function call(tool: "hc" | "herdr-app", args: string[]): Called;
16
+ /** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
17
+ export declare function hcObject(args: string[]): JsonObject;
18
+ /** What an error says, whatever was thrown. */
19
+ export declare function describe(error: unknown): string;
20
+ /** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
21
+ export declare function refuse(command: string, error: unknown): number;
22
+ /** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
23
+ export declare function wholeNumber(flag: string, value: string | undefined, fallback: number, min: number): number | {
24
+ error: string;
25
+ };
26
+ /** The terminal the command runs in, which is the Lead of the Workers it starts or reads. */
27
+ export declare function lead(): string;
28
+ export type Worker = {
29
+ name: string;
30
+ address: string;
31
+ /** herdr's state: live or finished. */
32
+ state: string;
33
+ /** What the Worker reports (idle, working, done, blocked, error), `finished` once it has ended, `unknown` before it reports. */
34
+ status: string;
35
+ /** The folder it was launched in, or the one it works in once `workingFolders` has asked herdr. */
36
+ folder: string;
37
+ };
38
+ /** Whether herdr still runs the Worker's terminal; the one test of it every command shares. */
39
+ export declare function isLive(worker: Worker): boolean;
40
+ /**
41
+ * The Workers with the folder each live one works in, its worktree, which `hc get` reports as
42
+ * `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
43
+ */
44
+ export declare function workingFolders(workers: Worker[]): Worker[];
45
+ /** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
46
+ export declare function workersOf(leadId: string, all: boolean): Worker[];
47
+ /** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
48
+ export declare function table(rows: string[][]): string;
@@ -0,0 +1,115 @@
1
+ // What the `worker` commands share: `hc` and `herdr-app` as child processes, the Lead a command acts for,
2
+ // and the Workers herdr lists for it.
3
+ import { spawnSync } from "node:child_process";
4
+ import { isObject, parseObject } from "../json-text.js";
5
+ /** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
6
+ export class Refusal extends Error {
7
+ remedy;
8
+ constructor(message, remedy) {
9
+ super(message);
10
+ this.remedy = remedy;
11
+ }
12
+ }
13
+ /** The first line of a tool's output that says anything, or the exit it ended with. */
14
+ export function said(called) {
15
+ const line = `${called.stderr}\n${called.stdout}`.split("\n").find((text) => text.trim() !== "");
16
+ return line?.trim() ?? `exited ${called.status}`;
17
+ }
18
+ /** `tool args` as a child process; a tool that is not on PATH stops the command. */
19
+ export function call(tool, args) {
20
+ const result = spawnSync(tool, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
21
+ if (result.error !== undefined) {
22
+ if ("code" in result.error && result.error.code === "ENOENT") {
23
+ throw new Refusal(`${tool} is not on PATH`, "install herdr, or run the command in a terminal herdr started");
24
+ }
25
+ throw result.error;
26
+ }
27
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
28
+ }
29
+ /** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
30
+ export function hcObject(args) {
31
+ const called = call("hc", args);
32
+ if (called.status !== 0) {
33
+ throw new Refusal(`hc ${args[0] ?? ""} failed: ${said(called)}`);
34
+ }
35
+ const parsed = parseObject(called.stdout);
36
+ if (parsed === undefined) {
37
+ throw new Refusal(`hc ${args[0] ?? ""} answered something that is no JSON object`, "check hc --version");
38
+ }
39
+ return parsed;
40
+ }
41
+ /** What an error says, whatever was thrown. */
42
+ export function describe(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+ /** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
46
+ export function refuse(command, error) {
47
+ if (!(error instanceof Refusal)) {
48
+ throw error;
49
+ }
50
+ process.stderr.write(`dw-sdlc ${command}: ${error.message}${error.remedy === undefined ? "" : ` — ${error.remedy}`}\n`);
51
+ return 2;
52
+ }
53
+ /** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
54
+ export function wholeNumber(flag, value, fallback, min) {
55
+ const number = value === undefined ? fallback : Number(value);
56
+ return Number.isInteger(number) && number >= min
57
+ ? number
58
+ : { error: `${flag} takes a whole number from ${min}, not ${JSON.stringify(value)}` };
59
+ }
60
+ /** The terminal the command runs in, which is the Lead of the Workers it starts or reads. */
61
+ export function lead() {
62
+ const id = process.env.HC_TERMINAL_ID;
63
+ if (id === undefined || id === "") {
64
+ throw new Refusal("outside herdr: $HC_TERMINAL_ID is not set", "run it in a terminal herdr started, where the Lead has an address");
65
+ }
66
+ return id;
67
+ }
68
+ /** Whether herdr still runs the Worker's terminal; the one test of it every command shares. */
69
+ export function isLive(worker) {
70
+ return worker.state === "live";
71
+ }
72
+ /**
73
+ * The Workers with the folder each live one works in, its worktree, which `hc get` reports as
74
+ * `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
75
+ */
76
+ export function workingFolders(workers) {
77
+ return workers.map((worker) => {
78
+ if (!isLive(worker)) {
79
+ return worker;
80
+ }
81
+ const got = call("hc", ["get", worker.address, "--json"]);
82
+ const foreground = got.status === 0 ? parseObject(got.stdout)?.foreground : undefined;
83
+ const cwd = isObject(foreground) ? foreground.cwd : undefined;
84
+ return typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
85
+ });
86
+ }
87
+ function word(value) {
88
+ return typeof value === "string" ? value : undefined;
89
+ }
90
+ /** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
91
+ export function workersOf(leadId, all) {
92
+ const listing = hcObject(["ls", "--json", ...(all ? ["--all"] : []), "--label", "role=worker"]);
93
+ const { terminals } = listing;
94
+ if (!Array.isArray(terminals)) {
95
+ throw new Refusal("hc ls answered no list of terminals", "check hc --version");
96
+ }
97
+ return terminals.flatMap((entry) => {
98
+ if (!isObject(entry) || entry.parent !== leadId) {
99
+ return [];
100
+ }
101
+ const { address, state, cwd } = entry;
102
+ if (typeof address !== "string" || typeof state !== "string" || typeof cwd !== "string") {
103
+ throw new Refusal("hc ls answered a terminal without an address, state or folder", "check hc --version");
104
+ }
105
+ const status = state === "finished" ? "finished" : (word(entry.status) ?? "unknown");
106
+ return [{ name: word(entry.name) ?? address, address, state, status, folder: cwd }];
107
+ });
108
+ }
109
+ /** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
110
+ export function table(rows) {
111
+ const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
112
+ return rows
113
+ .map((row) => row.map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd((widths[column] ?? 0) + 2))).join(""))
114
+ .join("\n");
115
+ }
@@ -0,0 +1,9 @@
1
+ /** What naming a tab came to: `kept` says why it stays numbered, a warning and never a failure. */
2
+ export type Named = "renamed" | {
3
+ kept: string;
4
+ };
5
+ /**
6
+ * Names the tab holding `address`, reading `herdr-app ls` again for up to `boundSeconds`, since the window
7
+ * lists a terminal's tab a moment after `hc run` returns.
8
+ */
9
+ export declare function nameTab(address: string, name: string, boundSeconds: number): Promise<Named>;
@@ -0,0 +1,62 @@
1
+ // The tab of a Worker's terminal in the herdr window: `hc run` with a `herdr.space` label opens one that herdr
2
+ // numbers, and `herdr-app` names it.
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { isObject, parseObject } from "../json-text.js";
5
+ import { call, Refusal, said } from "./herdr.js";
6
+ /** The tabs `herdr-app ls` shows, each with the addresses of its terminals. */
7
+ function tabsOf() {
8
+ const listed = call("herdr-app", ["ls"]);
9
+ if (listed.status !== 0) {
10
+ throw new Refusal(`herdr-app ls failed: ${said(listed)}`);
11
+ }
12
+ const parsed = parseObject(listed.stdout);
13
+ if (parsed === undefined) {
14
+ throw new Refusal("herdr-app ls answered no JSON object");
15
+ }
16
+ const spaces = Array.isArray(parsed.spaces) ? parsed.spaces : [];
17
+ return spaces
18
+ .flatMap((space) => (isObject(space) && Array.isArray(space.tabs) ? space.tabs : []))
19
+ .flatMap((tab) => {
20
+ if (!isObject(tab) || typeof tab.id !== "string" || !Array.isArray(tab.terminals)) {
21
+ return [];
22
+ }
23
+ const terminals = tab.terminals.flatMap((entry) => isObject(entry) && typeof entry.terminal === "string" ? [entry.terminal] : []);
24
+ return [{ id: tab.id, terminals }];
25
+ });
26
+ }
27
+ /** The `error` of a herdr-app reply `{ "error": "..." }`. */
28
+ function errorOf(stdout) {
29
+ const parsed = parseObject(stdout);
30
+ return typeof parsed?.error === "string" ? parsed.error : undefined;
31
+ }
32
+ /** How long `nameTab` waits between two reads of `herdr-app ls`. */
33
+ const RETRY_MS = 250;
34
+ /**
35
+ * Names the tab holding `address`, reading `herdr-app ls` again for up to `boundSeconds`, since the window
36
+ * lists a terminal's tab a moment after `hc run` returns.
37
+ */
38
+ export async function nameTab(address, name, boundSeconds) {
39
+ try {
40
+ const deadline = Date.now() + boundSeconds * 1000;
41
+ const find = () => tabsOf().find((candidate) => candidate.terminals.includes(address));
42
+ let tab = find();
43
+ while (tab === undefined && Date.now() < deadline) {
44
+ await sleep(RETRY_MS);
45
+ tab = find();
46
+ }
47
+ if (tab === undefined) {
48
+ return { kept: `the tab of ${address} is not in herdr-app ls within ${boundSeconds} s, so it keeps herdr's name` };
49
+ }
50
+ const renamed = call("herdr-app", ["do", "tab.rename", "--id", tab.id, "--name", name]);
51
+ if (renamed.status !== 0) {
52
+ return { kept: `herdr-app could not rename the tab of ${address}: ${errorOf(renamed.stdout) ?? said(renamed)}` };
53
+ }
54
+ return "renamed";
55
+ }
56
+ catch (error) {
57
+ if (error instanceof Refusal) {
58
+ return { kept: `the tab of ${address} keeps herdr's name: ${error.message}` };
59
+ }
60
+ throw error;
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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": {