dw-sdlc 0.12.1 → 0.14.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/dist/pr/read.js CHANGED
@@ -64,6 +64,10 @@ function parseOrCannotRun(parse) {
64
64
  throw error;
65
65
  }
66
66
  }
67
+ /** The remedy for a pull request `command`, as `pr ship`, cannot find by itself: name it in full. */
68
+ export function nameItRemedy(command) {
69
+ return `name it: dw-sdlc ${command} <n> -R owner/name`;
70
+ }
67
71
  /** The pull request named in full, or the one `gh pr view` resolves from what is given. */
68
72
  function targetOf({ number, repo }, command) {
69
73
  if (number !== undefined && repo !== undefined) {
@@ -72,8 +76,7 @@ function targetOf({ number, repo }, command) {
72
76
  const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
73
77
  const view = gh(["pr", "view", ...named, "--json", "url"]);
74
78
  if (view.status !== 0) {
75
- const remedy = `name it: dw-sdlc ${command} <n> -R owner/name`;
76
- throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, remedy);
79
+ throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, nameItRemedy(command));
77
80
  }
78
81
  return parseOrCannotRun(() => parseView(view.stdout));
79
82
  }
@@ -185,25 +188,26 @@ function ownChanges(pr, commit) {
185
188
  return parseOrCannotRun(() => changesOf(reply));
186
189
  }
187
190
  /**
188
- * `pr` and whether its head carries the last reviewed commit's status: only merges since it, and the
189
- * same own changes at both. The compares run only when a Review awaits the head.
191
+ * `pr` and whether its head carries the success of the last commit a Review read: a clean read, only
192
+ * merges since it, and the same own changes at both. A read mark never carries. The compares run only
193
+ * when a Review awaits the head.
190
194
  */
191
195
  function withCarried(pr) {
192
- const last = pr.lastReviewed;
193
- if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
196
+ const last = pr.lastRead;
197
+ if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
194
198
  return { ...pr, carried: false };
195
199
  }
196
200
  return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
197
201
  }
198
202
  /**
199
203
  * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
200
- * the compare from the last reviewed commit, else from the base, to the head.
204
+ * the compare from the last commit a Review read, else from the base, to the head.
201
205
  */
202
206
  function withChanged(pr, sized) {
203
207
  if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
204
208
  return { ...pr, changed: null };
205
209
  }
206
- const from = pr.lastReviewed?.commit ?? pr.baseOid;
210
+ const from = pr.lastRead?.commit ?? pr.baseOid;
207
211
  const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
208
212
  return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
209
213
  }
