dw-sdlc 0.10.0 → 0.12.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.
@@ -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,12 +40,17 @@ 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[];
47
50
  /** The threads neither resolved nor outdated. */
48
51
  unresolvedThreads: Thread[];
52
+ /** The oids of the pull request's last 100 commits, oldest first, the head last. */
53
+ commits: string[];
49
54
  /** The last commit before the head with a review success not posted under off, of the last 100. */
50
55
  lastReviewed: LastReviewed | null;
51
56
  /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
@@ -55,6 +60,11 @@ export type PrState = {
55
60
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
56
61
  */
57
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;
58
68
  };
59
69
  /**
60
70
  * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
@@ -95,6 +105,8 @@ export declare function parseView(reply: string): {
95
105
  repo: string;
96
106
  number: number;
97
107
  };
108
+ /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
109
+ export declare function parseDefaultBranch(reply: string): string;
98
110
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
99
- 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">;
100
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 } } } }
@@ -221,6 +221,10 @@ export function parseView(reply) {
221
221
  }
222
222
  return { repo, number: Number(number) };
223
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
+ }
224
228
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
225
229
  export function parsePr(reply, repo) {
226
230
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
@@ -228,6 +232,7 @@ export function parsePr(reply, repo) {
228
232
  const rollup = childOrNull(commit, "statusCheckRollup");
229
233
  const mergeCommit = childOrNull(pr, "mergeCommit");
230
234
  const head = string(pr, "headRefOid");
235
+ const baseRef = childOrNull(pr, "baseRef");
231
236
  return {
232
237
  url: string(pr, "url"),
233
238
  repo,
@@ -242,12 +247,13 @@ export function parsePr(reply, repo) {
242
247
  headCommittedAt: time(commit, "committedDate"),
243
248
  branch: string(pr, "headRefName"),
244
249
  base: string(pr, "baseRefName"),
245
- baseOid: string(pr, "baseRefOid"),
250
+ baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
246
251
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
247
252
  checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
248
253
  unresolvedThreads: nodes(pr, "reviewThreads")
249
254
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
250
255
  .map(threadOf),
256
+ commits: nodes(pr, "history").map((node) => string(child(node, "commit"), "oid")),
251
257
  lastReviewed: lastReviewedOf(pr, head)
252
258
  };
253
259
  }
@@ -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.10.0",
3
+ "version": "0.12.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": {