@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.
- package/LICENSE +15 -0
- package/README.md +86 -2
- package/build/files-node.mjs +70 -0
- package/build/git-node.mjs +1792 -0
- package/build/index-browser.es.js +3338 -0
- package/build/index-node.mjs +3338 -0
- package/package.json +136 -4
- package/src/api/pullRequest.ts +617 -0
- package/src/config/insertCheckoutEntry.ts +54 -0
- package/src/diff/diffPaths.ts +78 -0
- package/src/diff/impacts.ts +47 -0
- package/src/diff/readingOrder.ts +428 -0
- package/src/diff/themes.ts +105 -0
- package/src/diff/types.ts +141 -0
- package/src/files/editableFile.ts +163 -0
- package/src/files.ts +13 -0
- package/src/git/groupDiffPaths.ts +134 -0
- package/src/git/manifests.ts +96 -0
- package/src/git/moduleBindings.ts +571 -0
- package/src/git/pullRequestDiff.ts +159 -0
- package/src/git/readBranchDiff.ts +359 -0
- package/src/git/readImpacts.ts +797 -0
- package/src/git/readingOrder.ts +190 -0
- package/src/git/run.ts +213 -0
- package/src/git.ts +38 -0
- package/src/github/applySuggestions.ts +399 -0
- package/src/github/editPullRequest.ts +790 -0
- package/src/github/fetchCompareDiff.ts +21 -0
- package/src/github/imageProxies.ts +43 -0
- package/src/github/lineDiff.ts +145 -0
- package/src/github/prQuery.ts +298 -0
- package/src/github/pullRequestDetail.ts +1347 -0
- package/src/github/pullRequestHead.ts +65 -0
- package/src/github/pullRequestRevisions.ts +124 -0
- package/src/github/transport.ts +171 -0
- package/src/index.ts +35 -0
- package/src/reviewflow/board.ts +333 -0
- package/src/reviewflow/changedParts.ts +80 -0
- package/src/reviewflow/checksFromReviewflow.ts +75 -0
- package/src/reviewflow/client.ts +589 -0
- package/src/reviewflow/follow.ts +345 -0
- package/src/reviewflow/status.ts +17 -0
- package/src/reviewflow/stream.ts +199 -0
- package/src/types/entities.ts +8 -0
- package/src/types/ids.ts +1 -0
- package/src/types/reviewflow.ts +32 -0
- package/src/utils/pullRequestEdit.ts +108 -0
- package/src/utils/pullRequestPart.ts +12 -0
- package/src/utils/pullRequestReaction.ts +19 -0
- package/src/utils/pullRequestSuggestion.ts +148 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import type { PrState } from "../types/entities.ts";
|
|
2
|
+
import type {
|
|
3
|
+
ReviewflowAccount,
|
|
4
|
+
ReviewflowChecksConclusion,
|
|
5
|
+
ReviewflowMergeable,
|
|
6
|
+
ReviewflowPrChanges,
|
|
7
|
+
ReviewflowReviewState,
|
|
8
|
+
ReviewflowStatusLink,
|
|
9
|
+
} from "../types/reviewflow.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The read api of reviewflow (`reviewflow/docs/api.md`), the read endpoints
|
|
13
|
+
* a pull request screen needs and nothing more. The answers are checked field by field before use:
|
|
14
|
+
* a server on another version must fail here with a message, not write a
|
|
15
|
+
* `undefined` state into `pr`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const reviewflowErrorCode = "ERR_REVIEWFLOW";
|
|
19
|
+
|
|
20
|
+
const timeoutMs = 15_000;
|
|
21
|
+
export const maxLookupTargets = 200;
|
|
22
|
+
/** pages past the first, per bucket: requested reviews alone reach 120 today */
|
|
23
|
+
export const maxExtraPagesPerBucket = 5;
|
|
24
|
+
|
|
25
|
+
/** GitHub's REST values, lowercase; `null` where reviewflow stored the run before the field */
|
|
26
|
+
export interface ReviewflowCheckRun {
|
|
27
|
+
name: string;
|
|
28
|
+
status: string | null;
|
|
29
|
+
conclusion: string | null;
|
|
30
|
+
title: string | null;
|
|
31
|
+
detailsUrl: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReviewflowCommitStatus {
|
|
35
|
+
context: string;
|
|
36
|
+
state: string;
|
|
37
|
+
description: string | null;
|
|
38
|
+
targetUrl: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReviewflowPrDetail {
|
|
42
|
+
id: string;
|
|
43
|
+
ownerRepo: string;
|
|
44
|
+
number: number;
|
|
45
|
+
title: string;
|
|
46
|
+
url: string;
|
|
47
|
+
headRef: string | null;
|
|
48
|
+
baseRef: string | null;
|
|
49
|
+
headSha: string | null;
|
|
50
|
+
/** absent from a reviewflow that predates it */
|
|
51
|
+
baseSha: string | null;
|
|
52
|
+
state: PrState | null;
|
|
53
|
+
reviewState: ReviewflowReviewState | null;
|
|
54
|
+
author: string | null;
|
|
55
|
+
checks: {
|
|
56
|
+
conclusion: ReviewflowChecksConclusion;
|
|
57
|
+
passed: number;
|
|
58
|
+
failed: number;
|
|
59
|
+
pending: number;
|
|
60
|
+
running: number;
|
|
61
|
+
failedNames: string[];
|
|
62
|
+
/** every check run of the head; null from a reviewflow that predates the list */
|
|
63
|
+
checkRuns: ReviewflowCheckRun[] | null;
|
|
64
|
+
/** every commit status of the head; null alongside `checkRuns` */
|
|
65
|
+
statuses: ReviewflowCommitStatus[] | null;
|
|
66
|
+
};
|
|
67
|
+
lintFailed: boolean;
|
|
68
|
+
approvedBy: string[];
|
|
69
|
+
changesRequestedBy: string[];
|
|
70
|
+
/** asked for the first time */
|
|
71
|
+
requestedReviewers: string[];
|
|
72
|
+
/** asked again after having reviewed */
|
|
73
|
+
reRequestedReviewers: string[];
|
|
74
|
+
requestedTeams: string[];
|
|
75
|
+
assignees: string[];
|
|
76
|
+
/** label names, in reviewflow's order */
|
|
77
|
+
labels: string[];
|
|
78
|
+
statusLinks: ReviewflowStatusLink[];
|
|
79
|
+
openedAt: string | null;
|
|
80
|
+
approvedAt: string | null;
|
|
81
|
+
changes: ReviewflowPrChanges | null;
|
|
82
|
+
unresolvedThreads: number | null;
|
|
83
|
+
mergeable: ReviewflowMergeable;
|
|
84
|
+
closedAt: string | null;
|
|
85
|
+
/** the last conversation comment created, edited or deleted */
|
|
86
|
+
commentActivityAt: string | null;
|
|
87
|
+
/** the last review submitted, whatever its verdict */
|
|
88
|
+
reviewActivityAt: string | null;
|
|
89
|
+
/** the last review comment created, edited or deleted, a reply in a thread included */
|
|
90
|
+
reviewCommentActivityAt: string | null;
|
|
91
|
+
/** the last edit of the description */
|
|
92
|
+
bodyEditedAt: string | null;
|
|
93
|
+
updatedAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `GET /api/reviewflow/pr?repo=<owner/repo>&number=<n>`: null without a token,
|
|
98
|
+
* for a pull request reviewflow never handled, or one the token cannot read.
|
|
99
|
+
*/
|
|
100
|
+
export interface ReviewflowPrResponse {
|
|
101
|
+
pr: ReviewflowPrDetail | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ReviewflowMe {
|
|
105
|
+
login: string;
|
|
106
|
+
accounts: ReviewflowAccount[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ReviewflowLookupTarget {
|
|
110
|
+
ownerRepo: string;
|
|
111
|
+
headRef: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ReviewflowLookupResult =
|
|
115
|
+
| { found: false; reason: "forbidden" | "noPr" | "notTracked" }
|
|
116
|
+
| { found: true; pr: ReviewflowPrDetail };
|
|
117
|
+
|
|
118
|
+
export interface ReviewflowBucket {
|
|
119
|
+
key: string;
|
|
120
|
+
title: string;
|
|
121
|
+
total: number;
|
|
122
|
+
prs: ReviewflowPrDetail[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ReviewflowGroup {
|
|
126
|
+
key: string;
|
|
127
|
+
title: string;
|
|
128
|
+
buckets: ReviewflowBucket[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ReviewflowClient {
|
|
132
|
+
me: () => Promise<ReviewflowMe>;
|
|
133
|
+
/** same order as `targets`, whatever their number */
|
|
134
|
+
lookup: (
|
|
135
|
+
targets: ReviewflowLookupTarget[],
|
|
136
|
+
) => Promise<ReviewflowLookupResult[]>;
|
|
137
|
+
/** one account's board, with up to `maxExtraPagesPerBucket` more pages per bucket */
|
|
138
|
+
board: (account: string) => Promise<ReviewflowGroup[]>;
|
|
139
|
+
/** null when reviewflow never handled it, or the token cannot read its owner */
|
|
140
|
+
pr: (ownerRepo: string, number: number) => Promise<ReviewflowPrDetail | null>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ReviewflowFailure extends Error {
|
|
144
|
+
code: string;
|
|
145
|
+
status: number | undefined;
|
|
146
|
+
/** from `Retry-After`, on a `429` or a `503` */
|
|
147
|
+
retryAfterMs: number | undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const fail = (
|
|
151
|
+
message: string,
|
|
152
|
+
{ status, retryAfterMs }: { status?: number; retryAfterMs?: number } = {},
|
|
153
|
+
): ReviewflowFailure =>
|
|
154
|
+
Object.assign(new Error(message), {
|
|
155
|
+
code: reviewflowErrorCode,
|
|
156
|
+
status,
|
|
157
|
+
retryAfterMs,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const isReviewflowFailure = (
|
|
161
|
+
error: unknown,
|
|
162
|
+
): error is ReviewflowFailure =>
|
|
163
|
+
error instanceof Error &&
|
|
164
|
+
(error as Partial<ReviewflowFailure>).code === reviewflowErrorCode;
|
|
165
|
+
|
|
166
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
167
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
168
|
+
|
|
169
|
+
const stringOrNull = (value: unknown): string | null =>
|
|
170
|
+
typeof value === "string" ? value : null;
|
|
171
|
+
|
|
172
|
+
const numberOr = (value: unknown, fallback: number): number =>
|
|
173
|
+
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
174
|
+
|
|
175
|
+
const prStates = new Set<string>(["closed", "draft", "merged", "open"]);
|
|
176
|
+
const reviewStates = new Set<string>([
|
|
177
|
+
"approved",
|
|
178
|
+
"changesRequested",
|
|
179
|
+
"none",
|
|
180
|
+
"requested",
|
|
181
|
+
]);
|
|
182
|
+
const mergeables = new Set<string>(["conflicting", "mergeable", "unknown"]);
|
|
183
|
+
|
|
184
|
+
const checksConclusions: Record<string, ReviewflowChecksConclusion> = {
|
|
185
|
+
failed: "failed",
|
|
186
|
+
"in-progress": "inProgress",
|
|
187
|
+
passed: "passed",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const enumOrNull = <Value extends string>(
|
|
191
|
+
value: unknown,
|
|
192
|
+
allowed: Set<string>,
|
|
193
|
+
): Value | null =>
|
|
194
|
+
typeof value === "string" && allowed.has(value) ? (value as Value) : null;
|
|
195
|
+
|
|
196
|
+
const strings = (value: unknown): string[] =>
|
|
197
|
+
Array.isArray(value)
|
|
198
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
199
|
+
: [];
|
|
200
|
+
|
|
201
|
+
const logins = (value: unknown): string[] =>
|
|
202
|
+
Array.isArray(value)
|
|
203
|
+
? value.flatMap((person: unknown) =>
|
|
204
|
+
isRecord(person) && typeof person.login === "string"
|
|
205
|
+
? [person.login]
|
|
206
|
+
: [],
|
|
207
|
+
)
|
|
208
|
+
: [];
|
|
209
|
+
|
|
210
|
+
/** the links reviewflow resolved, a ticket key among them; not the failed or pending ones */
|
|
211
|
+
const readCheckRuns = (value: unknown): ReviewflowCheckRun[] | null =>
|
|
212
|
+
Array.isArray(value)
|
|
213
|
+
? value.flatMap((run: unknown) =>
|
|
214
|
+
isRecord(run) && typeof run.name === "string"
|
|
215
|
+
? [
|
|
216
|
+
{
|
|
217
|
+
name: run.name,
|
|
218
|
+
status: stringOrNull(run.status),
|
|
219
|
+
conclusion: stringOrNull(run.conclusion),
|
|
220
|
+
title: stringOrNull(run.title),
|
|
221
|
+
detailsUrl: stringOrNull(run.detailsUrl),
|
|
222
|
+
},
|
|
223
|
+
]
|
|
224
|
+
: [],
|
|
225
|
+
)
|
|
226
|
+
: null;
|
|
227
|
+
|
|
228
|
+
const readCommitStatuses = (value: unknown): ReviewflowCommitStatus[] | null =>
|
|
229
|
+
Array.isArray(value)
|
|
230
|
+
? value.flatMap((status: unknown) =>
|
|
231
|
+
isRecord(status) &&
|
|
232
|
+
typeof status.context === "string" &&
|
|
233
|
+
typeof status.state === "string"
|
|
234
|
+
? [
|
|
235
|
+
{
|
|
236
|
+
context: status.context,
|
|
237
|
+
state: status.state,
|
|
238
|
+
description: stringOrNull(status.description),
|
|
239
|
+
targetUrl: stringOrNull(status.targetUrl),
|
|
240
|
+
},
|
|
241
|
+
]
|
|
242
|
+
: [],
|
|
243
|
+
)
|
|
244
|
+
: null;
|
|
245
|
+
|
|
246
|
+
const readStatusLinks = (value: unknown): ReviewflowStatusLink[] =>
|
|
247
|
+
Array.isArray(value)
|
|
248
|
+
? value.flatMap((link: unknown) =>
|
|
249
|
+
isRecord(link) &&
|
|
250
|
+
link.type === "success" &&
|
|
251
|
+
typeof link.name === "string" &&
|
|
252
|
+
typeof link.label === "string" &&
|
|
253
|
+
typeof link.url === "string"
|
|
254
|
+
? [{ name: link.name, label: link.label, url: link.url }]
|
|
255
|
+
: [],
|
|
256
|
+
)
|
|
257
|
+
: [];
|
|
258
|
+
|
|
259
|
+
const readLabels = (value: unknown): string[] =>
|
|
260
|
+
Array.isArray(value)
|
|
261
|
+
? value.flatMap((label: unknown) =>
|
|
262
|
+
isRecord(label) && typeof label.name === "string" ? [label.name] : [],
|
|
263
|
+
)
|
|
264
|
+
: [];
|
|
265
|
+
|
|
266
|
+
/** reviewflow leaves `changes` out of the json when it does not know them */
|
|
267
|
+
const readChanges = (value: unknown): ReviewflowPrChanges | null =>
|
|
268
|
+
isRecord(value) &&
|
|
269
|
+
typeof value.changedFiles === "number" &&
|
|
270
|
+
typeof value.additions === "number" &&
|
|
271
|
+
typeof value.deletions === "number"
|
|
272
|
+
? {
|
|
273
|
+
changedFiles: value.changedFiles,
|
|
274
|
+
additions: value.additions,
|
|
275
|
+
deletions: value.deletions,
|
|
276
|
+
}
|
|
277
|
+
: null;
|
|
278
|
+
|
|
279
|
+
/** A state reviewflow adds later reads as unknown, never as one this client knows. */
|
|
280
|
+
export const readPrDetail = (value: unknown): ReviewflowPrDetail => {
|
|
281
|
+
if (
|
|
282
|
+
!isRecord(value) ||
|
|
283
|
+
typeof value._id !== "string" ||
|
|
284
|
+
typeof value.ownerRepo !== "string" ||
|
|
285
|
+
typeof value.number !== "number" ||
|
|
286
|
+
typeof value.url !== "string" ||
|
|
287
|
+
!isRecord(value.checks)
|
|
288
|
+
) {
|
|
289
|
+
throw fail("reviewflow returned a pull request the client cannot read");
|
|
290
|
+
}
|
|
291
|
+
const { checks } = value;
|
|
292
|
+
const creator = isRecord(value.creator) ? value.creator : null;
|
|
293
|
+
const flowDates = isRecord(value.flowDates) ? value.flowDates : {};
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
id: value._id,
|
|
297
|
+
ownerRepo: value.ownerRepo,
|
|
298
|
+
number: value.number,
|
|
299
|
+
title: typeof value.title === "string" ? value.title : "",
|
|
300
|
+
url: value.url,
|
|
301
|
+
headRef: stringOrNull(value.headRef),
|
|
302
|
+
baseRef: stringOrNull(value.baseRef),
|
|
303
|
+
headSha: stringOrNull(value.headSha),
|
|
304
|
+
baseSha: stringOrNull(value.baseSha),
|
|
305
|
+
state: enumOrNull<PrState>(value.state, prStates),
|
|
306
|
+
reviewState: enumOrNull<ReviewflowReviewState>(
|
|
307
|
+
value.reviewState,
|
|
308
|
+
reviewStates,
|
|
309
|
+
),
|
|
310
|
+
author: stringOrNull(creator?.login),
|
|
311
|
+
checks: {
|
|
312
|
+
conclusion:
|
|
313
|
+
typeof checks.conclusion === "string"
|
|
314
|
+
? (checksConclusions[checks.conclusion] ?? "unknown")
|
|
315
|
+
: "unknown",
|
|
316
|
+
passed: numberOr(checks.passed, 0),
|
|
317
|
+
failed: numberOr(checks.failed, 0),
|
|
318
|
+
pending: numberOr(checks.pending, 0),
|
|
319
|
+
running: numberOr(checks.runningCount, 0),
|
|
320
|
+
failedNames: strings(checks.failedNames),
|
|
321
|
+
checkRuns: readCheckRuns(checks.checkRuns),
|
|
322
|
+
statuses: readCommitStatuses(checks.statuses),
|
|
323
|
+
},
|
|
324
|
+
lintFailed: value.lintFailed === true,
|
|
325
|
+
approvedBy: logins(value.approvedBy),
|
|
326
|
+
changesRequestedBy: logins(value.changesRequestedBy),
|
|
327
|
+
requestedReviewers: logins(value.requestedReviewers),
|
|
328
|
+
reRequestedReviewers: logins(value.reRequestedReviewers),
|
|
329
|
+
requestedTeams: strings(value.requestedTeams),
|
|
330
|
+
assignees: logins(value.assignees),
|
|
331
|
+
labels: readLabels(value.labels),
|
|
332
|
+
statusLinks: readStatusLinks(value.statusLinks),
|
|
333
|
+
openedAt: stringOrNull(flowDates.openedAt),
|
|
334
|
+
approvedAt: stringOrNull(flowDates.approvedAt),
|
|
335
|
+
changes: readChanges(value.changes),
|
|
336
|
+
unresolvedThreads:
|
|
337
|
+
typeof value.unresolvedThreads === "number"
|
|
338
|
+
? value.unresolvedThreads
|
|
339
|
+
: null,
|
|
340
|
+
mergeable:
|
|
341
|
+
enumOrNull<ReviewflowMergeable>(value.mergeable, mergeables) ?? "unknown",
|
|
342
|
+
closedAt: stringOrNull(flowDates.closedAt),
|
|
343
|
+
commentActivityAt: stringOrNull(flowDates.lastCommentActivityAt),
|
|
344
|
+
reviewActivityAt: stringOrNull(flowDates.lastReviewSubmittedAt),
|
|
345
|
+
reviewCommentActivityAt: stringOrNull(
|
|
346
|
+
flowDates.lastReviewCommentActivityAt,
|
|
347
|
+
),
|
|
348
|
+
bodyEditedAt: stringOrNull(flowDates.lastBodyEditedAt),
|
|
349
|
+
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "",
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const lookupReasons = new Set<string>(["forbidden", "noPr", "notTracked"]);
|
|
354
|
+
|
|
355
|
+
const readLookupResult = (value: unknown): ReviewflowLookupResult => {
|
|
356
|
+
if (isRecord(value) && value.found === true) {
|
|
357
|
+
return { found: true, pr: readPrDetail(value.pr) };
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
isRecord(value) &&
|
|
361
|
+
value.found === false &&
|
|
362
|
+
typeof value.reason === "string" &&
|
|
363
|
+
lookupReasons.has(value.reason)
|
|
364
|
+
) {
|
|
365
|
+
return {
|
|
366
|
+
found: false,
|
|
367
|
+
reason: value.reason as "forbidden" | "noPr" | "notTracked",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// an unknown reason is the one answer that cannot be trusted as `noPr`
|
|
371
|
+
return { found: false, reason: "notTracked" };
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
interface BucketPage {
|
|
375
|
+
key: string;
|
|
376
|
+
title: string;
|
|
377
|
+
total: number;
|
|
378
|
+
prs: ReviewflowPrDetail[];
|
|
379
|
+
nextPage: string | null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const readBucketPage = (value: unknown): BucketPage => {
|
|
383
|
+
if (
|
|
384
|
+
!isRecord(value) ||
|
|
385
|
+
typeof value.key !== "string" ||
|
|
386
|
+
!Array.isArray(value.prs)
|
|
387
|
+
) {
|
|
388
|
+
throw fail("reviewflow returned a board bucket the client cannot read");
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
key: value.key,
|
|
392
|
+
title: typeof value.title === "string" ? value.title : value.key,
|
|
393
|
+
total: numberOr(value.total, value.prs.length),
|
|
394
|
+
prs: value.prs.map(readPrDetail),
|
|
395
|
+
nextPage: stringOrNull(value.nextPage),
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const readMe = (value: unknown): ReviewflowMe => {
|
|
400
|
+
if (
|
|
401
|
+
!isRecord(value) ||
|
|
402
|
+
!isRecord(value.user) ||
|
|
403
|
+
typeof value.user.login !== "string" ||
|
|
404
|
+
!Array.isArray(value.accounts)
|
|
405
|
+
) {
|
|
406
|
+
throw fail("reviewflow returned a /me the client cannot read");
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
login: value.user.login,
|
|
410
|
+
accounts: value.accounts.filter(isRecord).map((account) => ({
|
|
411
|
+
login: String(account.login),
|
|
412
|
+
type: account.type === "Organization" ? "Organization" : "User",
|
|
413
|
+
})),
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/** an HTTP date in `Retry-After` is not something this api sends */
|
|
418
|
+
const readRetryAfterMs = (response: Response): number | undefined => {
|
|
419
|
+
const seconds = Number(response.headers.get("retry-after") ?? Number.NaN);
|
|
420
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/** The api's own envelope when there is one, the status otherwise. */
|
|
424
|
+
export const responseFailure = async (
|
|
425
|
+
response: Response,
|
|
426
|
+
): Promise<ReviewflowFailure> => {
|
|
427
|
+
const body: unknown = await response.json().catch(() => null);
|
|
428
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : null;
|
|
429
|
+
const detail =
|
|
430
|
+
error === null
|
|
431
|
+
? ""
|
|
432
|
+
: ` ${String(error.code)}: ${typeof error.message === "string" ? error.message : ""}`;
|
|
433
|
+
const retryAfter = response.headers.get("retry-after");
|
|
434
|
+
const retry = retryAfter === null ? "" : ` (retry after ${retryAfter}s)`;
|
|
435
|
+
return fail(
|
|
436
|
+
`reviewflow answered ${String(response.status)}${detail}${retry}`,
|
|
437
|
+
{ status: response.status, retryAfterMs: readRetryAfterMs(response) },
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
interface CreateReviewflowClientParams {
|
|
442
|
+
url: string;
|
|
443
|
+
token: string;
|
|
444
|
+
/** injected by tests: nothing in the suite reaches the network */
|
|
445
|
+
fetch?: typeof globalThis.fetch;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const chunk = <Item>(items: Item[], size: number): Item[][] =>
|
|
449
|
+
Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
|
|
450
|
+
items.slice(index * size, (index + 1) * size),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
export const createReviewflowClient = ({
|
|
454
|
+
url,
|
|
455
|
+
token,
|
|
456
|
+
fetch = globalThis.fetch,
|
|
457
|
+
}: CreateReviewflowClientParams): ReviewflowClient => {
|
|
458
|
+
const request = async (
|
|
459
|
+
path: string,
|
|
460
|
+
init: { method?: string; body?: unknown } = {},
|
|
461
|
+
): Promise<unknown> => {
|
|
462
|
+
const response = await fetch(`${url}/api${path}`, {
|
|
463
|
+
method: init.method ?? "GET",
|
|
464
|
+
headers: {
|
|
465
|
+
authorization: `Bearer ${token}`,
|
|
466
|
+
accept: "application/json",
|
|
467
|
+
...(init.body === undefined
|
|
468
|
+
? {}
|
|
469
|
+
: { "content-type": "application/json" }),
|
|
470
|
+
},
|
|
471
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
472
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
473
|
+
}).catch((error: unknown) => {
|
|
474
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
475
|
+
throw fail(`reviewflow could not be reached at ${url}: ${cause}`);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (!response.ok) throw await responseFailure(response);
|
|
479
|
+
return response.json().catch(() => {
|
|
480
|
+
throw fail("reviewflow answered with a body that is not json");
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const readBucket = async (
|
|
485
|
+
account: string,
|
|
486
|
+
first: BucketPage,
|
|
487
|
+
): Promise<ReviewflowBucket> => {
|
|
488
|
+
const prs = [...first.prs];
|
|
489
|
+
let { nextPage } = first;
|
|
490
|
+
for (
|
|
491
|
+
let page = 0;
|
|
492
|
+
nextPage !== null && page < maxExtraPagesPerBucket;
|
|
493
|
+
page++
|
|
494
|
+
) {
|
|
495
|
+
// a page token is only valid for the account it was issued for
|
|
496
|
+
const query = new URLSearchParams({
|
|
497
|
+
bucket: first.key,
|
|
498
|
+
account,
|
|
499
|
+
page: nextPage,
|
|
500
|
+
});
|
|
501
|
+
const next = readBucketPage(
|
|
502
|
+
await request(`/board-bucket/v1?${query.toString()}`),
|
|
503
|
+
);
|
|
504
|
+
prs.push(...next.prs);
|
|
505
|
+
({ nextPage } = next);
|
|
506
|
+
}
|
|
507
|
+
// a page read live can repeat a row that moved between two reads
|
|
508
|
+
const seen = new Set<string>();
|
|
509
|
+
return {
|
|
510
|
+
key: first.key,
|
|
511
|
+
title: first.title,
|
|
512
|
+
total: first.total,
|
|
513
|
+
prs: prs.filter((pr) => !seen.has(pr.id) && seen.add(pr.id)),
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
me: async () => readMe(await request("/me/v1")),
|
|
519
|
+
|
|
520
|
+
lookup: async (targets) => {
|
|
521
|
+
const answers = await Promise.all(
|
|
522
|
+
chunk(targets, maxLookupTargets).map(async (part) => {
|
|
523
|
+
const body = await request("/prs-lookup/v1", {
|
|
524
|
+
method: "POST",
|
|
525
|
+
body: { targets: part },
|
|
526
|
+
});
|
|
527
|
+
if (
|
|
528
|
+
!isRecord(body) ||
|
|
529
|
+
!Array.isArray(body.results) ||
|
|
530
|
+
body.results.length !== part.length
|
|
531
|
+
) {
|
|
532
|
+
throw fail("reviewflow returned a lookup the client cannot read");
|
|
533
|
+
}
|
|
534
|
+
return body.results.map(readLookupResult);
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
return answers.flat();
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
pr: async (ownerRepo, number) => {
|
|
541
|
+
const path = ownerRepo
|
|
542
|
+
.split("/")
|
|
543
|
+
.map((part) => encodeURIComponent(part))
|
|
544
|
+
.join("/");
|
|
545
|
+
try {
|
|
546
|
+
return readPrDetail(await request(`/pr/v1/${path}/${String(number)}`));
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (
|
|
549
|
+
isReviewflowFailure(error) &&
|
|
550
|
+
(error.status === 403 || error.status === 404)
|
|
551
|
+
) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
board: async (account) => {
|
|
559
|
+
const body = await request(
|
|
560
|
+
`/board/v1?${new URLSearchParams({ account }).toString()}`,
|
|
561
|
+
);
|
|
562
|
+
if (!isRecord(body) || !Array.isArray(body.groups)) {
|
|
563
|
+
throw fail("reviewflow returned a board the client cannot read");
|
|
564
|
+
}
|
|
565
|
+
return Promise.all(
|
|
566
|
+
body.groups.map(async (group: unknown): Promise<ReviewflowGroup> => {
|
|
567
|
+
if (
|
|
568
|
+
!isRecord(group) ||
|
|
569
|
+
typeof group.key !== "string" ||
|
|
570
|
+
!Array.isArray(group.buckets)
|
|
571
|
+
) {
|
|
572
|
+
throw fail(
|
|
573
|
+
"reviewflow returned a board group the client cannot read",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
key: group.key,
|
|
578
|
+
title: typeof group.title === "string" ? group.title : group.key,
|
|
579
|
+
buckets: await Promise.all(
|
|
580
|
+
group.buckets.map((bucket: unknown) =>
|
|
581
|
+
readBucket(account, readBucketPage(bucket)),
|
|
582
|
+
),
|
|
583
|
+
),
|
|
584
|
+
};
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
};
|