@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,333 @@
1
+ import type { PullRequestRef } from "../api/pullRequest.ts";
2
+ import type { PrState } from "../types/entities.ts";
3
+ import type {
4
+ ReviewflowChecksConclusion,
5
+ ReviewflowMergeable,
6
+ ReviewflowPrChanges,
7
+ ReviewflowReviewState,
8
+ ReviewflowStatusLink,
9
+ } from "../types/reviewflow.ts";
10
+ import type {
11
+ ReviewflowClient,
12
+ ReviewflowGroup,
13
+ ReviewflowPrDetail,
14
+ } from "./client.ts";
15
+ import type { ReviewflowStreamEvent } from "./stream.ts";
16
+
17
+ /**
18
+ * The pull requests board, assembled from what the reviewflow client answers.
19
+ * Pure functions over plain data: a caller keeps the snapshot wherever it
20
+ * likes (memory, a database) and turns it into the answer with `assembleBoard`.
21
+ */
22
+
23
+ /**
24
+ * One pull request of the board, as reviewflow computed it. `null` is
25
+ * reviewflow not knowing, shown as unknown, never guessed.
26
+ */
27
+ export interface ReviewflowBoardPr {
28
+ /** reviewflow's id */
29
+ id: string;
30
+ ownerRepo: string;
31
+ number: number;
32
+ title: string;
33
+ url: string;
34
+ headRef: string | null;
35
+ state: PrState | null;
36
+ reviewState: ReviewflowReviewState | null;
37
+ author: string | null;
38
+ checksFailed: number;
39
+ checksConclusion: ReviewflowChecksConclusion;
40
+ checksRunning: number;
41
+ /** after reviewflow's allowed-to-fail rules, so possibly fewer than `checksFailed` */
42
+ failedChecks: string[];
43
+ lintFailed: boolean;
44
+ approvedBy: string[];
45
+ changesRequestedBy: string[];
46
+ /** asked for the first time */
47
+ requestedReviewers: string[];
48
+ /** asked again after having reviewed */
49
+ reRequestedReviewers: string[];
50
+ requestedTeams: string[];
51
+ assignees: string[];
52
+ statusLinks: ReviewflowStatusLink[];
53
+ changes: ReviewflowPrChanges | null;
54
+ openedAt: string | null;
55
+ approvedAt: string | null;
56
+ unresolvedThreads: number | null;
57
+ mergeable: ReviewflowMergeable;
58
+ /** reviewflow's last write */
59
+ updatedAt: string;
60
+ }
61
+
62
+ /**
63
+ * Keys and titles are reviewflow's, kept as strings: a bucket it adds later is
64
+ * rendered without a release.
65
+ */
66
+ export interface ReviewflowBoardBucket {
67
+ key: string;
68
+ title: string;
69
+ /** the bucket's size in reviewflow, accounts added up; `prs` stops at the pages read */
70
+ total: number;
71
+ /** in the bucket's own order */
72
+ prs: ReviewflowBoardPr[];
73
+ }
74
+
75
+ export interface ReviewflowBoardGroup {
76
+ key: string;
77
+ title: string;
78
+ buckets: ReviewflowBoardBucket[];
79
+ }
80
+
81
+ /**
82
+ * `GET /api/reviewflow/board`, and the data of a `board` event: the boards of
83
+ * every account read, bucket by bucket with their totals added up; `board` is
84
+ * null before any account was read.
85
+ */
86
+ export interface ReviewflowBoardResponse {
87
+ board: { fetchedAt: string; groups: ReviewflowBoardGroup[] } | null;
88
+ /** the token's GitHub login, named "you" on the board */
89
+ viewer: string | null;
90
+ }
91
+
92
+ export const toBoardPr = (pr: ReviewflowPrDetail): ReviewflowBoardPr => ({
93
+ id: pr.id,
94
+ ownerRepo: pr.ownerRepo,
95
+ number: pr.number,
96
+ title: pr.title,
97
+ url: pr.url,
98
+ headRef: pr.headRef,
99
+ state: pr.state,
100
+ reviewState: pr.reviewState,
101
+ author: pr.author,
102
+ checksFailed: pr.checks.failed,
103
+ checksConclusion: pr.checks.conclusion,
104
+ checksRunning: pr.checks.running,
105
+ failedChecks: pr.checks.failedNames,
106
+ lintFailed: pr.lintFailed,
107
+ approvedBy: pr.approvedBy,
108
+ changesRequestedBy: pr.changesRequestedBy,
109
+ requestedReviewers: pr.requestedReviewers,
110
+ reRequestedReviewers: pr.reRequestedReviewers,
111
+ requestedTeams: pr.requestedTeams,
112
+ assignees: pr.assignees,
113
+ statusLinks: pr.statusLinks,
114
+ changes: pr.changes,
115
+ openedAt: pr.openedAt,
116
+ approvedAt: pr.approvedAt,
117
+ unresolvedThreads: pr.unresolvedThreads,
118
+ mergeable: pr.mergeable,
119
+ updatedAt: pr.updatedAt,
120
+ });
121
+
122
+ export interface AccountBoard {
123
+ groups: ReviewflowGroup[];
124
+ /** ISO date of the read */
125
+ fetchedAt: string;
126
+ }
127
+
128
+ /** each account's last read, keyed by the account as the caller spells it */
129
+ export type ReviewflowBoardSnapshot = Record<string, AccountBoard>;
130
+
131
+ /** the account (owner) a pull request belongs to, lowercased */
132
+ export const accountOfOwnerRepo = (ownerRepo: string): string =>
133
+ ownerRepo.slice(0, ownerRepo.indexOf("/")).toLowerCase();
134
+
135
+ export const isPullRequestOnBoard = (
136
+ board: ReviewflowBoardResponse["board"],
137
+ { ownerRepo, number }: PullRequestRef,
138
+ ): boolean => {
139
+ const wanted = ownerRepo.toLowerCase();
140
+ return (
141
+ board?.groups.some((group) =>
142
+ group.buckets.some((bucket) =>
143
+ bucket.prs.some(
144
+ (pr) => pr.number === number && pr.ownerRepo.toLowerCase() === wanted,
145
+ ),
146
+ ),
147
+ ) ?? false
148
+ );
149
+ };
150
+
151
+ /** Replaces one account's read; the other accounts' are left as they are. */
152
+ export const withAccountBoard = (
153
+ snapshot: ReviewflowBoardSnapshot,
154
+ account: string,
155
+ board: AccountBoard,
156
+ ): ReviewflowBoardSnapshot => ({ ...snapshot, [account]: board });
157
+
158
+ export interface AccountBoardRead {
159
+ boards: { account: string; groups: ReviewflowGroup[] }[];
160
+ /** an account that failed alone: the others still count */
161
+ errors: { account: string; message: string }[];
162
+ }
163
+
164
+ /**
165
+ * Reads each account's board (the client follows the extra pages past the
166
+ * first 100 of a bucket). One account the token cannot read fails alone.
167
+ */
168
+ export const readAccountBoards = async (
169
+ client: Pick<ReviewflowClient, "board">,
170
+ accounts: string[],
171
+ ): Promise<AccountBoardRead> => {
172
+ const settled = await Promise.allSettled(
173
+ accounts.map(async (account) => ({
174
+ account,
175
+ groups: await client.board(account),
176
+ })),
177
+ );
178
+ const read: AccountBoardRead = { boards: [], errors: [] };
179
+ settled.forEach((result, index) => {
180
+ if (result.status === "fulfilled") read.boards.push(result.value);
181
+ else {
182
+ const { reason } = result;
183
+ read.errors.push({
184
+ account: accounts[index]!,
185
+ message: reason instanceof Error ? reason.message : String(reason),
186
+ });
187
+ }
188
+ });
189
+ return read;
190
+ };
191
+
192
+ interface Flat {
193
+ key: string;
194
+ group: { key: string; title: string };
195
+ title: string;
196
+ total: number;
197
+ position: number;
198
+ prs: ReviewflowBoardPr[];
199
+ fetchedAt: string;
200
+ }
201
+
202
+ /**
203
+ * One bucket per key across the accounts, totals added up: the board is its
204
+ * accounts' boards side by side. `fetchedAt` is the oldest read, the one the
205
+ * age shown has to answer for. Null when no account was read.
206
+ */
207
+ export const assembleBoard = (
208
+ snapshot: ReviewflowBoardSnapshot,
209
+ viewer: string | null,
210
+ accounts: string[] = Object.keys(snapshot),
211
+ ): ReviewflowBoardResponse => {
212
+ const merged = new Map<string, Flat>();
213
+ const fetchedAts: string[] = [];
214
+ for (const account of accounts) {
215
+ const read = snapshot[account];
216
+ if (read === undefined) continue;
217
+ fetchedAts.push(read.fetchedAt);
218
+ let position = 0;
219
+ for (const group of read.groups) {
220
+ for (const bucket of group.buckets) {
221
+ const existing = merged.get(bucket.key);
222
+ const prs = bucket.prs.map((pr) => toBoardPr(pr));
223
+ if (existing === undefined) {
224
+ merged.set(bucket.key, {
225
+ key: bucket.key,
226
+ group: { key: group.key, title: group.title },
227
+ title: bucket.title,
228
+ total: bucket.total,
229
+ position,
230
+ prs,
231
+ fetchedAt: read.fetchedAt,
232
+ });
233
+ } else {
234
+ existing.total += bucket.total;
235
+ existing.position = Math.min(existing.position, position);
236
+ const seen = new Set(existing.prs.map((pr) => pr.id));
237
+ existing.prs.push(...prs.filter((pr) => !seen.has(pr.id)));
238
+ }
239
+ position++;
240
+ }
241
+ }
242
+ }
243
+ if (merged.size === 0) return { board: null, viewer };
244
+
245
+ const groups: ReviewflowBoardGroup[] = [];
246
+ for (const bucket of [...merged.values()].toSorted(
247
+ (a, b) => a.position - b.position,
248
+ )) {
249
+ const entry: ReviewflowBoardBucket = {
250
+ key: bucket.key,
251
+ title: bucket.title,
252
+ total: bucket.total,
253
+ prs: bucket.prs,
254
+ };
255
+ const last = groups.at(-1);
256
+ if (last?.key === bucket.group.key) last.buckets.push(entry);
257
+ else {
258
+ groups.push({
259
+ key: bucket.group.key,
260
+ title: bucket.group.title,
261
+ buckets: [entry],
262
+ });
263
+ }
264
+ }
265
+ const [oldest] = fetchedAts.toSorted((a, b) => a.localeCompare(b));
266
+ return { board: { fetchedAt: oldest ?? "", groups }, viewer };
267
+ };
268
+
269
+ export interface AppliedStreamEvent {
270
+ snapshot: ReviewflowBoardSnapshot;
271
+ /** a row of the snapshot was replaced or removed */
272
+ changed: boolean;
273
+ /**
274
+ * The accounts to read again. The board is not rebuilt from events: its
275
+ * buckets are sorted on fields the stream does not carry, so a pull request
276
+ * that may have changed bucket, entered the scope or left it is the signal to
277
+ * read the account again.
278
+ */
279
+ readAccounts: string[];
280
+ }
281
+
282
+ /**
283
+ * Applies a `pr` or `prRemoved` stream event to the snapshot: a row already on
284
+ * the board is replaced in place (or dropped, its bucket's total one lower),
285
+ * and the account is flagged for a fresh read. Any other event changes nothing.
286
+ */
287
+ export const applyStreamEvent = (
288
+ snapshot: ReviewflowBoardSnapshot,
289
+ event: ReviewflowStreamEvent,
290
+ ): AppliedStreamEvent => {
291
+ if (event.type !== "pr" && event.type !== "prRemoved") {
292
+ return { snapshot, changed: false, readAccounts: [] };
293
+ }
294
+ const id = event.type === "pr" ? event.pr.id : event.id;
295
+ let changed = false;
296
+ const holding = new Set<string>();
297
+ const next: ReviewflowBoardSnapshot = {};
298
+ for (const [account, read] of Object.entries(snapshot)) {
299
+ // oxlint-disable-next-line eslint/no-loop-func
300
+ const groups = read.groups.map((group) => ({
301
+ ...group,
302
+ buckets: group.buckets.map((bucket) => {
303
+ if (!bucket.prs.some((pr) => pr.id === id)) return bucket;
304
+ holding.add(account);
305
+ changed = true;
306
+ if (event.type === "pr") {
307
+ return {
308
+ ...bucket,
309
+ prs: bucket.prs.map((pr) => (pr.id === id ? event.pr : pr)),
310
+ };
311
+ }
312
+ return {
313
+ ...bucket,
314
+ total: Math.max(0, bucket.total - 1),
315
+ prs: bucket.prs.filter((pr) => pr.id !== id),
316
+ };
317
+ }),
318
+ }));
319
+ next[account] = { ...read, groups };
320
+ }
321
+ const readAccounts = new Set(holding);
322
+ if (event.type === "pr") {
323
+ const owner = accountOfOwnerRepo(event.pr.ownerRepo);
324
+ for (const account of Object.keys(snapshot)) {
325
+ if (account.toLowerCase() === owner) readAccounts.add(account);
326
+ }
327
+ }
328
+ return {
329
+ snapshot: changed ? next : snapshot,
330
+ changed,
331
+ readAccounts: [...readAccounts],
332
+ };
333
+ };
@@ -0,0 +1,80 @@
1
+ import type { PullRequestPart } from "../api/pullRequest.ts";
2
+ import { pullRequestParts } from "../utils/pullRequestPart.ts";
3
+ import type { ReviewflowPrDetail } from "./client.ts";
4
+
5
+ /**
6
+ * What each part of the pull request screen shows, as far as reviewflow knows
7
+ * it. Two reads of a part are the same while its fingerprint is: a push moves
8
+ * every part (each names the head), the checks move the checks and only their
9
+ * conclusion moves the header's merge state, a comment moves the timeline
10
+ * alone, an edited description the header alone. The last open conversation
11
+ * being resolved, or a first one opened, moves the merge state too: a branch
12
+ * protection may require every conversation resolved, while a count going
13
+ * from 2 to 1 changes nothing there and stays the timeline's.
14
+ *
15
+ * What reviewflow does not carry is in no fingerprint and moves nothing: a
16
+ * reaction, the viewed marks, the base branch moving under an unchanged pull
17
+ * request ("behind"), the viewer's rights. A read the app makes after some time,
18
+ * or an explicit refresh, reads those.
19
+ */
20
+ const partFields: Record<PullRequestPart, (pr: ReviewflowPrDetail) => unknown> =
21
+ {
22
+ detail: (pr) => [
23
+ pr.title,
24
+ pr.headRef,
25
+ pr.baseRef,
26
+ pr.headSha,
27
+ pr.baseSha,
28
+ pr.state,
29
+ pr.reviewState,
30
+ pr.author,
31
+ pr.checks.conclusion,
32
+ pr.approvedBy,
33
+ pr.changesRequestedBy,
34
+ pr.requestedReviewers,
35
+ pr.reRequestedReviewers,
36
+ pr.requestedTeams,
37
+ pr.assignees,
38
+ pr.labels,
39
+ pr.mergeable,
40
+ pr.unresolvedThreads === 0,
41
+ pr.reviewActivityAt,
42
+ pr.bodyEditedAt,
43
+ pr.closedAt,
44
+ ],
45
+ checks: (pr) => [pr.headSha, pr.checks],
46
+ timeline: (pr) => [
47
+ pr.headSha,
48
+ pr.baseSha,
49
+ pr.baseRef,
50
+ pr.title,
51
+ pr.state,
52
+ pr.labels,
53
+ pr.assignees,
54
+ pr.requestedReviewers,
55
+ pr.reRequestedReviewers,
56
+ pr.requestedTeams,
57
+ pr.approvedBy,
58
+ pr.changesRequestedBy,
59
+ pr.reviewActivityAt,
60
+ pr.commentActivityAt,
61
+ pr.reviewCommentActivityAt,
62
+ pr.unresolvedThreads,
63
+ ],
64
+ commits: (pr) => [pr.headSha, pr.baseSha],
65
+ files: (pr) => [pr.headSha, pr.baseSha],
66
+ };
67
+
68
+ export const partFingerprint = (
69
+ pr: ReviewflowPrDetail,
70
+ part: PullRequestPart,
71
+ ): string => JSON.stringify(partFields[part](pr));
72
+
73
+ /** The parts of the pull request screen a reviewflow change makes stale. */
74
+ export const changedPullRequestParts = (
75
+ previous: ReviewflowPrDetail,
76
+ next: ReviewflowPrDetail,
77
+ ): PullRequestPart[] =>
78
+ pullRequestParts.filter(
79
+ (part) => partFingerprint(previous, part) !== partFingerprint(next, part),
80
+ );
@@ -0,0 +1,75 @@
1
+ import type { PullRequestChecksResponse } from "../api/pullRequest.ts";
2
+ import type { GhPullRequestChecks } from "../github/pullRequestDetail.ts";
3
+ import { parsePullRequestChecks } from "../github/pullRequestDetail.ts";
4
+ import type { ReviewflowCheckRun, ReviewflowCommitStatus } from "./client.ts";
5
+
6
+ const upperOrNull = (value: string | null): string | null =>
7
+ value === null ? null : value.toUpperCase();
8
+
9
+ /**
10
+ * reviewflow's copy of the head's checks, read as GitHub's are: its values are
11
+ * the REST api's, lowercase where GraphQL's are uppercase. A run stored before
12
+ * reviewflow kept its status reads as completed when it has a conclusion.
13
+ *
14
+ * The runs are rebuilt as the GraphQL contexts and handed to
15
+ * `parsePullRequestChecks`, so the outcome mapping and the ordering stay the
16
+ * one GitHub-read checks use.
17
+ */
18
+ export const checksFromReviewflow = (
19
+ {
20
+ checkRuns,
21
+ statuses,
22
+ }: {
23
+ checkRuns: ReviewflowCheckRun[];
24
+ statuses: ReviewflowCommitStatus[];
25
+ },
26
+ at: string,
27
+ ): PullRequestChecksResponse => {
28
+ const nodes: NonNullable<
29
+ NonNullable<
30
+ NonNullable<GhPullRequestChecks["headCommits"]["nodes"]>[number]
31
+ >["commit"]["statusCheckRollup"]
32
+ >["contexts"]["nodes"] = [
33
+ ...checkRuns.map((run) => ({
34
+ __typename: "CheckRun" as const,
35
+ name: run.name,
36
+ status:
37
+ upperOrNull(run.status) ??
38
+ (run.conclusion === null ? "QUEUED" : "COMPLETED"),
39
+ conclusion: upperOrNull(run.conclusion),
40
+ title: run.title,
41
+ detailsUrl: run.detailsUrl,
42
+ })),
43
+ ...statuses.map((status) => ({
44
+ __typename: "StatusContext" as const,
45
+ context: status.context,
46
+ state: status.state.toUpperCase(),
47
+ description: status.description,
48
+ targetUrl: status.targetUrl,
49
+ })),
50
+ ];
51
+ const parsed = parsePullRequestChecks({
52
+ data: {
53
+ repository: {
54
+ pullRequest: {
55
+ headCommits: {
56
+ nodes: [
57
+ {
58
+ commit: {
59
+ statusCheckRollup: {
60
+ contexts: { totalCount: nodes.length, nodes },
61
+ },
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ },
68
+ },
69
+ errors: [],
70
+ ownerRepo: "",
71
+ number: 0,
72
+ fetchedAt: at,
73
+ });
74
+ return { ...parsed, source: "reviewflow" };
75
+ };