dw-sdlc 0.20.0 → 0.21.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.
Files changed (97) hide show
  1. package/README.md +13 -13
  2. package/config/agentic.config.schema.json +1 -1
  3. package/dist/agentic-config.d.ts +16 -0
  4. package/dist/agentic-config.js +28 -23
  5. package/dist/cannot-run.d.ts +27 -0
  6. package/dist/cannot-run.js +48 -0
  7. package/dist/catalogue.js +2 -1
  8. package/dist/checks/agents-map.js +7 -8
  9. package/dist/checks/check.d.ts +10 -8
  10. package/dist/checks/check.js +9 -7
  11. package/dist/checks/decision-records.js +3 -2
  12. package/dist/checks/file-size-ratchet.js +13 -17
  13. package/dist/checks/index.d.ts +6 -5
  14. package/dist/checks/index.js +10 -9
  15. package/dist/checks/markdown-refs.js +58 -6
  16. package/dist/checks/primitives.js +5 -5
  17. package/dist/checks/routed-docs.js +1 -3
  18. package/dist/checks/skill-sections.js +12 -20
  19. package/dist/checks/tree.js +7 -7
  20. package/dist/cli.d.ts +2 -1
  21. package/dist/cli.js +26 -5
  22. package/dist/commands/check.js +27 -42
  23. package/dist/commands/config.js +22 -27
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +11 -18
  26. package/dist/commands/gate.js +35 -40
  27. package/dist/commands/lead-start.js +28 -42
  28. package/dist/commands/pr-reviewed.js +41 -59
  29. package/dist/commands/pr-ship.js +72 -87
  30. package/dist/commands/primitives.js +38 -40
  31. package/dist/commands/recap.js +23 -74
  32. package/dist/commands/references-setup.js +28 -76
  33. package/dist/commands/references-sync.js +29 -53
  34. package/dist/commands/setup.d.ts +2 -7
  35. package/dist/commands/setup.js +38 -90
  36. package/dist/commands/sweep.js +46 -157
  37. package/dist/commands/worker-ls.js +15 -31
  38. package/dist/commands/worker-report.js +36 -52
  39. package/dist/commands/worker-start.js +43 -57
  40. package/dist/drift.js +2 -1
  41. package/dist/exec.d.ts +20 -0
  42. package/dist/exec.js +37 -4
  43. package/dist/gate-cache.js +2 -1
  44. package/dist/git.d.ts +32 -0
  45. package/dist/git.js +60 -0
  46. package/dist/json-schema.js +1 -3
  47. package/dist/landed.js +11 -22
  48. package/dist/paused-operation.d.ts +2 -0
  49. package/dist/paused-operation.js +17 -0
  50. package/dist/pins.js +2 -1
  51. package/dist/pr/decide.d.ts +4 -16
  52. package/dist/pr/decide.js +48 -83
  53. package/dist/pr/delta.d.ts +3 -4
  54. package/dist/pr/delta.js +11 -20
  55. package/dist/pr/read.d.ts +27 -30
  56. package/dist/pr/read.js +96 -158
  57. package/dist/pr/reply.d.ts +34 -0
  58. package/dist/pr/reply.js +112 -0
  59. package/dist/pr/route.d.ts +11 -3
  60. package/dist/pr/route.js +5 -5
  61. package/dist/pr/standing.d.ts +138 -0
  62. package/dist/pr/standing.js +181 -0
  63. package/dist/pr/state.d.ts +14 -69
  64. package/dist/pr/state.js +75 -148
  65. package/dist/pr/upstream.d.ts +1 -6
  66. package/dist/pr/upstream.js +2 -2
  67. package/dist/primitives-diff.js +1 -1
  68. package/dist/recap-next.js +34 -47
  69. package/dist/recap-primitives.js +1 -1
  70. package/dist/references-key.d.ts +0 -1
  71. package/dist/references-key.js +3 -4
  72. package/dist/run-command.d.ts +4 -1
  73. package/dist/run-command.js +32 -11
  74. package/dist/setup-gate.d.ts +1 -7
  75. package/dist/setup-rules.js +2 -6
  76. package/dist/setup-writer.d.ts +32 -0
  77. package/dist/setup-writer.js +60 -0
  78. package/dist/shell-line.d.ts +2 -0
  79. package/dist/shell-line.js +5 -0
  80. package/dist/sweep-removal.d.ts +31 -0
  81. package/dist/sweep-removal.js +133 -0
  82. package/dist/sweep-terminals.d.ts +1 -1
  83. package/dist/sweep-terminals.js +21 -57
  84. package/dist/sweep-trees.d.ts +19 -3
  85. package/dist/sweep-trees.js +67 -31
  86. package/dist/worker/herdr.d.ts +67 -23
  87. package/dist/worker/herdr.js +126 -77
  88. package/dist/worker/launch.js +32 -45
  89. package/dist/worker/origin.d.ts +1 -1
  90. package/dist/worker/origin.js +9 -11
  91. package/dist/worker/tabs.js +11 -7
  92. package/dist/working-tree.d.ts +0 -8
  93. package/dist/working-tree.js +6 -22
  94. package/dist/worktree-lock.js +1 -1
  95. package/package.json +1 -1
  96. package/dist/git-env.d.ts +0 -2
  97. package/dist/git-env.js +0 -10