@@ -2,6 +2,14 @@
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
+ /**
6
+ * The read mark: the review status in this state and with this description says a Review read that
7
+ * head and left a Finding open.
8
+ */
9
+ export declare const MARK_STATE = "failure";
10
+ export declare const READ_MARK = "read, findings open";
11
+ /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
+ export declare const ACCEPTED = "accepted by the dev, findings open";
5
13
  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
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
7
15
  export type CiCheck = {
@@ -51,12 +59,17 @@ export type PrState = {
51
59
  unresolvedThreads: Thread[];
52
60
  /** The oids of the pull request's last 100 commits, oldest first, the head last. */
53
61
  commits: string[];
54
- /** The last commit before the head with a review success not posted under off, of the last 100. */
55
- lastReviewed: LastReviewed | null;
56
- /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
62
+ /**
63
+ * The last commit before the head a Review read, of the last 100: a review success not posted under
64
+ * off, or a read mark.
65
+ */
66
+ lastRead: LastRead | null;
67
+ /** The read marks since the last review success of any kind, of the last 100 commits. */
68
+ marks: ReadMarks;
69
+ /** Whether the head carries the success of `lastRead`: the read decides it from two compares. */
57
70
  carried: boolean;
58
71
  /**
59
- * The files a Review would read, from `lastReviewed` or the base to the head, read only for `pr.review:
72
+ * The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
60
73
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
61
74
  */
62
75
  changed: ChangedFile[] | "unsized" | null;
@@ -75,11 +88,24 @@ export type ChangedFile = {
75
88
  previous: string | null;
76
89
  lines: number | null;
77
90
  };
78
- /** A reviewed commit, and whether every commit after it, the head included, is a merge. */
79
- export type LastReviewed = {
91
+ /**
92
+ * A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
93
+ * every commit after it, the head included, is a merge.
94
+ */
95
+ export type LastRead = {
80
96
  commit: string;
97
+ clean: boolean;
81
98
  mergesOnly: boolean;
82
99
  };
100
+ /**
101
+ * The Reviews that left a Finding open since the last review success, a success under off and the dev's
102
+ * acceptance included: `count` their read marks, the head's own among them, and `since` that success's
103
+ * commit, null when none of the last 100 holds one.
104
+ */
105
+ export type ReadMarks = {
106
+ count: number;
107
+ since: string | null;
108
+ };
83
109
  /** A reply that is not the shape the query asks for; the message names the field. */
84
110
  export declare class ReplyError extends Error {
85
111
  }
package/dist/pr/state.js CHANGED
@@ -4,6 +4,14 @@
4
4
  export const REVIEW = "dw-sdlc/review";
5
5
  /** How the description of a success the script posts under off begins: no one read that head. */
6
6
  export const OFF_PREFIX = "off:";
7
+ /**
8
+ * The read mark: the review status in this state and with this description says a Review read that
9
+ * head and left a Finding open.
10
+ */
11
+ export const MARK_STATE = "failure";
12
+ export const READ_MARK = "read, findings open";
13
+ /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
14
+ export const ACCEPTED = "accepted by the dev, findings open";
7
15
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
8
16
  repository(owner: $owner, name: $name) {
9
17
  pullRequest(number: $number) {
@@ -191,26 +199,57 @@ function threadOf(thread) {
191
199
  export function awaitsReview(pr) {
192
200
  return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
193
201
  }
194
- /** A commit whose review success says someone read it: a status posted under off never does. */
195
- function isReviewed(commit) {
202
+ /**
203
+ * What a commit's review status says: `clean` a success, `findings` the read mark, `off` a success
204
+ * posted under off, which says no one read the commit, null no status of the Review.
205
+ */
206
+ function postedOn(commit) {
196
207
  const status = childOrNull(commit, "status");
197
208
  const context = status === null ? null : childOrNull(status, "context");
198
- if (context === null || string(context, "state") !== "SUCCESS") {
199
- return false;
209
+ if (context === null) {
210
+ return null;
211
+ }
212
+ const state = string(context, "state");
213
+ const description = stringOrNull(context, "description") ?? "";
214
+ if (state === "SUCCESS") {
215
+ return description.startsWith(OFF_PREFIX) ? "off" : "clean";
200
216
  }
201
- return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
217
+ return state === MARK_STATE.toUpperCase() && description === READ_MARK ? "findings" : null;
218
+ }
219
+ /** The pull request's last 100 commits, oldest first, each with the review status it carries. */
220
+ function historyOf(pr) {
221
+ return nodes(pr, "history").map((node) => {
222
+ const commit = child(node, "commit");
223
+ return {
224
+ oid: string(commit, "oid"),
225
+ posted: postedOn(commit),
226
+ merge: integer(child(commit, "parents"), "totalCount") > 1
227
+ };
228
+ });
202
229
  }
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) {
230
+ /**
231
+ * The last commit before `head` a Review read, the history oldest first. A read mark with only merges
232
+ * after it is passed over: no fix answers its Findings yet, and a Review from it would read only the
233
+ * base's changes, so the Review reads from the read before it and finds them again.
234
+ */
235
+ function lastReadOf(history, head) {
236
+ const commits = history.map(({ oid, posted, merge }) => ({
237
+ oid,
238
+ read: oid === head || posted === "off" ? null : posted,
239
+ merge
240
+ }));
241
+ const mergesOnlyAfter = (index) => commits.slice(index + 1).every(({ merge }) => merge);
242
+ const index = commits.findLastIndex(({ read }, at) => read === "clean" || (read === "findings" && !mergesOnlyAfter(at)));
243
+ const last = commits[index];
244
+ if (last === undefined) {
209
245
  return null;
210
246
  }
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 };
247
+ return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
248
+ }
249
+ function readMarksOf(history) {
250
+ const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
251
+ const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
252
+ return { count, since: history[success]?.oid ?? null };
214
253
  }
215
254
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
216
255
  export function parseView(reply) {
@@ -233,6 +272,7 @@ export function parsePr(reply, repo) {
233
272
  const mergeCommit = childOrNull(pr, "mergeCommit");
234
273
  const head = string(pr, "headRefOid");
235
274
  const baseRef = childOrNull(pr, "baseRef");
275
+ const history = historyOf(pr);
236
276
  return {
237
277
  url: string(pr, "url"),
238
278
  repo,
@@ -253,7 +293,8 @@ export function parsePr(reply, repo) {
253
293
  unresolvedThreads: nodes(pr, "reviewThreads")
254
294
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
255
295
  .map(threadOf),
256
- commits: nodes(pr, "history").map((node) => string(child(node, "commit"), "oid")),
257
- lastReviewed: lastReviewedOf(pr, head)
296
+ commits: history.map(({ oid }) => oid),
297
+ lastRead: lastReadOf(history, head),
298
+ marks: readMarksOf(history)
258
299
  };
259
300
  }
@@ -0,0 +1,16 @@
1
+ import type { PrState } from "./state.ts";
2
+ /**
3
+ * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
4
+ * after the remote deleted the branch.
5
+ */
6
+ export declare function upstreamName(root: string, branch: string): string | undefined;
7
+ /** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
8
+ export declare function holds(pr: PrState, head: string | null): boolean;
9
+ /** The checked-out branch at `root`, its head commit, and the other name its upstream gives. */
10
+ export type RenamedUpstream = {
11
+ branch: string;
12
+ head: string;
13
+ upstream: string;
14
+ };
15
+ /** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
16
+ export declare function renamedUpstream(root: string): RenamedUpstream | undefined;
@@ -0,0 +1,31 @@
1
+ // A branch's upstream, read from git, for a branch pushed under another name: gh resolves
2
+ // its pull request neither through `@{push}` nor by the local name, so `pr ship` and recap ask by the name
3
+ // the upstream gives, and take that pull request only when its commits hold the local head.
4
+ import { asked } from "../working-tree.js";
5
+ /**
6
+ * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
7
+ * after the remote deleted the branch.
8
+ */
9
+ export function upstreamName(root, branch) {
10
+ const remote = asked(root, ["config", "--get", `branch.${branch}.remote`]);
11
+ const merge = asked(root, ["config", "--get", `branch.${branch}.merge`]);
12
+ if (remote === undefined || remote === "." || merge === undefined || !merge.startsWith("refs/heads/")) {
13
+ return undefined;
14
+ }
15
+ const name = merge.slice("refs/heads/".length);
16
+ return name === branch ? undefined : name;
17
+ }
18
+ /** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
19
+ export function holds(pr, head) {
20
+ return head !== null && pr.commits.includes(head);
21
+ }
22
+ /** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
23
+ export function renamedUpstream(root) {
24
+ const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
25
+ const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]);
26
+ if (branch === undefined || head === undefined) {
27
+ return undefined;
28
+ }
29
+ const upstream = upstreamName(root, branch);
30
+ return upstream === undefined ? undefined : { branch, head, upstream };
31
+ }
@@ -19,6 +19,6 @@ export type Ran = {
19
19
  export declare const TAIL_LINES = 50;
20
20
  /** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
21
21
  export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
22
- /** The signal that stopped the run, once one has; no command starts after it. */
22
+ /** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
23
23
  export declare function stoppedBy(): NodeJS.Signals | undefined;
24
24
  export declare function runCommand(command: string, root: string, output: Output): Promise<Ran>;
@@ -13,6 +13,11 @@ const STOPPING = ["SIGINT", "SIGTERM"];
13
13
  let running;
14
14
  /** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
15
15
  let received;
16
+ /**
17
+ * The SIGINT or SIGTERM that ended a command; no command starts after it either. Ctrl-C reaches the
18
+ * command and the Gate together, and the command's exit can be seen before the Gate's own signal.
19
+ */
20
+ let commandEndedBy;
16
21
  function release() {
17
22
  for (const signal of STOPPING) {
18
23
  process.off(signal, stop);
@@ -32,6 +37,8 @@ function stop(signal) {
32
37
  export async function holdingSignals(body) {
33
38
  running = undefined;
34
39
  received = undefined;
40
+ commandEndedBy = undefined;
41
+ release();
35
42
  for (const signal of STOPPING) {
36
43
  process.on(signal, stop);
37
44
  }
@@ -41,12 +48,16 @@ export async function holdingSignals(body) {
41
48
  finally {
42
49
  // One turn of the event loop hands a signal that landed after the last command to its handler.
43
50
  await new Promise((resolve) => setImmediate(resolve));
44
- release();
51
+ // The Gate's own signal may still be on its way after a command it ended: the handlers stay, so
52
+ // that signal does not end the Gate before its report.
53
+ if (commandEndedBy === undefined || received !== undefined) {
54
+ release();
55
+ }
45
56
  }
46
57
  }
47
- /** The signal that stopped the run, once one has; no command starts after it. */
58
+ /** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
48
59
  export function stoppedBy() {
49
- return received;
60
+ return received ?? commandEndedBy;
50
61
  }
51
62
  /** Everything written to `fd`, read from its start: the command's writes moved the offset it shares to the end. */
52
63
  function written(fd) {
@@ -99,7 +110,12 @@ function run(command, root, stdio) {
99
110
  done({ status: "error", exitCode: null, signal: null, detail: `could not start: ${error.message}` });
100
111
  }
101
112
  });
102
- child.once("exit", (exitCode, signal) => done(answer(exitCode, signal)));
113
+ child.once("exit", (exitCode, signal) => {
114
+ if (signal !== null && STOPPING.includes(signal)) {
115
+ commandEndedBy ??= signal;
116
+ }
117
+ done(answer(exitCode, signal));
118
+ });
103
119
  });
104
120
  }
105
121
  export async function runCommand(command, root, output) {
@@ -0,0 +1,89 @@
1
+ import { type Worker } from "./worker/herdr.ts";
2
+ import { type LockVerdict } from "./worktree-lock.ts";
3
+ /** A live terminal of another session, or the sweeping one's own, with the place herdr shows it at. */
4
+ type Seen = {
5
+ address: string;
6
+ name: string;
7
+ folder: string;
8
+ place: string;
9
+ };
10
+ /** A Worker with the pid of the program `hc get` reports in its terminal, which a Claude Code lock names. */
11
+ export type Located = Worker & {
12
+ pid: number | undefined;
13
+ };
14
+ export type Terminals = {
15
+ own: Seen | undefined;
16
+ workers: Located[];
17
+ others: Seen[];
18
+ };
19
+ /** What holds a worktree: the terminal of this session, another session's, or a Worker of this one. */
20
+ export type Holder = {
21
+ role: "own";
22
+ terminal: Seen;
23
+ } | {
24
+ role: "other";
25
+ terminal: Seen;
26
+ } | {
27
+ role: "worker";
28
+ worker: Located;
29
+ };
30
+ /** The terminal to stop and remove, or only to remove once it has finished. */
31
+ export type TerminalPlan = {
32
+ kind: "terminal";
33
+ name: string;
34
+ reason: string;
35
+ stop: boolean;
36
+ tree?: string;
37
+ };
38
+ /** The address of the terminal the Sweep runs in, or undefined outside herdr. */
39
+ export declare function sweepingTerminal(): string | undefined;
40
+ /**
41
+ * Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
42
+ * terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
43
+ */
44
+ export declare function readTerminals(own: string): Terminals;
45
+ /** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
46
+ export declare function workerWithoutFolder({ workers }: Terminals, roots: string[]): Worker[];
47
+ /** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
48
+ export declare function holdersOf({ own, workers, others }: Terminals, main: string, trees: string[]): Map<string, Holder[]>;
49
+ /** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
50
+ export declare function atWork({ status }: Worker): string | undefined;
51
+ /** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
52
+ export declare function whyHeld(holders: Holder[]): {
53
+ reason: string;
54
+ command: string | null;
55
+ } | null;
56
+ /** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
57
+ export declare function stopsOf(holders: Holder[], tree: string): TerminalPlan[];
58
+ /** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
59
+ export declare function removeTerminal(item: {
60
+ name: string;
61
+ stop?: boolean;
62
+ }): string | undefined;
63
+ /** A Worker the Sweep keeps, with why. */
64
+ export type KeptWorker = {
65
+ name: string;
66
+ reason: string;
67
+ };
68
+ /** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
69
+ export declare function outsideWorktrees(terminals: Terminals, roots: string[]): {
70
+ remove: TerminalPlan[];
71
+ keep: KeptWorker[];
72
+ };
73
+ /** The command that stops or removes the terminal of a planned terminal by hand. */
74
+ export declare function byHand(item: {
75
+ name: string;
76
+ stop?: boolean;
77
+ }): string;
78
+ /** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
79
+ export declare function lockHolder(pid: number, holders: Holder[]): Located | undefined;
80
+ /** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
81
+ export declare function lockAfterStop(root: string, path: string): LockVerdict;
82
+ type KeptTerminal = {
83
+ name: string;
84
+ reason: string;
85
+ command: string | null;
86
+ };
87
+ /** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
88
+ export declare function keptIn(holders: Holder[], tree: string): KeptTerminal[];
89
+ export {};