@reviewflow/sdk 0.0.0-stage → 6.21.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.
Files changed (50) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +86 -2
  3. package/build/files-node.mjs +70 -0
  4. package/build/git-node.mjs +1792 -0
  5. package/build/index-browser.es.js +3338 -0
  6. package/build/index-node.mjs +3338 -0
  7. package/package.json +136 -4
  8. package/src/api/pullRequest.ts +617 -0
  9. package/src/config/insertCheckoutEntry.ts +54 -0
  10. package/src/diff/diffPaths.ts +78 -0
  11. package/src/diff/impacts.ts +47 -0
  12. package/src/diff/readingOrder.ts +428 -0
  13. package/src/diff/themes.ts +105 -0
  14. package/src/diff/types.ts +141 -0
  15. package/src/files/editableFile.ts +163 -0
  16. package/src/files.ts +13 -0
  17. package/src/git/groupDiffPaths.ts +134 -0
  18. package/src/git/manifests.ts +96 -0
  19. package/src/git/moduleBindings.ts +571 -0
  20. package/src/git/pullRequestDiff.ts +159 -0
  21. package/src/git/readBranchDiff.ts +359 -0
  22. package/src/git/readImpacts.ts +797 -0
  23. package/src/git/readingOrder.ts +190 -0
  24. package/src/git/run.ts +213 -0
  25. package/src/git.ts +38 -0
  26. package/src/github/applySuggestions.ts +399 -0
  27. package/src/github/editPullRequest.ts +790 -0
  28. package/src/github/fetchCompareDiff.ts +21 -0
  29. package/src/github/imageProxies.ts +43 -0
  30. package/src/github/lineDiff.ts +145 -0
  31. package/src/github/prQuery.ts +298 -0
  32. package/src/github/pullRequestDetail.ts +1347 -0
  33. package/src/github/pullRequestHead.ts +65 -0
  34. package/src/github/pullRequestRevisions.ts +124 -0
  35. package/src/github/transport.ts +171 -0
  36. package/src/index.ts +35 -0
  37. package/src/reviewflow/board.ts +333 -0
  38. package/src/reviewflow/changedParts.ts +80 -0
  39. package/src/reviewflow/checksFromReviewflow.ts +75 -0
  40. package/src/reviewflow/client.ts +589 -0
  41. package/src/reviewflow/follow.ts +345 -0
  42. package/src/reviewflow/status.ts +17 -0
  43. package/src/reviewflow/stream.ts +199 -0
  44. package/src/types/entities.ts +8 -0
  45. package/src/types/ids.ts +1 -0
  46. package/src/types/reviewflow.ts +32 -0
  47. package/src/utils/pullRequestEdit.ts +108 -0
  48. package/src/utils/pullRequestPart.ts +12 -0
  49. package/src/utils/pullRequestReaction.ts +19 -0
  50. package/src/utils/pullRequestSuggestion.ts +148 -0