@@ -0,0 +1,181 @@
1
+ // The Review's standing at a pull request's head: what the Reviews before it read, from the review status
2
+ // each commit carries and the commits force pushes left behind; what the head carries of them; what the
3
+ // next Review reads, through a port onto GitHub's compares; and so the Review the head gets.
4
+ import { route } from "./route.js";
5
+ /**
6
+ * How many Reviews that each left a Finding open, with no success between them, the script lets run: the
7
+ * next call parks the pull request for the dev. A constant, like the depth rules, so no repository turns
8
+ * the loop back on.
9
+ */
10
+ const REVIEW_LIMIT = 3;
11
+ /** A change as a key, equal exactly when the two changes are. */
12
+ function keyOf({ path, status, previous, lines }) {
13
+ return JSON.stringify([path, status, previous, lines]);
14
+ }
15
+ /** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
16
+ function sameChanges(then, now) {
17
+ if (then === null || now === null) {
18
+ return false;
19
+ }
20
+ return JSON.stringify(then.map(keyOf).toSorted()) === JSON.stringify(now.map(keyOf).toSorted());
21
+ }
22
+ /**
23
+ * The files whose own change differs between `before` and `after`, a rename under both names; null when a
24
+ * compare cannot show either whole.
25
+ */
26
+ function movedFiles(before, after) {
27
+ if (before === null || after === null) {
28
+ return null;
29
+ }
30
+ const was = new Set(before.map(keyOf));
31
+ const is = new Set(after.map(keyOf));
32
+ const moved = [
33
+ ...after.filter((change) => !was.has(keyOf(change))),
34
+ ...before.filter((change) => !is.has(keyOf(change)))
35
+ ];
36
+ const names = moved.flatMap(({ path, previous }) => (previous === null ? [path] : [path, previous]));
37
+ return [...new Set(names)].toSorted();
38
+ }
39
+ /**
40
+ * The last commit before the head a Review read, a success under off passed over, or `open`: a read mark
41
+ * on the head, or one with only merges after it. No fix answers its Findings, so no read stands in.
42
+ */
43
+ function lastReadOf({ commits, head }) {
44
+ if (commits.some(({ oid, posted }) => oid === head && posted === "findings")) {
45
+ return { lastRead: null, open: head };
46
+ }
47
+ const index = commits.findLastIndex(({ oid, posted }) => oid !== head && (posted === "clean" || posted === "findings"));
48
+ const last = commits[index];
49
+ if (last === undefined) {
50
+ return { lastRead: null, open: null };
51
+ }
52
+ const after = commits.slice(index + 1);
53
+ const baseOnly = after.every(({ merge }) => merge);
54
+ if (last.posted === "findings" && baseOnly) {
55
+ return { lastRead: null, open: last.oid };
56
+ }
57
+ const baseMoved = after.some(({ merge }) => merge);
58
+ return { lastRead: { commit: last.oid, clean: last.posted === "clean", baseOnly, baseMoved }, open: null };
59
+ }
60
+ /** The newest commit force pushes left behind that a Review read: a rebase moved the base under it. */
61
+ function leftBehindRead({ leftBehind }) {
62
+ const read = leftBehind.find(({ posted }) => posted === "clean" || posted === "findings");
63
+ return read === undefined
64
+ ? null
65
+ : { commit: read.oid, clean: read.posted === "clean", baseOnly: true, baseMoved: true };
66
+ }
67
+ /**
68
+ * A read mark a force push left behind and posted after every read of the history: the Review the head
69
+ * needs reads from it, not from the history's last read, whose clean status no longer stands.
70
+ */
71
+ function leftBehindMark(history) {
72
+ const newest = inPostedOrder(history).findLast(({ posted }) => posted === "clean" || posted === "findings");
73
+ if (newest?.posted !== "findings" || history.commits.some(({ oid }) => oid === newest.oid)) {
74
+ return null;
75
+ }
76
+ return { commit: newest.oid, clean: false, baseOnly: true, baseMoved: true };
77
+ }
78
+ /**
79
+ * The read mark `read` left open, no fix showing after it (ADR 0023): no change since it after the branch's
80
+ * own commits alone, else the same own changes at both commits or a compare that cannot show them whole.
81
+ * Null for any other read.
82
+ */
83
+ function openAt(read, { changed }, own, head) {
84
+ if (read === null || read.clean) {
85
+ return null;
86
+ }
87
+ if (!read.baseMoved) {
88
+ const since = changed(read.commit, head);
89
+ return since !== "unsized" && since.length === 0 ? read.commit : null;
90
+ }
91
+ const atRead = own(read.commit);
92
+ const atHead = own(head);
93
+ return atRead !== null && atHead !== null && !sameChanges(atRead, atHead) ? null : read.commit;
94
+ }
95
+ /** Whether a commit carries a review success of any kind. */
96
+ function isSuccess({ posted }) {
97
+ return posted === "clean" || posted === "off";
98
+ }
99
+ /** How many of `marked` carry a read mark. */
100
+ function findings(marked) {
101
+ return marked.filter(({ posted }) => posted === "findings").length;
102
+ }
103
+ /**
104
+ * The commits in the order their review statuses were posted: each line a force push left behind just
105
+ * after the commit of the history it was pushed on, older pushes first, and a line that holds none of
106
+ * the history before all of it.
107
+ */
108
+ function inPostedOrder({ commits, leftBehind }) {
109
+ const oldestFirst = leftBehind.toReversed();
110
+ const pushedOn = (index) => oldestFirst.filter(({ forksAt }) => forksAt === index);
111
+ return [...pushedOn(-1), ...commits.flatMap((commit, index) => [commit, ...pushedOn(index)])];
112
+ }
113
+ /** The read marks posted after the last success, of the history and of the commits force pushes left behind. */
114
+ function readMarksOf(history) {
115
+ const posted = inPostedOrder(history);
116
+ const success = posted.findLastIndex(isSuccess);
117
+ return { count: findings(posted.slice(success + 1)), since: posted[success]?.oid ?? null };
118
+ }
119
+ /**
120
+ * The standing at the head of `history`. The compares run only when a Review `awaits` the head, and the
121
+ * files that Review would read are read only when `sized`, as `pr.review: auto` routes on them.
122
+ */
123
+ export function standingOf(history, compares, { awaits, sized }) {
124
+ const marks = readMarksOf(history);
125
+ const inHistory = lastReadOf(history);
126
+ const read = inHistory.open === null ? (leftBehindMark(history) ?? inHistory.lastRead ?? leftBehindRead(history)) : null;
127
+ if (!awaits) {
128
+ return { lastRead: read, open: inHistory.open, marks, carried: false, changed: null, scope: null };
129
+ }
130
+ const { head, base } = history;
131
+ const own = (commit) => compares.ownChanges(base, commit);
132
+ const open = inHistory.open ?? openAt(read, compares, own, head);
133
+ const lastRead = open === null ? read : null;
134
+ const reads = { lastRead, open, marks };
135
+ // A clean read carries over the base's changes alone.
136
+ const carried = lastRead !== null && lastRead.clean && lastRead.baseOnly && sameChanges(own(lastRead.commit), own(head));
137
+ if (carried) {
138
+ return { ...reads, carried, changed: null, scope: null };
139
+ }
140
+ const scope = lastRead?.baseMoved === true ? movedFiles(own(lastRead.commit), own(head)) : null;
141
+ if (!sized) {
142
+ return { ...reads, carried, changed: null, scope };
143
+ }
144
+ const changed = compares.changed(lastRead?.commit ?? base, head);
145
+ if (scope === null || changed === "unsized") {
146
+ return { ...reads, carried, changed, scope };
147
+ }
148
+ const within = changed.filter(({ path, previous }) => scope.includes(path) || (previous !== null && scope.includes(previous)));
149
+ return { ...reads, carried, changed: within, scope };
150
+ }
151
+ /** Whether a Finding stands: a read mark since the last success, as the last read, or left open (ADR 0016). */
152
+ function findingsOpen({ marks, lastRead, open }) {
153
+ return marks.count > 0 || lastRead?.clean === false || open !== null;
154
+ }
155
+ /** The depth the head is read at: the dev's or the one `pr.review` sets, else the one the rules route. */
156
+ function depthOf({ depth, source }, standing) {
157
+ if (depth === "off") {
158
+ return { depth, source, why: source === "flag" ? "--review off" : "pr.review" };
159
+ }
160
+ if (depth !== "auto") {
161
+ return { depth, source };
162
+ }
163
+ return { ...route(standing.changed, findingsOpen(standing)), source: "auto" };
164
+ }
165
+ /** The Review the head gets at `standing` under `review`; the dev's word goes over a carry. */
166
+ export function reviewCall(standing, review) {
167
+ const { lastRead, open, marks, carried, scope } = standing;
168
+ const flagged = review.source === "flag";
169
+ if (carried && lastRead !== null && !flagged) {
170
+ return { kind: "carry", from: lastRead.commit };
171
+ }
172
+ const depth = depthOf(review, standing);
173
+ // The dev's own depth buys one Review past the limit, and only one: the flag rides on every
174
+ // later call. Their --review off posts past it: no one reads, by their word.
175
+ const limit = flagged ? REVIEW_LIMIT + 1 : REVIEW_LIMIT;
176
+ if (!(flagged && depth.depth === "off") && marks.count >= limit) {
177
+ return { ...marks, kind: "limit" };
178
+ }
179
+ // Over a carry the dev's depth reads the whole diff: the changes after the read are only the base's.
180
+ return { ...depth, kind: "review", since: carried ? null : (lastRead?.commit ?? null), paths: scope, open };
181
+ }
@@ -1,3 +1,4 @@
1
+ import type { History, Standing } from "./standing.ts";
1
2
  /** The commit status a clean review of the head leaves on it. */