@@ -0,0 +1,21 @@
1
+ import type { GitHubTransport } from "./transport.ts";
2
+
3
+ /**
4
+ * The compare endpoint rather than the pull request's own diff: it takes any
5
+ * range, so a single commit or "since my last review" reads like the whole
6
+ * pull request. Three dots, from the merge base, as a local `git diff` does.
7
+ * GitHub refuses it past 300 files or 20,000 lines.
8
+ */
9
+ export const fetchCompareDiff = (
10
+ transport: GitHubTransport,
11
+ { ownerRepo, base, head }: { ownerRepo: string; base: string; head: string },
12
+ ): Promise<string> => {
13
+ const [owner, name, ...rest] = ownerRepo.split("/");
14
+ if (owner === undefined || name === undefined || rest.length > 0) {
15
+ throw new Error(`${ownerRepo} is not owner/repo`);
16
+ }
17
+ return transport.text({
18
+ path: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${base}...${head}`,
19
+ accept: "application/vnd.github.diff",
20
+ });
21
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * GitHub serves the images of a body through camo, its image proxy, so the
3
+ * host of an image a stranger put in a pull request never learns who read it
4
+ * nor when. A camo url is signed with GitHub's key: it cannot be built, only
5
+ * read from the html GitHub renders (`bodyHTML`), where each proxied `<img>`
6
+ * keeps the url the markdown wrote in `data-canonical-src`.
7
+ */
8
+
9
+ const imgTag = /<img\b[^>]*>/gi;
10
+
11
+ const decodeAttribute = (value: string): string =>
12
+ value
13
+ .replaceAll("&quot;", '"')
14
+ .replaceAll("&#39;", "'")
15
+ .replaceAll("&lt;", "<")
16
+ .replaceAll("&gt;", ">")
17
+ .replaceAll("&amp;", "&");
18
+
19
+ const attribute = (tag: string, name: string): string | null => {
20
+ const match = new RegExp(`\\s${name}="([^"]*)"`, "i").exec(tag);
21
+ return match?.[1] === undefined ? null : decodeAttribute(match[1]);
22
+ };
23
+
24
+ /** The url each image of `html` was written with, to the one GitHub serves it from. */
25
+ export const imageProxiesOf = (
26
+ html: string | null | undefined,
27
+ ): Record<string, string> => {
28
+ const proxies: Record<string, string> = {};
29
+ for (const [tag] of (html ?? "").matchAll(imgTag)) {
30
+ const src = attribute(tag, "src");
31
+ const canonical = attribute(tag, "data-canonical-src");
32
+ if (src !== null && canonical !== null && src !== canonical) {
33
+ proxies[canonical] = src;
34
+ }
35
+ }
36
+ return proxies;
37
+ };
38
+
39
+ /** Every body's proxies of one part, merged. */
40
+ export const mergeImageProxies = (
41
+ htmls: readonly (string | null | undefined)[],
42
+ ): Record<string, string> =>
43
+ Object.assign({}, ...htmls.map(imageProxiesOf)) as Record<string, string>;
@@ -0,0 +1,145 @@
1
+ export type DiffLineKind = "added" | "context" | "removed";
2
+
3
+ export interface DiffLine {
4
+ kind: DiffLineKind;
5
+ text: string;
6
+ }
7
+
8
+ /**
9
+ * Past this many cells the table is not built at all and the two sides are
10
+ * emitted whole — every old line removed, then every new line added. Still a
11
+ * correct diff, only not a minimal one, and 400×400 is well past anything an
12
+ * `Edit` has measured (153 lines is the largest) once the common prefix and
13
+ * suffix are off. The cap is in cells rather than lines because that is what
14
+ * the allocation is: a 1200-line cap on each side would allow a 5.7MB transient
15
+ * array, several times per page.
16
+ */
17
+ const maxDiffCells = 160_000;
18
+
19
+ /** A file that ends in a newline is not a file with a blank last line. */
20
+ const toLines = (value: string): string[] => {
21
+ if (value === "") return [];
22
+ const lines = value.split("\n");
23
+ if (lines.at(-1) === "") lines.pop();
24
+ return lines;
25
+ };
26
+
27
+ const context = (text: string): DiffLine => ({ kind: "context", text });
28
+ const removed = (text: string): DiffLine => ({ kind: "removed", text });
29
+ const added = (text: string): DiffLine => ({ kind: "added", text });
30
+
31
+ const wholeSides = (before: string[], after: string[]): DiffLine[] => [
32
+ ...before.map(removed),
33
+ ...after.map(added),
34
+ ];
35
+
36
+ /**
37
+ * The shortest edit script between two runs that share neither their first nor
38
+ * their last line, by the longest common subsequence of them.
39
+ *
40
+ * At a position where a removal and an addition both apply, the removal is
41
+ * emitted first — git's order, and the one that lets the two be read as a
42
+ * replacement of each other.
43
+ */
44
+ const shortestEdit = (before: string[], after: string[]): DiffLine[] => {
45
+ const height = before.length;
46
+ const width = after.length + 1;
47
+ const common = new Uint32Array((height + 1) * width);
48
+
49
+ for (let row = height - 1; row >= 0; row--) {
50
+ const beforeLine = before[row];
51
+ for (let column = after.length - 1; column >= 0; column--) {
52
+ common[row * width + column] =
53
+ beforeLine === after[column]
54
+ ? (common[(row + 1) * width + column + 1] ?? 0) + 1
55
+ : Math.max(
56
+ common[(row + 1) * width + column] ?? 0,
57
+ common[row * width + column + 1] ?? 0,
58
+ );
59
+ }
60
+ }
61
+
62
+ const lines: DiffLine[] = [];
63
+ let row = 0;
64
+ let column = 0;
65
+
66
+ while (row < height && column < after.length) {
67
+ if (before[row] === after[column]) {
68
+ lines.push(context(before[row] ?? ""));
69
+ row++;
70
+ column++;
71
+ continue;
72
+ }
73
+ if (
74
+ (common[(row + 1) * width + column] ?? 0) >=
75
+ (common[row * width + column + 1] ?? 0)
76
+ ) {
77
+ lines.push(removed(before[row] ?? ""));
78
+ row++;
79
+ continue;
80
+ }
81
+ lines.push(added(after[column] ?? ""));
82
+ column++;
83
+ }
84
+
85
+ return [
86
+ ...lines,
87
+ ...before.slice(row).map(removed),
88
+ ...after.slice(column).map(added),
89
+ ];
90
+ };
91
+
92
+ /**
93
+ * The two sides of a change, line by line.
94
+ *
95
+ * **The common prefix and suffix come off first**, and that is the guard that
96
+ * does the real work rather than the cell cap below it: Claude Code's
97
+ * `old_string` and `new_string` are a fragment and its replacement, and a
98
+ * fragment is chosen to be unique, which means it almost always opens and
99
+ * closes on lines the replacement keeps. Trimming them is O(n+m) and allocates
100
+ * nothing, and it collapses nearly every measured edit to a handful of lines
101
+ * before the table is sized.
102
+ *
103
+ * A whole new file is `before: ""`, which trims to nothing and comes back as
104
+ * every line added.
105
+ */
106
+ export const diffLines = ({
107
+ before,
108
+ after,
109
+ }: {
110
+ before: string;
111
+ after: string;
112
+ }): DiffLine[] => {
113
+ const beforeLines = toLines(before);
114
+ const afterLines = toLines(after);
115
+
116
+ let head = 0;
117
+ while (
118
+ head < beforeLines.length &&
119
+ head < afterLines.length &&
120
+ beforeLines[head] === afterLines[head]
121
+ ) {
122
+ head++;
123
+ }
124
+
125
+ let tail = 0;
126
+ while (
127
+ tail < beforeLines.length - head &&
128
+ tail < afterLines.length - head &&
129
+ beforeLines[beforeLines.length - 1 - tail] ===
130
+ afterLines[afterLines.length - 1 - tail]
131
+ ) {
132
+ tail++;
133
+ }
134
+
135
+ const beforeMiddle = beforeLines.slice(head, beforeLines.length - tail);
136
+ const afterMiddle = afterLines.slice(head, afterLines.length - tail);
137
+
138
+ return [
139
+ ...beforeLines.slice(0, head).map(context),
140
+ ...(beforeMiddle.length * afterMiddle.length > maxDiffCells
141
+ ? wholeSides(beforeMiddle, afterMiddle)
142
+ : shortestEdit(beforeMiddle, afterMiddle)),
143
+ ...beforeLines.slice(beforeLines.length - tail).map(context),
144
+ ];
145
+ };
@@ -0,0 +1,298 @@
1
+ import type {
2
+ PrMergeable,
3
+ PrReviewDecision,
4
+ PrState,
5
+ } from "../types/entities.ts";
6
+
7
+ /**
8
+ * One branch to ask GitHub about — a task's current branch, which is the only
9
+ * branch a PR is wanted for (a task has N branches over time and only the
10
+ * latest one is live).
11
+ */
12
+ export interface PrQueryTarget {
13
+ branchId: string;
14
+ taskId: string;
15
+ /** `owner/repo`, from `repo.owner_repo` */
16
+ ownerRepo: string;
17
+ branch: string;
18
+ }
19
+
20
+ export interface PrQuery {
21
+ query: string;
22
+ /** alias -> the branch it asks about; how an answer is matched back */
23
+ targetsByAlias: Map<string, PrQueryTarget>;
24
+ }
25
+
26
+ /**
27
+ * Every branch is one aliased field in one document, so the whole board costs a
28
+ * single HTTP request and — because every connection is asked for `first: 1` —
29
+ * a single point of the GraphQL rate limit. A query per PR would be one request
30
+ * and one point *each*, which is the thing this exists to avoid.
31
+ *
32
+ * The counts come off `checkRunCountsByState` / `statusContextCountsByState`
33
+ * rather than off the context nodes: those are aggregates GitHub computes, so
34
+ * `contexts(first: 1)` returns exact totals for a PR with two hundred checks
35
+ * without transferring or paying for them.
36
+ */
37
+ export const prFields = `fragment prFields on PullRequest {
38
+ number
39
+ state
40
+ isDraft
41
+ reviewDecision
42
+ mergedAt
43
+ mergeable
44
+ headRefOid
45
+ baseRefName
46
+ commits(last: 1) {
47
+ nodes {
48
+ commit {
49
+ statusCheckRollup {
50
+ contexts(first: 1) {
51
+ checkRunCountsByState { count state }
52
+ statusContextCountsByState { count state }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }`;
59
+
60
+ /**
61
+ * `owner/repo` exactly, because that is what the GraphQL arguments are. A value
62
+ * that does not split into two segments is skipped rather than guessed at: it
63
+ * would resolve to a repository that is not the operator's, and registration
64
+ * already refuses a repo with no usable remote.
65
+ */
66
+ export const splitOwnerRepo = (
67
+ ownerRepo: string,
68
+ ): { owner: string; name: string } | null => {
69
+ const segments = ownerRepo.split("/");
70
+ const [owner, name] = segments;
71
+ if (segments.length !== 2 || !owner || !name) return null;
72
+ return { owner, name };
73
+ };
74
+
75
+ /** Returns null when no target is queryable, so no request is made at all. */
76
+ export const buildPrQuery = (targets: PrQueryTarget[]): PrQuery | null => {
77
+ const byRepo = new Map<string, { alias: string; branch: string }[]>();
78
+ const targetsByAlias = new Map<string, PrQueryTarget>();
79
+
80
+ targets.forEach((target, index) => {
81
+ if (target.branch === "" || splitOwnerRepo(target.ownerRepo) === null) {
82
+ return;
83
+ }
84
+ const alias = `t${String(index)}`;
85
+ targetsByAlias.set(alias, target);
86
+ const existing = byRepo.get(target.ownerRepo);
87
+ const entry = { alias, branch: target.branch };
88
+ if (existing) existing.push(entry);
89
+ else byRepo.set(target.ownerRepo, [entry]);
90
+ });
91
+
92
+ if (targetsByAlias.size === 0) return null;
93
+
94
+ const repoBlocks = [...byRepo].map(([ownerRepo, branches], repoIndex) => {
95
+ const { owner, name } = splitOwnerRepo(ownerRepo) as {
96
+ owner: string;
97
+ name: string;
98
+ };
99
+ const branchFields = branches.map(
100
+ ({ alias, branch }) =>
101
+ // Newest first: a branch that had a closed PR and then a new one is
102
+ // described by the new one, and `first: 1` is what keeps the cost flat.
103
+ ` ${alias}: pullRequests(headRefName: ${JSON.stringify(branch)}, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ...prFields } }`,
104
+ );
105
+ return [
106
+ ` r${String(repoIndex)}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {`,
107
+ ...branchFields,
108
+ " }",
109
+ ].join("\n");
110
+ });
111
+
112
+ return {
113
+ query: `query {\n${repoBlocks.join("\n")}\n}\n\n${prFields}`,
114
+ targetsByAlias,
115
+ };
116
+ };
117
+
118
+ interface GhCountByState {
119
+ count: number;
120
+ state: string;
121
+ }
122
+
123
+ export interface GhPullRequest {
124
+ number: number;
125
+ state: string;
126
+ isDraft: boolean;
127
+ reviewDecision: string | null;
128
+ mergedAt: string | null;
129
+ mergeable: string;
130
+ headRefOid: string;
131
+ baseRefName: string;
132
+ commits: {
133
+ nodes: {
134
+ commit: {
135
+ statusCheckRollup: {
136
+ contexts: {
137
+ checkRunCountsByState: GhCountByState[] | null;
138
+ statusContextCountsByState: GhCountByState[] | null;
139
+ };
140
+ } | null;
141
+ };
142
+ }[];
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Repository aliases at the top level, branch aliases inside each. A repository
148
+ * the token cannot see comes back as a null alias next to the ones that
149
+ * resolved, which is why this reads defensively rather than asserting a shape.
150
+ */
151
+ export type PrQueryData = Record<
152
+ string,
153
+ Record<string, { nodes: (GhPullRequest | null)[] | null } | null> | null
154
+ >;
155
+
156
+ /**
157
+ * How GitHub's rollup states fold into the three counts `pr` keeps.
158
+ *
159
+ * A whitelist, like the hook mapper: a state GitHub adds later is ignored
160
+ * rather than counted as something, because the one thing worse than an
161
+ * unranked board is a board that calls a red PR green.
162
+ *
163
+ * `CANCELLED` and `STALE` count as pending rather than failed on purpose. They
164
+ * mean "no answer for this commit" — a superseded run after a force-push is the
165
+ * common cause — and calling that a failure would raise `checksFailing` on a
166
+ * PR nobody needs to look at, which is how an attention signal stops being read.
167
+ */
168
+ export const checkOutcomes = new Map<string, "failed" | "passed" | "pending">([
169
+ ["ACTION_REQUIRED", "failed"],
170
+ ["ERROR", "failed"],
171
+ ["FAILURE", "failed"],
172
+ ["STARTUP_FAILURE", "failed"],
173
+ ["TIMED_OUT", "failed"],
174
+
175
+ ["COMPLETED", "passed"],
176
+ ["NEUTRAL", "passed"],
177
+ ["SKIPPED", "passed"],
178
+ ["SUCCESS", "passed"],
179
+
180
+ ["CANCELLED", "pending"],
181
+ ["EXPECTED", "pending"],
182
+ ["IN_PROGRESS", "pending"],
183
+ ["PENDING", "pending"],
184
+ ["QUEUED", "pending"],
185
+ ["STALE", "pending"],
186
+ ["WAITING", "pending"],
187
+ ]);
188
+
189
+ interface CheckCounts {
190
+ checksPassed: number;
191
+ checksFailed: number;
192
+ checksPending: number;
193
+ }
194
+
195
+ const countChecks = (pullRequest: GhPullRequest): CheckCounts => {
196
+ const totals = { failed: 0, passed: 0, pending: 0 };
197
+ const contexts =
198
+ pullRequest.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
199
+
200
+ for (const { count, state } of [
201
+ ...(contexts?.checkRunCountsByState ?? []),
202
+ ...(contexts?.statusContextCountsByState ?? []),
203
+ ]) {
204
+ const outcome = checkOutcomes.get(state);
205
+ if (outcome !== undefined) totals[outcome] += count;
206
+ }
207
+
208
+ return {
209
+ checksPassed: totals.passed,
210
+ checksFailed: totals.failed,
211
+ checksPending: totals.pending,
212
+ };
213
+ };
214
+
215
+ /** `isDraft` is a separate field upstream; `pr.state` folds it in. */
216
+ const prStates = new Map<string, PrState>([
217
+ ["CLOSED", "closed"],
218
+ ["MERGED", "merged"],
219
+ ]);
220
+
221
+ const toPrState = (state: string, isDraft: boolean): PrState | null => {
222
+ if (state === "OPEN") return isDraft ? "draft" : "open";
223
+ return prStates.get(state) ?? null;
224
+ };
225
+
226
+ const reviewDecisions = new Map<string, PrReviewDecision>([
227
+ ["APPROVED", "approved"],
228
+ ["CHANGES_REQUESTED", "changesRequested"],
229
+ ["REVIEW_REQUIRED", "reviewRequired"],
230
+ ]);
231
+
232
+ const mergeables = new Map<string, PrMergeable>([
233
+ ["CONFLICTING", "conflicting"],
234
+ ["MERGEABLE", "mergeable"],
235
+ ]);
236
+
237
+ export interface FetchedPr extends CheckCounts {
238
+ target: PrQueryTarget;
239
+ number: number;
240
+ state: PrState;
241
+ reviewDecision: PrReviewDecision;
242
+ mergeable: PrMergeable;
243
+ mergedAt: string | null;
244
+ /** always set by GitHub; null only from a reviewflow document that lacks it */
245
+ headSha: string | null;
246
+ /** null only from a reviewflow server that predates `baseRef` */
247
+ baseRef: string | null;
248
+ }
249
+
250
+ /** Null for a state GitHub added after this was written. */
251
+ export const toFetchedPr = (
252
+ target: PrQueryTarget,
253
+ pullRequest: GhPullRequest,
254
+ ): FetchedPr | null => {
255
+ const state = toPrState(pullRequest.state, pullRequest.isDraft);
256
+ if (state === null) return null;
257
+ return {
258
+ target,
259
+ number: pullRequest.number,
260
+ state,
261
+ reviewDecision:
262
+ pullRequest.reviewDecision === null
263
+ ? null
264
+ : (reviewDecisions.get(pullRequest.reviewDecision) ?? null),
265
+ mergeable: mergeables.get(pullRequest.mergeable) ?? "unknown",
266
+ mergedAt: pullRequest.mergedAt,
267
+ headSha: pullRequest.headRefOid,
268
+ baseRef: pullRequest.baseRefName,
269
+ ...countChecks(pullRequest),
270
+ };
271
+ };
272
+
273
+ /**
274
+ * Only the aliases that both resolved and carry a pull request produce a row. A
275
+ * branch with no PR is not an absence to record — it is the normal state of a
276
+ * worktree whose work has not been pushed for review yet.
277
+ */
278
+ export const parsePrQueryResponse = (
279
+ data: PrQueryData | null,
280
+ targetsByAlias: Map<string, PrQueryTarget>,
281
+ ): FetchedPr[] => {
282
+ if (data === null) return [];
283
+ const found: FetchedPr[] = [];
284
+
285
+ for (const repository of Object.values(data)) {
286
+ if (!repository) continue;
287
+ for (const [alias, connection] of Object.entries(repository)) {
288
+ const target = targetsByAlias.get(alias);
289
+ const pullRequest = connection?.nodes?.[0];
290
+ if (!target || !pullRequest) continue;
291
+
292
+ const fetched = toFetchedPr(target, pullRequest);
293
+ if (fetched !== null) found.push(fetched);
294
+ }
295
+ }
296
+
297
+ return found;
298
+ };