2
3
  export declare const REVIEW = "dw-sdlc/review";
3
4
  /** How the description of a success the script posts under off begins: no one read that head. */
@@ -10,7 +11,7 @@ export declare const MARK_STATE = "failure";
10
11
  export declare const READ_MARK = "read, findings open";
11
12
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
13
  export declare const ACCEPTED = "accepted by the dev, findings open";
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 headRef { name } isCrossRepository baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } 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) { pageInfo { hasNextPage } 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}";
14
+ 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 headRef { name } isCrossRepository baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } 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) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } }\n status { context(name: \"dw-sdlc/review\") { 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 rewritten: timelineItems(last: 20, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent {\n beforeCommit { oid history(first: 20) { nodes { oid parents(first: 1) { nodes { oid } }\n status { context(name: \"dw-sdlc/review\") { state description } } } } } } } }\n }\n }\n}";
14
15
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
16
  export type CiCheck = {
16
17
  name: string;
@@ -38,7 +39,8 @@ export type ChangeRequest = {
38
39
  reviewer: string;
39
40
  answered: boolean;
40
41
  };
41
- export type PrState = {
42
+ /** The pull request as the reply holds it, before the Review's standing is read. */
43
+ export type PullRequest = {
42
44
  url: string;
43
45
  repo: string;
44
46
  number: number;
@@ -74,72 +76,9 @@ export type PrState = {
74
76
  cutOff: string[];
75
77
  /** The oids of the pull request's last 100 commits, oldest first, the head last. */
76
78
  commits: string[];
77
- /**
78
- * The last commit before the head a Review read, of the last 100: a review success not posted under
79
- * off, or a read mark.
80
- */
81
- lastRead: LastRead | null;
82
- /** The read marks since the last review success of any kind, of the last 100 commits. */
83
- marks: ReadMarks;
84
- /** Whether the head carries the success of `lastRead`: the read decides it from two compares. */
85
- carried: boolean;
86
- /**
87
- * The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
88
- * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
89
- */
90
- changed: ChangedFile[] | "unsized" | null;
91
- /**
92
- * The files whose own change moved since `lastRead`, when a merge of the base came after it: the files
93
- * a fix touched, the base's own left out. Null with no such merge, or when a compare cannot show them.
94
- */
95
- scope: string[] | null;
96
79
  };
97
- /**
98
- * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
99
- * null when the compare shows no patch for it, as for a binary file.
100
- */
101
- export type ChangedFile = {
102
- path: string;
103
- previous: string | null;
104
- lines: number | null;
105
- };
106
- /**
107
- * A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
108
- * every commit after it, the head included, is a merge.
109
- */
110
- export type LastRead = {
111
- commit: string;
112
- clean: boolean;
113
- mergesOnly: boolean;
114
- merges: boolean;
115
- };
116
- /**
117
- * The Reviews that left a Finding open since the last review success, a success under off and the dev's
118
- * acceptance included: `count` their read marks, the head's own among them, and `since` that success's
119
- * commit, null when none of the last 100 holds one.
120
- */
121
- export type ReadMarks = {
122
- count: number;
123
- since: string | null;
124
- };
125
- /** A reply that is not the shape the query asks for; the message names the field. */
126
- export declare class ReplyError extends Error {
127
- }
128
- /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
129
- export type Node = {
130
- value: Record<string, unknown>;
131
- at: string;
132
- };
133
- export declare function shown(value: unknown): string;
134
- /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
135
- export declare function rootOf(reply: string, what: string): Node;
136
- export declare function fieldOf(node: Node, key: string): {
137
- value: unknown;
138
- at: string;
139
- };
140
- export declare function object(value: unknown, at: string): Node;
141
- export declare function string(node: Node, key: string): string;
142
- export declare function integer(node: Node, key: string): number;
80
+ /** The pull request and the Review's standing at its head. */
81
+ export type PrState = PullRequest & Standing;
143
82
  /** The viewer's own pull request without a review success on its head: the one a Review reads. */
144
83
  export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
145
84
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
@@ -149,6 +88,12 @@ export declare function parseView(reply: string): {
149
88
  };
150
89
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
151
90
  export declare function parseDefaultBranch(reply: string): string;
152
- /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
153
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "scope">;
91
+ /**
92
+ * The pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`, and the history the
93
+ * Review's standing at its head is read from.
94
+ */
95
+ export declare function parsePr(reply: string, repo: string): {
96
+ pr: PullRequest;
97
+ history: History;
98
+ };
154
99
  export {};
package/dist/pr/state.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
- // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
2
+ // state the decision reads, the review history the Review's standing is read from beside it. Every field
3
+ // the state needs is checked; a reply that fails names the field.
4
+ import { boolean, child, childOrNull, firstNode, hasMore, integer, integerOrNull, nodes, oneOf, ReplyError, rootOf, string, stringOrNull, time, timeOrNull } from "./reply.js";
3
5
  /** The commit status a clean review of the head leaves on it. */
4
6
  export const REVIEW = "dw-sdlc/review";
5
7
  /** How the description of a success the script posts under off begins: no one read that head. */
@@ -15,6 +17,12 @@ export const ACCEPTED = "accepted by the dev, findings open";
15
17
  /** How many checks and review threads the one read holds; each list's `pageInfo` says when more follow. */
16
18
  const CHECKS_READ = 50;
17
19
  const THREADS_READ = 100;
20
+ /**
21
+ * How many of the last force pushes the read holds, each with the head it left behind, and how many
22
+ * commits of that head's history.
23
+ */
24
+ const FORCE_PUSHES_READ = 20;
25
+ const LEFT_BEHIND_READ = 20;
18
26
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
19
27
  repository(owner: $owner, name: $name) {
20
28
  pullRequest(number: $number) {
@@ -28,126 +36,18 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
28
36
  commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
29
37
  contexts(first: ${CHECKS_READ}) { pageInfo { hasNextPage } nodes {
30
38
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
31
- ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
39
+ ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } }
40
+ status { context(name: "${REVIEW}") { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } }
32
41
  history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
42
+ rewritten: timelineItems(last: ${FORCE_PUSHES_READ}, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent {
43
+ beforeCommit { oid history(first: ${LEFT_BEHIND_READ}) { nodes { oid parents(first: 1) { nodes { oid } }
44
+ status { context(name: "${REVIEW}") { state description } } } } } } } }
33
45
  }
34
46
  }
35
47
  }`;
36
48
  const STATES = ["OPEN", "CLOSED", "MERGED"];
37
49
  const MERGEABLE = ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
38
50
  const REVIEW_DECISIONS = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
39
- /** A reply that is not the shape the query asks for; the message names the field. */
40
- export class ReplyError extends Error {
41
- }
42
- function isRecord(value) {
43
- return typeof value === "object" && value !== null && !Array.isArray(value);
44
- }
45
- export function shown(value) {
46
- return value === undefined ? "missing" : JSON.stringify(value);
47
- }
48
- /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
49
- export function rootOf(reply, what) {
50
- let json;
51
- try {
52
- json = JSON.parse(reply);
53
- }
54
- catch {
55
- throw new ReplyError(`${what} is not JSON`);
56
- }
57
- if (!isRecord(json)) {
58
- throw new ReplyError(`${what} is not a JSON object`);
59
- }
60
- return { value: json, at: "" };
61
- }
62
- export function fieldOf(node, key) {
63
- return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
64
- }
65
- export function object(value, at) {
66
- if (!isRecord(value)) {
67
- throw new ReplyError(`${at} is ${shown(value)}, not an object`);
68
- }
69
- return { value, at };
70
- }
71
- function child(node, key) {
72
- const { value, at } = fieldOf(node, key);
73
- return object(value, at);
74
- }
75
- function childOrNull(node, key) {
76
- const { value, at } = fieldOf(node, key);
77
- return value === null ? null : object(value, at);
78
- }
79
- export function string(node, key) {
80
- const { value, at } = fieldOf(node, key);
81
- if (typeof value !== "string") {
82
- throw new ReplyError(`${at} is ${shown(value)}, not a string`);
83
- }
84
- return value;
85
- }
86
- function stringOrNull(node, key) {
87
- return node.value[key] === null ? null : string(node, key);
88
- }
89
- function boolean(node, key) {
90
- const { value, at } = fieldOf(node, key);
91
- if (typeof value !== "boolean") {
92
- throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
93
- }
94
- return value;
95
- }
96
- export function integer(node, key) {
97
- const { value, at } = fieldOf(node, key);
98
- if (typeof value !== "number" || !Number.isInteger(value)) {
99
- throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
100
- }
101
- return value;
102
- }
103
- function integerOrNull(node, key) {
104
- const { value, at } = fieldOf(node, key);
105
- if (value !== null && !Number.isInteger(value)) {
106
- throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
107
- }
108
- return typeof value === "number" ? value : null;
109
- }
110
- /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
111
- function time(node, key) {
112
- const value = string(node, key);
113
- if (Number.isNaN(Date.parse(value))) {
114
- throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
115
- }
116
- return value;
117
- }
118
- function timeOrNull(node, key) {
119
- return node.value[key] === null ? null : time(node, key);
120
- }
121
- function isOneOf(values, value) {
122
- return values.some((known) => known === value);
123
- }
124
- function oneOf(node, key, values) {
125
- const { value, at } = fieldOf(node, key);
126
- if (!isOneOf(values, value)) {
127
- throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
128
- }
129
- return value;
130
- }
131
- /** The objects of a connection's `nodes`. */
132
- function nodes(node, key) {
133
- const { value, at } = fieldOf(child(node, key), "nodes");
134
- if (!Array.isArray(value)) {
135
- throw new ReplyError(`${at} is ${shown(value)}, not a list`);
136
- }
137
- return value.map((item, index) => object(item, `${at}[${index}]`));
138
- }
139
- /** Whether a connection has more nodes after the ones the reply holds. */
140
- function hasMore(node, key) {
141
- return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
142
- }
143
- /** The first object of a connection's `nodes`; an empty connection does not parse. */
144
- function firstNode(node, key) {
145
- const [first] = nodes(node, key);
146
- if (first === undefined) {
147
- throw new ReplyError(`${node.at}.${key}.nodes is empty`);
148
- }
149
- return first;
150
- }
151
51
  const FAIL = new Set(["ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
152
52
  const SKIPPING = new Set(["SKIPPED", "NEUTRAL"]);
153
53
  /** A check's bucket as `gh pr checks` sorts it: every word not passed, failed or skipped is pending. */
@@ -191,6 +91,23 @@ function checkOf(context, repo) {
191
91
  }
192
92
  throw new ReplyError(`${context.at} is neither a CheckRun nor a StatusContext`);
193
93
  }
94
+ /**
95
+ * The head's checks as the rollup lists them. With more past its first 50, the review status is read by
96
+ * its name too, so whether the head is reviewed never hangs on where the rollup lists it.
97
+ */
98
+ function checksOf(commit, repo) {
99
+ const rollup = childOrNull(commit, "statusCheckRollup");
100
+ if (rollup === null) {
101
+ return [];
102
+ }
103
+ const listed = nodes(rollup, "contexts").map((context) => checkOf(context, repo));
104
+ if (!hasMore(rollup, "contexts") || listed.some(({ name }) => name === REVIEW)) {
105
+ return listed;
106
+ }
107
+ const status = childOrNull(commit, "status");
108
+ const review = status === null ? null : childOrNull(status, "context");
109
+ return review === null ? listed : [...listed, checkOf(review, repo)];
110
+ }
194
111
  /** A thread by its opening comment; a deleted account's comment has no author and reads as GitHub's ghost. */
195
112
  function threadOf(thread) {
196
113
  const first = firstNode(thread, "comments");
@@ -237,31 +154,43 @@ function historyOf(pr) {
237
154
  });
238
155
  }
239
156
  /**
240
- * The last commit before `head` a Review read, the history oldest first. A read mark with only merges
241
- * after it is passed over: no fix answers its Findings yet, and a Review from it would read only the
242
- * base's changes, so the Review reads from the read before it and finds them again.
157
+ * The commits a force push left behind: the head, then down its first parents within the history the
158
+ * read holds of it. A merge's first parent is the branch, so the walk stays off the base's side.
243
159
  */
244
- function lastReadOf(history, head) {
245
- const commits = history.map(({ oid, posted, merge }) => ({
246
- oid,
247
- read: oid === head || posted === "off" ? null : posted,
248
- merge
249
- }));
250
- const mergesOnlyAfter = (index) => commits.slice(index + 1).every(({ merge }) => merge);
251
- const index = commits.findLastIndex(({ read }, at) => read === "clean" || (read === "findings" && !mergesOnlyAfter(at)));
252
- const last = commits[index];
253
- if (last === undefined) {
254
- return null;
160
+ function lineOf(left) {
161
+ const held = new Map(nodes(left, "history").map((commit) => [string(commit, "oid"), commit]));
162
+ const line = [];
163
+ let commit = held.get(string(left, "oid"));
164
+ while (commit !== undefined && line.length < held.size) {
165
+ line.push({ oid: string(commit, "oid"), posted: postedOn(commit) });
166
+ const [parent] = nodes(commit, "parents");
167
+ commit = parent === undefined ? undefined : held.get(string(parent, "oid"));
255
168
  }
256
- return {
257
- commit: last.oid,
258
- clean: last.read === "clean",
259
- mergesOnly: mergesOnlyAfter(index),
260
- merges: commits.slice(index + 1).some(({ merge }) => merge)
261
- };
169
+ return line;
262
170
  }
263
171
  /**
264
- * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
172
+ * The commits the force pushes left behind, newest push first, each once and none the history still
173
+ * holds: a line stops where it meets the history, as under an amend, or a newer push's line, and forks
174
+ * where the commit it meets does.
175
+ */
176
+ function leftBehindOf(pr, history) {
177
+ const forks = new Map(history.map(({ oid }, index) => [oid, index]));
178
+ const left = [];
179
+ for (const event of nodes(pr, "rewritten").toReversed()) {
180
+ const head = childOrNull(event, "beforeCommit");
181
+ const line = head === null ? [] : lineOf(head);
182
+ const met = line.findIndex(({ oid }) => forks.has(oid));
183
+ const meets = line[met];
184
+ const forksAt = meets === undefined ? -1 : (forks.get(meets.oid) ?? -1);
185
+ for (const commit of meets === undefined ? line : line.slice(0, met)) {
186
+ forks.set(commit.oid, forksAt);
187
+ left.push({ ...commit, forksAt });
188
+ }
189
+ }
190
+ return left;
191
+ }
192
+ /**
193
+ * Whether a non-merge commit follows `commit` in the history, oldest first, as the standing judges a read
265
194
  * mark. A commit outside the history, after a force-push or past the last 100, is not answered.
266
195
  */
267
196
  function answeredAfter(history, commit) {
@@ -281,11 +210,6 @@ function changeRequestsOf(pr, history) {
281
210
  };
282
211
  });
283
212
  }
284
- function readMarksOf(history) {
285
- const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
286
- const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
287
- return { count, since: history[success]?.oid ?? null };
288
- }
289
213
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
290
214
  export function parseView(reply) {
291
215
  const url = string(rootOf(reply, "the reply of gh pr view"), "url");
@@ -299,7 +223,10 @@ export function parseView(reply) {
299
223
  export function parseDefaultBranch(reply) {
300
224
  return string(child(rootOf(reply, "the reply of gh repo view"), "defaultBranchRef"), "name");
301
225
  }
302
- /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
226
+ /**
227
+ * The pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`, and the history the
228
+ * Review's standing at its head is read from.
229
+ */
303
230
  export function parsePr(reply, repo) {
304
231
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
305
232
  const commit = child(firstNode(pr, "commits"), "commit");
@@ -307,9 +234,9 @@ export function parsePr(reply, repo) {
307
234
  const mergeCommit = childOrNull(pr, "mergeCommit");
308
235
  const head = string(pr, "headRefOid");
309
236
  const baseRef = childOrNull(pr, "baseRef");
310
- const history = historyOf(pr);
237
+ const commits = historyOf(pr);
311
238
  const [ready] = nodes(pr, "ready");
312
- return {
239
+ const parsed = {
313
240
  url: string(pr, "url"),
314
241
  repo,
315
242
  number: integer(pr, "number"),
@@ -328,17 +255,17 @@ export function parsePr(reply, repo) {
328
255
  base: string(pr, "baseRefName"),
329
256
  baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
330
257
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
331
- checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
258
+ checks: checksOf(commit, repo),
332
259
  unresolvedThreads: nodes(pr, "reviewThreads")
333
260
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
334
261
  .map(threadOf),
335
- changeRequests: changeRequestsOf(pr, history),
262
+ changeRequests: changeRequestsOf(pr, commits),
336
263
  cutOff: [
337
264
  ...(rollup !== null && hasMore(rollup, "contexts") ? [`the first ${CHECKS_READ} checks`] : []),
338
265
  ...(hasMore(pr, "reviewThreads") ? [`the first ${THREADS_READ} threads`] : [])
339
266
  ],
340
- commits: history.map(({ oid }) => oid),
341
- lastRead: lastReadOf(history, head),
342
- marks: readMarksOf(history)
267
+ commits: commits.map(({ oid }) => oid)
343
268
  };
269
+ const history = { commits, leftBehind: leftBehindOf(pr, commits), head, base: parsed.baseOid };
270
+ return { pr: parsed, history };
344
271
  }
@@ -1,11 +1,6 @@
1
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
2
  /** 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;
3
+ export declare function holds(pr: Pick<PrState, "commits">, head: string | null): boolean;
9
4
  /** The checked-out branch at `root`, its head commit, and the other name its upstream gives. */
10
5
  export type RenamedUpstream = {
11
6
  branch: string;
@@ -1,12 +1,12 @@
1
1
  // A branch's upstream, read from git, for a branch pushed under another name: gh resolves
2
2
  // its pull request neither through `@{push}` nor by the local name, so `pr ship` and recap ask by the name
3
3
  // the upstream gives, and take that pull request only when its commits hold the local head.
4
- import { asked } from "../working-tree.js";
4
+ import { asked } from "../git.js";
5
5
  /**
6
6
  * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
7
7
  * after the remote deleted the branch.
8
8
  */
9
- export function upstreamName(root, branch) {
9
+ function upstreamName(root, branch) {
10
10
  const remote = asked(root, ["config", "--get", `branch.${branch}.remote`]);
11
11
  const merge = asked(root, ["config", "--get", `branch.${branch}.merge`]);
12
12
  if (remote === undefined || remote === "." || merge === undefined || !merge.startsWith("refs/heads/")) {
@@ -4,7 +4,7 @@
4
4
  import { resolveConfig } from "./agentic-config.js";
5
5
  import { parseCatalogue, primitivesNaming } from "./catalogue.js";
6
6
  import { catalogueFile } from "./checks/primitives.js";
7
- import { asked, git } from "./working-tree.js";
7
+ import { asked, git } from "./git.js";
8
8
  const CLASSES = ["removes", "adds", "extends"];
9
9
  const REMEDIES = {
10
10
  missing: "create it, or point gate.checks.primitives.file at the catalogue",