@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,3338 @@
|
|
|
1
|
+
const cEscapes = {
|
|
2
|
+
a: 7,
|
|
3
|
+
b: 8,
|
|
4
|
+
f: 12,
|
|
5
|
+
n: 10,
|
|
6
|
+
r: 13,
|
|
7
|
+
t: 9,
|
|
8
|
+
v: 11,
|
|
9
|
+
'"': 34,
|
|
10
|
+
"\\": 92
|
|
11
|
+
};
|
|
12
|
+
const unquoteGitPath = (quoted) => {
|
|
13
|
+
const bytes = [];
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
for (let index = 1; index < quoted.length - 1; index++) {
|
|
16
|
+
const char = quoted[index];
|
|
17
|
+
if (char !== "\\") {
|
|
18
|
+
bytes.push(...encoder.encode(char));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const next = quoted[index + 1] ?? "";
|
|
22
|
+
const octal = /^[0-7]{3}/.exec(quoted.slice(index + 1));
|
|
23
|
+
if (octal) {
|
|
24
|
+
bytes.push(Number.parseInt(octal[0], 8));
|
|
25
|
+
index += 3;
|
|
26
|
+
} else {
|
|
27
|
+
bytes.push(cEscapes[next] ?? next.codePointAt(0) ?? 0);
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
32
|
+
};
|
|
33
|
+
const unquoted = (name) => name.length > 1 && name.startsWith('"') && name.endsWith('"') ? unquoteGitPath(name) : name;
|
|
34
|
+
const headerName = (raw, prefix) => {
|
|
35
|
+
const name = unquoted(raw.endsWith(" ") ? raw.slice(0, -1) : raw);
|
|
36
|
+
return name.startsWith(prefix) ? name.slice(prefix.length) : null;
|
|
37
|
+
};
|
|
38
|
+
const pathOfBlock = (block) => {
|
|
39
|
+
const added = /^\+\+\+ (.*)$/m.exec(block)?.[1];
|
|
40
|
+
const addedName = added === void 0 ? null : headerName(added, "b/");
|
|
41
|
+
if (addedName !== null) return addedName;
|
|
42
|
+
const removed = /^--- (.*)$/m.exec(block)?.[1];
|
|
43
|
+
const removedName = removed === void 0 ? null : headerName(removed, "a/");
|
|
44
|
+
if (removedName !== null) return removedName;
|
|
45
|
+
const renamed = /^rename to (.*)$/m.exec(block)?.[1];
|
|
46
|
+
if (renamed !== void 0) return unquoted(renamed);
|
|
47
|
+
const header = block.split("\n", 1)[0] ?? "";
|
|
48
|
+
const quotedSeparator = header.lastIndexOf(' "b/');
|
|
49
|
+
if (quotedSeparator !== -1) {
|
|
50
|
+
return headerName(header.slice(quotedSeparator + 1), "b/");
|
|
51
|
+
}
|
|
52
|
+
const separator = header.lastIndexOf(" b/");
|
|
53
|
+
return separator === -1 ? null : header.slice(separator + 3);
|
|
54
|
+
};
|
|
55
|
+
const diffPaths = (patch) => patch.split(/^diff --git /m).slice(1).map(pathOfBlock).filter((path) => path !== null);
|
|
56
|
+
|
|
57
|
+
const typescriptExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
|
58
|
+
const extensionOf = (path) => {
|
|
59
|
+
const slash = path.lastIndexOf("/");
|
|
60
|
+
const dot = path.lastIndexOf(".");
|
|
61
|
+
return dot > slash ? path.slice(dot) : "";
|
|
62
|
+
};
|
|
63
|
+
const isTypescriptPath = (path) => typescriptExtensions.includes(extensionOf(path));
|
|
64
|
+
const attachableSuffix = /\.(?:test|spec|stories)(?=\.[cm]?tsx?$)/;
|
|
65
|
+
const testDirectories = /* @__PURE__ */ new Set([
|
|
66
|
+
"__fixtures__",
|
|
67
|
+
"__mocks__",
|
|
68
|
+
"__tests__",
|
|
69
|
+
"fixtures",
|
|
70
|
+
"test",
|
|
71
|
+
"tests"
|
|
72
|
+
]);
|
|
73
|
+
const isAttachablePath = (path) => isTypescriptPath(path) && (attachableSuffix.test(path) || path.split("/").slice(0, -1).some((directory) => testDirectories.has(directory)));
|
|
74
|
+
const directoryOf = (path) => {
|
|
75
|
+
const slash = path.lastIndexOf("/");
|
|
76
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
77
|
+
};
|
|
78
|
+
const joinPath = (directory, name) => directory === "" ? name : `${directory}/${name}`;
|
|
79
|
+
const coveredDirectoriesOf = (path) => {
|
|
80
|
+
const directory = directoryOf(path);
|
|
81
|
+
return directory.split("/").at(-1) === "__tests__" ? [directory, directoryOf(directory)] : [directory];
|
|
82
|
+
};
|
|
83
|
+
const attachableKinds = /* @__PURE__ */ new Set(["spec", "stories", "test"]);
|
|
84
|
+
const subjectsOf = (path) => {
|
|
85
|
+
const parts = path.slice(directoryOf(path).length).replace(/^\//, "").replace(/\.[cm]?tsx?$/, "").split(".");
|
|
86
|
+
const kind = parts.findIndex((part) => attachableKinds.has(part));
|
|
87
|
+
const stem = kind === -1 ? parts : parts.slice(0, kind);
|
|
88
|
+
const names = stem.map(
|
|
89
|
+
(_, dropped) => stem.slice(0, stem.length - dropped).join(".")
|
|
90
|
+
);
|
|
91
|
+
return coveredDirectoriesOf(path).flatMap(
|
|
92
|
+
(directory) => names.flatMap(
|
|
93
|
+
(name) => typescriptExtensions.map(
|
|
94
|
+
(extension) => joinPath(directory, name + extension)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const diffThemes = [
|
|
101
|
+
"github",
|
|
102
|
+
"github-high-contrast",
|
|
103
|
+
"pierre",
|
|
104
|
+
"vscode",
|
|
105
|
+
"one",
|
|
106
|
+
"catppuccin",
|
|
107
|
+
"rose-pine",
|
|
108
|
+
"solarized",
|
|
109
|
+
"gruvbox",
|
|
110
|
+
"everforest",
|
|
111
|
+
"kanagawa",
|
|
112
|
+
"vitesse",
|
|
113
|
+
"ayu",
|
|
114
|
+
"material",
|
|
115
|
+
"night-owl",
|
|
116
|
+
"min"
|
|
117
|
+
];
|
|
118
|
+
const isDiffTheme = (value) => diffThemes.includes(value);
|
|
119
|
+
const families = {
|
|
120
|
+
github: {
|
|
121
|
+
label: "GitHub",
|
|
122
|
+
themes: { light: "github-light-default", dark: "github-dark-default" }
|
|
123
|
+
},
|
|
124
|
+
"github-high-contrast": {
|
|
125
|
+
label: "GitHub high contrast",
|
|
126
|
+
themes: {
|
|
127
|
+
light: "github-light-high-contrast",
|
|
128
|
+
dark: "github-dark-high-contrast"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
pierre: {
|
|
132
|
+
label: "Pierre",
|
|
133
|
+
themes: { light: "pierre-light", dark: "pierre-dark" }
|
|
134
|
+
},
|
|
135
|
+
vscode: {
|
|
136
|
+
label: "VS Code",
|
|
137
|
+
themes: { light: "light-plus", dark: "dark-plus" }
|
|
138
|
+
},
|
|
139
|
+
one: { label: "One", themes: { light: "one-light", dark: "one-dark-pro" } },
|
|
140
|
+
catppuccin: {
|
|
141
|
+
label: "Catppuccin",
|
|
142
|
+
themes: { light: "catppuccin-latte", dark: "catppuccin-mocha" }
|
|
143
|
+
},
|
|
144
|
+
"rose-pine": {
|
|
145
|
+
label: "Ros\xE9 Pine",
|
|
146
|
+
themes: { light: "rose-pine-dawn", dark: "rose-pine" }
|
|
147
|
+
},
|
|
148
|
+
solarized: {
|
|
149
|
+
label: "Solarized",
|
|
150
|
+
themes: { light: "solarized-light", dark: "solarized-dark" }
|
|
151
|
+
},
|
|
152
|
+
gruvbox: {
|
|
153
|
+
label: "Gruvbox",
|
|
154
|
+
themes: { light: "gruvbox-light-medium", dark: "gruvbox-dark-medium" }
|
|
155
|
+
},
|
|
156
|
+
everforest: {
|
|
157
|
+
label: "Everforest",
|
|
158
|
+
themes: { light: "everforest-light", dark: "everforest-dark" }
|
|
159
|
+
},
|
|
160
|
+
kanagawa: {
|
|
161
|
+
label: "Kanagawa",
|
|
162
|
+
themes: { light: "kanagawa-lotus", dark: "kanagawa-wave" }
|
|
163
|
+
},
|
|
164
|
+
vitesse: {
|
|
165
|
+
label: "Vitesse",
|
|
166
|
+
themes: { light: "vitesse-light", dark: "vitesse-dark" }
|
|
167
|
+
},
|
|
168
|
+
ayu: { label: "Ayu", themes: { light: "ayu-light", dark: "ayu-dark" } },
|
|
169
|
+
material: {
|
|
170
|
+
label: "Material",
|
|
171
|
+
themes: { light: "material-theme-lighter", dark: "material-theme" }
|
|
172
|
+
},
|
|
173
|
+
"night-owl": {
|
|
174
|
+
label: "Night Owl",
|
|
175
|
+
themes: { light: "night-owl-light", dark: "night-owl" }
|
|
176
|
+
},
|
|
177
|
+
min: { label: "Min", themes: { light: "min-light", dark: "min-dark" } }
|
|
178
|
+
};
|
|
179
|
+
const diffThemeNames = (theme) => families[theme].themes;
|
|
180
|
+
const diffThemeOptions = diffThemes.map((theme) => ({
|
|
181
|
+
value: theme,
|
|
182
|
+
label: families[theme].label
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const isDiffWhitespace = (value) => value === "hide" || value === "show";
|
|
186
|
+
|
|
187
|
+
const fenceOpen = /^( {0,3})(`{3,}(?!`)|~{3,}(?!~))(.*)$/;
|
|
188
|
+
const withoutIndent = (line, indent) => {
|
|
189
|
+
let index = 0;
|
|
190
|
+
while (index < indent && line[index] === " ") index += 1;
|
|
191
|
+
return line.slice(index);
|
|
192
|
+
};
|
|
193
|
+
const parseSuggestions = (body) => {
|
|
194
|
+
const lines = body.replaceAll("\r\n", "\n").split("\n");
|
|
195
|
+
const suggestions = [];
|
|
196
|
+
let index = 0;
|
|
197
|
+
while (index < lines.length) {
|
|
198
|
+
const open = fenceOpen.exec(lines[index] ?? "");
|
|
199
|
+
index += 1;
|
|
200
|
+
if (open === null) continue;
|
|
201
|
+
const [, indent = "", fence = "", info = ""] = open;
|
|
202
|
+
if (fence.startsWith("`") && info.includes("`")) continue;
|
|
203
|
+
const close = new RegExp(
|
|
204
|
+
`^ {0,3}${fence[0] ?? ""}{${String(fence.length)},}[ \\t]*$`
|
|
205
|
+
);
|
|
206
|
+
const content = [];
|
|
207
|
+
while (index < lines.length && !close.test(lines[index] ?? "")) {
|
|
208
|
+
content.push(withoutIndent(lines[index] ?? "", indent.length));
|
|
209
|
+
index += 1;
|
|
210
|
+
}
|
|
211
|
+
index += 1;
|
|
212
|
+
if (info.trim().split(/\s/)[0] === "suggestion") suggestions.push(content);
|
|
213
|
+
}
|
|
214
|
+
return suggestions;
|
|
215
|
+
};
|
|
216
|
+
const rangeLength = (start, end) => {
|
|
217
|
+
if (end === null) return null;
|
|
218
|
+
return end - (start ?? end) + 1;
|
|
219
|
+
};
|
|
220
|
+
const suggestionOriginalLines = (thread) => {
|
|
221
|
+
const count = thread.line === null ? rangeLength(thread.originalStartLine, thread.originalLine) : rangeLength(thread.startLine, thread.line);
|
|
222
|
+
if (count === null || count < 1) return null;
|
|
223
|
+
const dropped = thread.side === "RIGHT" ? "-" : "+";
|
|
224
|
+
const sideLines = thread.diffHunk.replaceAll("\r\n", "\n").split("\n").filter(
|
|
225
|
+
(line) => !line.startsWith("@@") && !line.startsWith("\\") && !line.startsWith(dropped)
|
|
226
|
+
);
|
|
227
|
+
if (sideLines.length < count) return null;
|
|
228
|
+
return sideLines.slice(-count).map((line) => line.slice(1));
|
|
229
|
+
};
|
|
230
|
+
const suggestionCommitRefusal = ({
|
|
231
|
+
state,
|
|
232
|
+
viewerCanApplySuggestion,
|
|
233
|
+
suggestionCount,
|
|
234
|
+
outdated,
|
|
235
|
+
line,
|
|
236
|
+
side,
|
|
237
|
+
startSide
|
|
238
|
+
}) => {
|
|
239
|
+
if (state === "closed" || state === "merged") {
|
|
240
|
+
return `The pull request is ${state}`;
|
|
241
|
+
}
|
|
242
|
+
if (!viewerCanApplySuggestion) {
|
|
243
|
+
return "GitHub does not let you commit to this pull request's branch";
|
|
244
|
+
}
|
|
245
|
+
if (suggestionCount === 0) return "The comment holds no suggestion";
|
|
246
|
+
if (suggestionCount > 1) {
|
|
247
|
+
return "The comment holds more than one suggestion: GitHub commits one per comment";
|
|
248
|
+
}
|
|
249
|
+
if (outdated || line === null) {
|
|
250
|
+
return "Outdated: the lines it replaces changed since";
|
|
251
|
+
}
|
|
252
|
+
if (side === "LEFT" || startSide === "LEFT") {
|
|
253
|
+
return "It is on removed lines, which the branch no longer has";
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
};
|
|
257
|
+
const suggestionRangesOverlap = (a, b) => a.path === b.path && (a.startLine ?? a.line) <= b.line && (b.startLine ?? b.line) <= a.line;
|
|
258
|
+
|
|
259
|
+
const prFields = `fragment prFields on PullRequest {
|
|
260
|
+
number
|
|
261
|
+
state
|
|
262
|
+
isDraft
|
|
263
|
+
reviewDecision
|
|
264
|
+
mergedAt
|
|
265
|
+
mergeable
|
|
266
|
+
headRefOid
|
|
267
|
+
baseRefName
|
|
268
|
+
commits(last: 1) {
|
|
269
|
+
nodes {
|
|
270
|
+
commit {
|
|
271
|
+
statusCheckRollup {
|
|
272
|
+
contexts(first: 1) {
|
|
273
|
+
checkRunCountsByState { count state }
|
|
274
|
+
statusContextCountsByState { count state }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}`;
|
|
281
|
+
const splitOwnerRepo = (ownerRepo) => {
|
|
282
|
+
const segments = ownerRepo.split("/");
|
|
283
|
+
const [owner, name] = segments;
|
|
284
|
+
if (segments.length !== 2 || !owner || !name) return null;
|
|
285
|
+
return { owner, name };
|
|
286
|
+
};
|
|
287
|
+
const buildPrQuery = (targets) => {
|
|
288
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
289
|
+
const targetsByAlias = /* @__PURE__ */ new Map();
|
|
290
|
+
targets.forEach((target, index) => {
|
|
291
|
+
if (target.branch === "" || splitOwnerRepo(target.ownerRepo) === null) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const alias = `t${String(index)}`;
|
|
295
|
+
targetsByAlias.set(alias, target);
|
|
296
|
+
const existing = byRepo.get(target.ownerRepo);
|
|
297
|
+
const entry = { alias, branch: target.branch };
|
|
298
|
+
if (existing) existing.push(entry);
|
|
299
|
+
else byRepo.set(target.ownerRepo, [entry]);
|
|
300
|
+
});
|
|
301
|
+
if (targetsByAlias.size === 0) return null;
|
|
302
|
+
const repoBlocks = [...byRepo].map(([ownerRepo, branches], repoIndex) => {
|
|
303
|
+
const { owner, name } = splitOwnerRepo(ownerRepo);
|
|
304
|
+
const branchFields = branches.map(
|
|
305
|
+
({ alias, branch }) => (
|
|
306
|
+
// Newest first: a branch that had a closed PR and then a new one is
|
|
307
|
+
// described by the new one, and `first: 1` is what keeps the cost flat.
|
|
308
|
+
` ${alias}: pullRequests(headRefName: ${JSON.stringify(branch)}, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ...prFields } }`
|
|
309
|
+
)
|
|
310
|
+
);
|
|
311
|
+
return [
|
|
312
|
+
` r${String(repoIndex)}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {`,
|
|
313
|
+
...branchFields,
|
|
314
|
+
" }"
|
|
315
|
+
].join("\n");
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
query: `query {
|
|
319
|
+
${repoBlocks.join("\n")}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
${prFields}`,
|
|
323
|
+
targetsByAlias
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
const checkOutcomes = /* @__PURE__ */ new Map([
|
|
327
|
+
["ACTION_REQUIRED", "failed"],
|
|
328
|
+
["ERROR", "failed"],
|
|
329
|
+
["FAILURE", "failed"],
|
|
330
|
+
["STARTUP_FAILURE", "failed"],
|
|
331
|
+
["TIMED_OUT", "failed"],
|
|
332
|
+
["COMPLETED", "passed"],
|
|
333
|
+
["NEUTRAL", "passed"],
|
|
334
|
+
["SKIPPED", "passed"],
|
|
335
|
+
["SUCCESS", "passed"],
|
|
336
|
+
["CANCELLED", "pending"],
|
|
337
|
+
["EXPECTED", "pending"],
|
|
338
|
+
["IN_PROGRESS", "pending"],
|
|
339
|
+
["PENDING", "pending"],
|
|
340
|
+
["QUEUED", "pending"],
|
|
341
|
+
["STALE", "pending"],
|
|
342
|
+
["WAITING", "pending"]
|
|
343
|
+
]);
|
|
344
|
+
const countChecks = (pullRequest) => {
|
|
345
|
+
const totals = { failed: 0, passed: 0, pending: 0 };
|
|
346
|
+
const contexts = pullRequest.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
347
|
+
for (const { count, state } of [
|
|
348
|
+
...contexts?.checkRunCountsByState ?? [],
|
|
349
|
+
...contexts?.statusContextCountsByState ?? []
|
|
350
|
+
]) {
|
|
351
|
+
const outcome = checkOutcomes.get(state);
|
|
352
|
+
if (outcome !== void 0) totals[outcome] += count;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
checksPassed: totals.passed,
|
|
356
|
+
checksFailed: totals.failed,
|
|
357
|
+
checksPending: totals.pending
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
const prStates$1 = /* @__PURE__ */ new Map([
|
|
361
|
+
["CLOSED", "closed"],
|
|
362
|
+
["MERGED", "merged"]
|
|
363
|
+
]);
|
|
364
|
+
const toPrState$1 = (state, isDraft) => {
|
|
365
|
+
if (state === "OPEN") return isDraft ? "draft" : "open";
|
|
366
|
+
return prStates$1.get(state) ?? null;
|
|
367
|
+
};
|
|
368
|
+
const reviewDecisions$1 = /* @__PURE__ */ new Map([
|
|
369
|
+
["APPROVED", "approved"],
|
|
370
|
+
["CHANGES_REQUESTED", "changesRequested"],
|
|
371
|
+
["REVIEW_REQUIRED", "reviewRequired"]
|
|
372
|
+
]);
|
|
373
|
+
const mergeables$2 = /* @__PURE__ */ new Map([
|
|
374
|
+
["CONFLICTING", "conflicting"],
|
|
375
|
+
["MERGEABLE", "mergeable"]
|
|
376
|
+
]);
|
|
377
|
+
const toFetchedPr = (target, pullRequest) => {
|
|
378
|
+
const state = toPrState$1(pullRequest.state, pullRequest.isDraft);
|
|
379
|
+
if (state === null) return null;
|
|
380
|
+
return {
|
|
381
|
+
target,
|
|
382
|
+
number: pullRequest.number,
|
|
383
|
+
state,
|
|
384
|
+
reviewDecision: pullRequest.reviewDecision === null ? null : reviewDecisions$1.get(pullRequest.reviewDecision) ?? null,
|
|
385
|
+
mergeable: mergeables$2.get(pullRequest.mergeable) ?? "unknown",
|
|
386
|
+
mergedAt: pullRequest.mergedAt,
|
|
387
|
+
headSha: pullRequest.headRefOid,
|
|
388
|
+
baseRef: pullRequest.baseRefName,
|
|
389
|
+
...countChecks(pullRequest)
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
const parsePrQueryResponse = (data, targetsByAlias) => {
|
|
393
|
+
if (data === null) return [];
|
|
394
|
+
const found = [];
|
|
395
|
+
for (const repository of Object.values(data)) {
|
|
396
|
+
if (!repository) continue;
|
|
397
|
+
for (const [alias, connection] of Object.entries(repository)) {
|
|
398
|
+
const target = targetsByAlias.get(alias);
|
|
399
|
+
const pullRequest = connection?.nodes?.[0];
|
|
400
|
+
if (!target || !pullRequest) continue;
|
|
401
|
+
const fetched = toFetchedPr(target, pullRequest);
|
|
402
|
+
if (fetched !== null) found.push(fetched);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return found;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const suggestionsQuery = `query SuggestionsToApply($owner: String!, $name: String!, $number: Int!, $threadIds: [ID!]!, $commentIds: [ID!]!) {
|
|
409
|
+
viewer { login }
|
|
410
|
+
repository(owner: $owner, name: $name) {
|
|
411
|
+
pullRequest(number: $number) {
|
|
412
|
+
id state headRefOid headRefName viewerCanApplySuggestion
|
|
413
|
+
headRepository { nameWithOwner }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
threads: nodes(ids: $threadIds) {
|
|
417
|
+
... on PullRequestReviewThread {
|
|
418
|
+
id path line startLine diffSide startDiffSide isOutdated isResolved
|
|
419
|
+
pullRequest { id }
|
|
420
|
+
comments(first: 1) { nodes { id } }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
comments: nodes(ids: $commentIds) {
|
|
424
|
+
... on PullRequestReviewComment {
|
|
425
|
+
id body state
|
|
426
|
+
replyTo { id }
|
|
427
|
+
author { login ... on User { databaseId name } }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}`;
|
|
431
|
+
const toPrState = (state) => {
|
|
432
|
+
if (state === "MERGED") return "merged";
|
|
433
|
+
if (state === "CLOSED") return "closed";
|
|
434
|
+
return "open";
|
|
435
|
+
};
|
|
436
|
+
const toSide$1 = (side) => {
|
|
437
|
+
if (side === null) return null;
|
|
438
|
+
return side === "LEFT" ? "LEFT" : "RIGHT";
|
|
439
|
+
};
|
|
440
|
+
const planSuggestions = (answer, edit) => {
|
|
441
|
+
const pullRequest = answer.repository?.pullRequest;
|
|
442
|
+
if (pullRequest === null || pullRequest === void 0) {
|
|
443
|
+
throw new Error("the pull request is not found");
|
|
444
|
+
}
|
|
445
|
+
if (pullRequest.headRefOid !== edit.expectedHeadSha) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`the pull request moved on to ${pullRequest.headRefOid.slice(0, 7)} since ${edit.expectedHeadSha.slice(0, 7)}: read it again before committing`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (pullRequest.headRepository === null) {
|
|
451
|
+
throw new Error("the head branch's repository no longer exists");
|
|
452
|
+
}
|
|
453
|
+
const suggestions = [];
|
|
454
|
+
edit.suggestions.forEach((ref, index) => {
|
|
455
|
+
const thread = answer.threads[index];
|
|
456
|
+
const comment = answer.comments[index];
|
|
457
|
+
if (thread?.id !== ref.threadId) {
|
|
458
|
+
throw new Error(`no review thread ${ref.threadId}`);
|
|
459
|
+
}
|
|
460
|
+
if (thread.pullRequest.id !== pullRequest.id) {
|
|
461
|
+
throw new Error(`thread ${ref.threadId} is not of this pull request`);
|
|
462
|
+
}
|
|
463
|
+
if (comment?.id !== ref.commentId) {
|
|
464
|
+
throw new Error(`no review comment ${ref.commentId}`);
|
|
465
|
+
}
|
|
466
|
+
const root = comment.replyTo?.id ?? comment.id;
|
|
467
|
+
if (root !== thread.comments.nodes?.[0]?.id) {
|
|
468
|
+
throw new Error(`comment ${ref.commentId} is not in that thread`);
|
|
469
|
+
}
|
|
470
|
+
if (comment.state === "PENDING") {
|
|
471
|
+
throw new Error("a suggestion in a pending review cannot be committed");
|
|
472
|
+
}
|
|
473
|
+
const parsed = parseSuggestions(comment.body);
|
|
474
|
+
const refusal = suggestionCommitRefusal({
|
|
475
|
+
state: toPrState(pullRequest.state),
|
|
476
|
+
viewerCanApplySuggestion: pullRequest.viewerCanApplySuggestion,
|
|
477
|
+
suggestionCount: parsed.length,
|
|
478
|
+
outdated: thread.isOutdated,
|
|
479
|
+
line: thread.line,
|
|
480
|
+
side: toSide$1(thread.diffSide) ?? "RIGHT",
|
|
481
|
+
startSide: toSide$1(thread.startDiffSide)
|
|
482
|
+
});
|
|
483
|
+
const [lines] = parsed;
|
|
484
|
+
if (refusal !== null || thread.line === null || lines === void 0) {
|
|
485
|
+
throw new Error(`${thread.path}: ${refusal ?? "nothing to commit"}`);
|
|
486
|
+
}
|
|
487
|
+
const planned = {
|
|
488
|
+
threadId: thread.id,
|
|
489
|
+
resolved: thread.isResolved,
|
|
490
|
+
path: thread.path,
|
|
491
|
+
startLine: thread.startLine,
|
|
492
|
+
line: thread.line,
|
|
493
|
+
lines,
|
|
494
|
+
author: comment.author
|
|
495
|
+
};
|
|
496
|
+
if (suggestions.some((other) => suggestionRangesOverlap(other, planned))) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`${planned.path}: two suggestions of the batch replace line ${String(planned.line)}`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
suggestions.push(planned);
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
repositoryNameWithOwner: pullRequest.headRepository.nameWithOwner,
|
|
505
|
+
branchName: pullRequest.headRefName,
|
|
506
|
+
viewerLogin: answer.viewer.login,
|
|
507
|
+
suggestions
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
const applySuggestionsToText = (text, suggestions) => {
|
|
511
|
+
const eol = text.includes("\r\n") ? "\r" : "";
|
|
512
|
+
const lines = text.split("\n");
|
|
513
|
+
const endsWithNewline = lines.at(-1) === "";
|
|
514
|
+
const lineCount = endsWithNewline ? lines.length - 1 : lines.length;
|
|
515
|
+
const bottomFirst = suggestions.toSorted((a, b) => b.line - a.line);
|
|
516
|
+
for (const { startLine, line, lines: replacement } of bottomFirst) {
|
|
517
|
+
if (line > lineCount) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`line ${String(line)} is past the end of the file, ${String(lineCount)} lines long`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const first = startLine ?? line;
|
|
523
|
+
const isLastLine = line === lineCount && !endsWithNewline;
|
|
524
|
+
const put = replacement.map(
|
|
525
|
+
(content, index) => isLastLine && index === replacement.length - 1 ? content : content + eol
|
|
526
|
+
);
|
|
527
|
+
lines.splice(first - 1, line - first + 1, ...put);
|
|
528
|
+
}
|
|
529
|
+
return lines.join("\n");
|
|
530
|
+
};
|
|
531
|
+
const coAuthorTrailers = (authors, viewerLogin) => {
|
|
532
|
+
const seen = /* @__PURE__ */ new Set([viewerLogin]);
|
|
533
|
+
const trailers = [];
|
|
534
|
+
for (const author of authors) {
|
|
535
|
+
if (author?.databaseId === void 0 || seen.has(author.login)) continue;
|
|
536
|
+
seen.add(author.login);
|
|
537
|
+
const name = author.name === null || author.name === void 0 || author.name === "" ? author.login : author.name;
|
|
538
|
+
trailers.push(
|
|
539
|
+
`Co-authored-by: ${name} <${String(author.databaseId)}+${author.login}@users.noreply.github.com>`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return trailers;
|
|
543
|
+
};
|
|
544
|
+
const fileQuery = (count) => {
|
|
545
|
+
const indexes = Array.from({ length: count }, (_, index) => index);
|
|
546
|
+
const variables = indexes.map((index) => `$e${String(index)}: String!`).join(", ");
|
|
547
|
+
const fields = indexes.map(
|
|
548
|
+
(index) => `f${String(index)}: object(expression: $e${String(index)}) { ... on Blob { text isBinary isTruncated } }`
|
|
549
|
+
).join("\n ");
|
|
550
|
+
return `query SuggestedFiles($owner: String!, $name: String!, ${variables}) {
|
|
551
|
+
repository(owner: $owner, name: $name) {
|
|
552
|
+
${fields}
|
|
553
|
+
}
|
|
554
|
+
}`;
|
|
555
|
+
};
|
|
556
|
+
const readFiles = async (transport, nameWithOwner, sha, paths) => {
|
|
557
|
+
const split = splitOwnerRepo(nameWithOwner);
|
|
558
|
+
if (split === null) throw new Error(`${nameWithOwner} is not owner/repo`);
|
|
559
|
+
const variables = Object.fromEntries(
|
|
560
|
+
paths.map((path, index) => [`e${String(index)}`, `${sha}:${path}`])
|
|
561
|
+
);
|
|
562
|
+
const { data, errors } = await transport.graphql(fileQuery(paths.length), { ...split, ...variables });
|
|
563
|
+
if (errors.length > 0) {
|
|
564
|
+
throw new Error(`GitHub refused reading the files: ${errors.join("; ")}`);
|
|
565
|
+
}
|
|
566
|
+
const files = /* @__PURE__ */ new Map();
|
|
567
|
+
paths.forEach((path, index) => {
|
|
568
|
+
const blob = data?.repository?.[`f${String(index)}`];
|
|
569
|
+
const text = blob?.text;
|
|
570
|
+
if (text === null || text === void 0 || blob?.isBinary === true || blob?.isTruncated === true) {
|
|
571
|
+
throw new Error(`${path} cannot be read as text at ${sha.slice(0, 7)}`);
|
|
572
|
+
}
|
|
573
|
+
files.set(path, text);
|
|
574
|
+
});
|
|
575
|
+
return files;
|
|
576
|
+
};
|
|
577
|
+
const createCommitMutation = `mutation CommitSuggestions($input: CreateCommitOnBranchInput!) {
|
|
578
|
+
createCommitOnBranch(input: $input) { commit { oid } }
|
|
579
|
+
}`;
|
|
580
|
+
const toBase64 = (text) => {
|
|
581
|
+
let binary = "";
|
|
582
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
583
|
+
binary += String.fromCodePoint(byte);
|
|
584
|
+
}
|
|
585
|
+
return btoa(binary);
|
|
586
|
+
};
|
|
587
|
+
const messageBody = (body, trailers) => [body.trim(), trailers.join("\n")].filter((part) => part !== "").join("\n\n");
|
|
588
|
+
const commitSuggestions = async (transport, {
|
|
589
|
+
ownerRepo,
|
|
590
|
+
number,
|
|
591
|
+
edit
|
|
592
|
+
}) => {
|
|
593
|
+
const split = splitOwnerRepo(ownerRepo);
|
|
594
|
+
if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
|
|
595
|
+
const lookup = await transport.graphql(suggestionsQuery, {
|
|
596
|
+
...split,
|
|
597
|
+
number,
|
|
598
|
+
threadIds: edit.suggestions.map(({ threadId }) => threadId),
|
|
599
|
+
commentIds: edit.suggestions.map(({ commentId }) => commentId)
|
|
600
|
+
});
|
|
601
|
+
if (lookup.data === null) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`GitHub refused reading the suggestions: ${lookup.errors.join("; ") || "no answer"}`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const plan = planSuggestions(lookup.data, edit);
|
|
607
|
+
const paths = [...new Set(plan.suggestions.map(({ path }) => path))];
|
|
608
|
+
const files = await readFiles(
|
|
609
|
+
transport,
|
|
610
|
+
plan.repositoryNameWithOwner,
|
|
611
|
+
edit.expectedHeadSha,
|
|
612
|
+
paths
|
|
613
|
+
);
|
|
614
|
+
const additions = paths.map((path) => {
|
|
615
|
+
const text = applySuggestionsToText(
|
|
616
|
+
files.get(path) ?? "",
|
|
617
|
+
plan.suggestions.filter((suggestion) => suggestion.path === path)
|
|
618
|
+
);
|
|
619
|
+
return { path, contents: toBase64(text) };
|
|
620
|
+
});
|
|
621
|
+
const { errors } = await transport.graphql(createCommitMutation, {
|
|
622
|
+
input: {
|
|
623
|
+
branch: {
|
|
624
|
+
repositoryNameWithOwner: plan.repositoryNameWithOwner,
|
|
625
|
+
branchName: plan.branchName
|
|
626
|
+
},
|
|
627
|
+
expectedHeadOid: edit.expectedHeadSha,
|
|
628
|
+
message: {
|
|
629
|
+
headline: edit.headline,
|
|
630
|
+
body: messageBody(
|
|
631
|
+
edit.body,
|
|
632
|
+
coAuthorTrailers(
|
|
633
|
+
plan.suggestions.map(({ author }) => author),
|
|
634
|
+
plan.viewerLogin
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
},
|
|
638
|
+
fileChanges: { additions }
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
if (errors.length > 0) {
|
|
642
|
+
throw new Error(`GitHub refused the commit: ${errors.join("; ")}`);
|
|
643
|
+
}
|
|
644
|
+
return plan.suggestions.filter(({ resolved }) => !resolved).map(({ threadId }) => threadId);
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const pullRequestReactionContents = [
|
|
648
|
+
"thumbsUp",
|
|
649
|
+
"thumbsDown",
|
|
650
|
+
"laugh",
|
|
651
|
+
"hooray",
|
|
652
|
+
"confused",
|
|
653
|
+
"heart",
|
|
654
|
+
"rocket",
|
|
655
|
+
"eyes"
|
|
656
|
+
];
|
|
657
|
+
const isPullRequestReactionContent = (value) => pullRequestReactionContents.includes(value);
|
|
658
|
+
|
|
659
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
660
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
661
|
+
const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
662
|
+
const isSha = (value) => typeof value === "string" && /^[\da-f]{40}$/i.test(value);
|
|
663
|
+
const isSide = (value) => value === "LEFT" || value === "RIGHT";
|
|
664
|
+
const mergeMethods = ["merge", "rebase", "squash"];
|
|
665
|
+
const reviewEvents = [
|
|
666
|
+
"approve",
|
|
667
|
+
"comment",
|
|
668
|
+
"requestChanges"
|
|
669
|
+
];
|
|
670
|
+
const editCommentTargets = ["issueComment", "review", "reviewComment"];
|
|
671
|
+
const parseMergeCommitMessage = (value) => {
|
|
672
|
+
if (typeof value !== "object" || value === null) {
|
|
673
|
+
return "a commit message must be an object";
|
|
674
|
+
}
|
|
675
|
+
const message = value;
|
|
676
|
+
if (!isNonEmptyString(message.title)) return "a commit needs a title";
|
|
677
|
+
if (typeof message.body !== "string") return "a commit body must be a string";
|
|
678
|
+
return { title: message.title, body: message.body };
|
|
679
|
+
};
|
|
680
|
+
const parseDraftComment = (value) => {
|
|
681
|
+
if (typeof value !== "object" || value === null) {
|
|
682
|
+
return "a comment must be an object";
|
|
683
|
+
}
|
|
684
|
+
const comment = value;
|
|
685
|
+
if (!isNonEmptyString(comment.path)) return "a comment needs a path";
|
|
686
|
+
if (!isNonEmptyString(comment.body)) return "a comment cannot be empty";
|
|
687
|
+
if (!isSide(comment.side)) return "a comment side must be LEFT or RIGHT";
|
|
688
|
+
if (!isPositiveInteger(comment.line)) {
|
|
689
|
+
return "a comment line must be a positive integer";
|
|
690
|
+
}
|
|
691
|
+
const startLine = comment.startLine ?? null;
|
|
692
|
+
const startSide = comment.startSide ?? null;
|
|
693
|
+
if (startLine === null) {
|
|
694
|
+
if (startSide !== null) return "startSide needs a startLine";
|
|
695
|
+
} else {
|
|
696
|
+
if (!isPositiveInteger(startLine)) {
|
|
697
|
+
return "startLine must be a positive integer";
|
|
698
|
+
}
|
|
699
|
+
if (startLine > comment.line) return "startLine cannot follow line";
|
|
700
|
+
if (!isSide(startSide)) return "a range needs startSide, LEFT or RIGHT";
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
path: comment.path,
|
|
704
|
+
side: comment.side,
|
|
705
|
+
line: comment.line,
|
|
706
|
+
startLine,
|
|
707
|
+
startSide,
|
|
708
|
+
body: comment.body
|
|
709
|
+
};
|
|
710
|
+
};
|
|
711
|
+
const parseReview = (edit) => {
|
|
712
|
+
if (!reviewEvents.includes(edit.event)) {
|
|
713
|
+
return "a review event must be approve, comment or requestChanges";
|
|
714
|
+
}
|
|
715
|
+
const event = edit.event;
|
|
716
|
+
if (typeof edit.body !== "string") return "a review body must be a string";
|
|
717
|
+
if (!isSha(edit.commitId)) return "a review needs the head commit sha";
|
|
718
|
+
if (!Array.isArray(edit.comments)) return "a review takes a comments array";
|
|
719
|
+
const comments = [];
|
|
720
|
+
for (const raw of edit.comments) {
|
|
721
|
+
const parsed = parseDraftComment(raw);
|
|
722
|
+
if (typeof parsed === "string") return parsed;
|
|
723
|
+
comments.push(parsed);
|
|
724
|
+
}
|
|
725
|
+
if (event === "requestChanges" && edit.body.trim() === "") {
|
|
726
|
+
return "requesting changes needs a body";
|
|
727
|
+
}
|
|
728
|
+
if (event === "comment" && edit.body.trim() === "" && comments.length === 0) {
|
|
729
|
+
return "a comment review needs a body or a comment";
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
kind: "review",
|
|
733
|
+
event,
|
|
734
|
+
body: edit.body,
|
|
735
|
+
commitId: edit.commitId,
|
|
736
|
+
comments
|
|
737
|
+
};
|
|
738
|
+
};
|
|
739
|
+
const parseSuggestionRef = (value) => {
|
|
740
|
+
if (typeof value !== "object" || value === null) {
|
|
741
|
+
return "a suggestion must be an object";
|
|
742
|
+
}
|
|
743
|
+
const ref = value;
|
|
744
|
+
if (!isNonEmptyString(ref.threadId)) return "a suggestion needs its threadId";
|
|
745
|
+
if (!isNonEmptyString(ref.commentId)) {
|
|
746
|
+
return "a suggestion needs its commentId";
|
|
747
|
+
}
|
|
748
|
+
return { threadId: ref.threadId, commentId: ref.commentId };
|
|
749
|
+
};
|
|
750
|
+
const parseApplySuggestions = (edit) => {
|
|
751
|
+
if (!isSha(edit.expectedHeadSha)) {
|
|
752
|
+
return "committing suggestions needs the head sha the screen read";
|
|
753
|
+
}
|
|
754
|
+
if (!Array.isArray(edit.suggestions) || edit.suggestions.length === 0) {
|
|
755
|
+
return "suggestions must be a non-empty array";
|
|
756
|
+
}
|
|
757
|
+
const suggestions = [];
|
|
758
|
+
for (const raw of edit.suggestions) {
|
|
759
|
+
const parsed = parseSuggestionRef(raw);
|
|
760
|
+
if (typeof parsed === "string") return parsed;
|
|
761
|
+
suggestions.push(parsed);
|
|
762
|
+
}
|
|
763
|
+
if (!isNonEmptyString(edit.headline)) return "a commit needs a headline";
|
|
764
|
+
if (typeof edit.body !== "string") return "a commit body must be a string";
|
|
765
|
+
return {
|
|
766
|
+
kind: "applySuggestions",
|
|
767
|
+
expectedHeadSha: edit.expectedHeadSha,
|
|
768
|
+
suggestions,
|
|
769
|
+
headline: edit.headline.trim(),
|
|
770
|
+
body: edit.body
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
const parsePullRequestEdit = (value) => {
|
|
774
|
+
if (typeof value !== "object" || value === null) return "edit is required";
|
|
775
|
+
const edit = value;
|
|
776
|
+
switch (edit.kind) {
|
|
777
|
+
case "title": {
|
|
778
|
+
if (typeof edit.title !== "string" || edit.title.trim() === "") {
|
|
779
|
+
return "a title cannot be empty";
|
|
780
|
+
}
|
|
781
|
+
return { kind: "title", title: edit.title.trim() };
|
|
782
|
+
}
|
|
783
|
+
case "labels": {
|
|
784
|
+
if (!isStringArray(edit.add) || !isStringArray(edit.remove)) {
|
|
785
|
+
return "labels take add and remove, arrays of names";
|
|
786
|
+
}
|
|
787
|
+
return { kind: "labels", add: edit.add, remove: edit.remove };
|
|
788
|
+
}
|
|
789
|
+
case "reviewers": {
|
|
790
|
+
if (!isStringArray(edit.add) || !isStringArray(edit.remove) || !isStringArray(edit.addTeams) || !isStringArray(edit.removeTeams)) {
|
|
791
|
+
return "reviewers take add, remove, addTeams and removeTeams, arrays of logins and team slugs";
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
kind: "reviewers",
|
|
795
|
+
add: edit.add,
|
|
796
|
+
remove: edit.remove,
|
|
797
|
+
addTeams: edit.addTeams,
|
|
798
|
+
removeTeams: edit.removeTeams
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
case "draft": {
|
|
802
|
+
if (typeof edit.draft !== "boolean") return "draft must be a boolean";
|
|
803
|
+
return { kind: "draft", draft: edit.draft };
|
|
804
|
+
}
|
|
805
|
+
case "assignees": {
|
|
806
|
+
if (!isStringArray(edit.add) || !isStringArray(edit.remove)) {
|
|
807
|
+
return "assignees take add and remove, arrays of logins";
|
|
808
|
+
}
|
|
809
|
+
return { kind: "assignees", add: edit.add, remove: edit.remove };
|
|
810
|
+
}
|
|
811
|
+
case "body": {
|
|
812
|
+
if (typeof edit.body !== "string") return "body must be a string";
|
|
813
|
+
return { kind: "body", body: edit.body };
|
|
814
|
+
}
|
|
815
|
+
case "state": {
|
|
816
|
+
if (edit.state !== "closed" && edit.state !== "open") {
|
|
817
|
+
return "state must be closed or open";
|
|
818
|
+
}
|
|
819
|
+
return { kind: "state", state: edit.state };
|
|
820
|
+
}
|
|
821
|
+
case "merge": {
|
|
822
|
+
if (!mergeMethods.includes(edit.method)) {
|
|
823
|
+
return "a merge method must be merge, rebase or squash";
|
|
824
|
+
}
|
|
825
|
+
if (!isSha(edit.expectedHeadSha)) {
|
|
826
|
+
return "a merge needs the head sha the screen read";
|
|
827
|
+
}
|
|
828
|
+
const merge = {
|
|
829
|
+
kind: "merge",
|
|
830
|
+
method: edit.method,
|
|
831
|
+
expectedHeadSha: edit.expectedHeadSha
|
|
832
|
+
};
|
|
833
|
+
if (edit.commitMessage === void 0) return merge;
|
|
834
|
+
if (merge.method === "rebase") return "a rebase writes no commit message";
|
|
835
|
+
const commitMessage = parseMergeCommitMessage(edit.commitMessage);
|
|
836
|
+
if (typeof commitMessage === "string") return commitMessage;
|
|
837
|
+
return { ...merge, commitMessage };
|
|
838
|
+
}
|
|
839
|
+
case "updateBranch": {
|
|
840
|
+
if (!isSha(edit.expectedHeadSha)) {
|
|
841
|
+
return "updating the branch needs the head sha the screen read";
|
|
842
|
+
}
|
|
843
|
+
return { kind: "updateBranch", expectedHeadSha: edit.expectedHeadSha };
|
|
844
|
+
}
|
|
845
|
+
case "comment": {
|
|
846
|
+
if (!isNonEmptyString(edit.body)) return "a comment cannot be empty";
|
|
847
|
+
return { kind: "comment", body: edit.body };
|
|
848
|
+
}
|
|
849
|
+
case "editComment": {
|
|
850
|
+
if (!editCommentTargets.includes(
|
|
851
|
+
edit.target
|
|
852
|
+
)) {
|
|
853
|
+
return "editComment target must be issueComment, review or reviewComment";
|
|
854
|
+
}
|
|
855
|
+
if (!isPositiveInteger(edit.databaseId)) {
|
|
856
|
+
return "editComment needs the comment's databaseId";
|
|
857
|
+
}
|
|
858
|
+
if (!isNonEmptyString(edit.body)) return "a comment cannot be empty";
|
|
859
|
+
return {
|
|
860
|
+
kind: "editComment",
|
|
861
|
+
target: edit.target,
|
|
862
|
+
databaseId: edit.databaseId,
|
|
863
|
+
body: edit.body
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
case "reply": {
|
|
867
|
+
if (!isPositiveInteger(edit.inReplyTo)) {
|
|
868
|
+
return "a reply needs the databaseId of a comment of the thread";
|
|
869
|
+
}
|
|
870
|
+
if (!isNonEmptyString(edit.body)) return "a reply cannot be empty";
|
|
871
|
+
return { kind: "reply", inReplyTo: edit.inReplyTo, body: edit.body };
|
|
872
|
+
}
|
|
873
|
+
case "reaction": {
|
|
874
|
+
if (!isNonEmptyString(edit.subjectId)) {
|
|
875
|
+
return "a reaction needs the id of what it reacts to";
|
|
876
|
+
}
|
|
877
|
+
if (!isPullRequestReactionContent(edit.content)) {
|
|
878
|
+
return "unknown reaction content";
|
|
879
|
+
}
|
|
880
|
+
if (typeof edit.reacted !== "boolean") return "reacted must be a boolean";
|
|
881
|
+
return {
|
|
882
|
+
kind: "reaction",
|
|
883
|
+
subjectId: edit.subjectId,
|
|
884
|
+
content: edit.content,
|
|
885
|
+
reacted: edit.reacted
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
case "resolveThread": {
|
|
889
|
+
if (!isNonEmptyString(edit.threadId)) return "a thread id is required";
|
|
890
|
+
if (typeof edit.resolved !== "boolean") {
|
|
891
|
+
return "resolved must be a boolean";
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
kind: "resolveThread",
|
|
895
|
+
threadId: edit.threadId,
|
|
896
|
+
resolved: edit.resolved
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
case "viewed": {
|
|
900
|
+
if (!isNonEmptyString(edit.path)) return "a file path is required";
|
|
901
|
+
if (typeof edit.viewed !== "boolean") return "viewed must be a boolean";
|
|
902
|
+
return { kind: "viewed", path: edit.path, viewed: edit.viewed };
|
|
903
|
+
}
|
|
904
|
+
case "review": {
|
|
905
|
+
return parseReview(edit);
|
|
906
|
+
}
|
|
907
|
+
case "applySuggestions": {
|
|
908
|
+
return parseApplySuggestions(edit);
|
|
909
|
+
}
|
|
910
|
+
case "inlineComment": {
|
|
911
|
+
if (!isSha(edit.commitId)) return "a comment needs the head commit sha";
|
|
912
|
+
const comment = parseDraftComment(edit.comment);
|
|
913
|
+
if (typeof comment === "string") return comment;
|
|
914
|
+
return { kind: "inlineComment", commitId: edit.commitId, comment };
|
|
915
|
+
}
|
|
916
|
+
default: {
|
|
917
|
+
return "unknown edit kind";
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const pullRequestIdQuery = `query PullRequestId($owner: String!, $name: String!, $number: Int!) {
|
|
922
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
|
|
923
|
+
}`;
|
|
924
|
+
const readyForReviewMutation = `mutation ReadyForReview($id: ID!) {
|
|
925
|
+
markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } }
|
|
926
|
+
}`;
|
|
927
|
+
const convertToDraftMutation = `mutation ConvertToDraft($id: ID!) {
|
|
928
|
+
convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } }
|
|
929
|
+
}`;
|
|
930
|
+
const resolveThreadMutation = `mutation ResolveThread($threadId: ID!) {
|
|
931
|
+
resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
|
|
932
|
+
}`;
|
|
933
|
+
const unresolveThreadMutation = `mutation UnresolveThread($threadId: ID!) {
|
|
934
|
+
unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
|
|
935
|
+
}`;
|
|
936
|
+
const addReactionMutation = `mutation AddReaction($subjectId: ID!, $content: ReactionContent!) {
|
|
937
|
+
addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
|
|
938
|
+
}`;
|
|
939
|
+
const removeReactionMutation = `mutation RemoveReaction($subjectId: ID!, $content: ReactionContent!) {
|
|
940
|
+
removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
|
|
941
|
+
}`;
|
|
942
|
+
const reactionContentNames = {
|
|
943
|
+
confused: "CONFUSED",
|
|
944
|
+
eyes: "EYES",
|
|
945
|
+
heart: "HEART",
|
|
946
|
+
hooray: "HOORAY",
|
|
947
|
+
laugh: "LAUGH",
|
|
948
|
+
rocket: "ROCKET",
|
|
949
|
+
thumbsDown: "THUMBS_DOWN",
|
|
950
|
+
thumbsUp: "THUMBS_UP"
|
|
951
|
+
};
|
|
952
|
+
const markViewedMutation = `mutation MarkViewed($id: ID!, $path: String!) {
|
|
953
|
+
markFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
|
|
954
|
+
}`;
|
|
955
|
+
const unmarkViewedMutation = `mutation UnmarkViewed($id: ID!, $path: String!) {
|
|
956
|
+
unmarkFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
|
|
957
|
+
}`;
|
|
958
|
+
const lookupPullRequestId = async (transport, { owner, name, number }) => {
|
|
959
|
+
const lookup = await transport.graphql(pullRequestIdQuery, { owner, name, number });
|
|
960
|
+
const id = lookup.data?.repository?.pullRequest?.id;
|
|
961
|
+
if (id === void 0) {
|
|
962
|
+
throw new Error(
|
|
963
|
+
`no pull request #${String(number)} in ${owner}/${name}: ${lookup.errors.join("; ") || "not found"}`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
return id;
|
|
967
|
+
};
|
|
968
|
+
const pullRequestHeadQuery$1 = `query PullRequestHead($owner: String!, $name: String!, $number: Int!) {
|
|
969
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) { headRefOid } }
|
|
970
|
+
}`;
|
|
971
|
+
const assertVerdictOnHead = async (transport, {
|
|
972
|
+
owner,
|
|
973
|
+
name,
|
|
974
|
+
number,
|
|
975
|
+
commitId
|
|
976
|
+
}) => {
|
|
977
|
+
const lookup = await transport.graphql(pullRequestHeadQuery$1, { owner, name, number });
|
|
978
|
+
const head = lookup.data?.repository?.pullRequest?.headRefOid;
|
|
979
|
+
if (head === void 0) {
|
|
980
|
+
throw new Error(
|
|
981
|
+
`no pull request #${String(number)} in ${owner}/${name}: ${lookup.errors.join("; ") || "not found"}`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
if (head !== commitId) {
|
|
985
|
+
throw new Error(
|
|
986
|
+
`the pull request moved on to ${head.slice(0, 7)} since ${commitId.slice(0, 7)}, the commit reviewed: only a comment review can be sent on it`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const runMutation = async (transport, query, variables, what) => {
|
|
991
|
+
const { errors } = await transport.graphql(query, variables);
|
|
992
|
+
if (errors.length > 0) {
|
|
993
|
+
throw new Error(`GitHub refused ${what}: ${errors.join("; ")}`);
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
const setDraft = async (transport, { owner, name, number, draft }) => {
|
|
997
|
+
const id = await lookupPullRequestId(transport, { owner, name, number });
|
|
998
|
+
await runMutation(
|
|
999
|
+
transport,
|
|
1000
|
+
draft ? convertToDraftMutation : readyForReviewMutation,
|
|
1001
|
+
{ id },
|
|
1002
|
+
"the draft change"
|
|
1003
|
+
);
|
|
1004
|
+
};
|
|
1005
|
+
const setViewed = async (transport, {
|
|
1006
|
+
owner,
|
|
1007
|
+
name,
|
|
1008
|
+
number,
|
|
1009
|
+
path,
|
|
1010
|
+
viewed
|
|
1011
|
+
}) => {
|
|
1012
|
+
const id = await lookupPullRequestId(transport, { owner, name, number });
|
|
1013
|
+
await runMutation(
|
|
1014
|
+
transport,
|
|
1015
|
+
viewed ? markViewedMutation : unmarkViewedMutation,
|
|
1016
|
+
{ id, path },
|
|
1017
|
+
"the viewed mark"
|
|
1018
|
+
);
|
|
1019
|
+
};
|
|
1020
|
+
const restComment = (comment) => ({
|
|
1021
|
+
path: comment.path,
|
|
1022
|
+
body: comment.body,
|
|
1023
|
+
line: comment.line,
|
|
1024
|
+
side: comment.side,
|
|
1025
|
+
...comment.startLine === null || comment.startSide === null ? {} : {
|
|
1026
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1027
|
+
start_line: comment.startLine,
|
|
1028
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1029
|
+
start_side: comment.startSide
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
const reviewEventNames = {
|
|
1033
|
+
approve: "APPROVE",
|
|
1034
|
+
comment: "COMMENT",
|
|
1035
|
+
requestChanges: "REQUEST_CHANGES"
|
|
1036
|
+
};
|
|
1037
|
+
const resolveCommittedThreads = async (transport, threadIds, logger) => {
|
|
1038
|
+
const failures = [];
|
|
1039
|
+
for (const threadId of threadIds) {
|
|
1040
|
+
try {
|
|
1041
|
+
await runMutation(
|
|
1042
|
+
transport,
|
|
1043
|
+
resolveThreadMutation,
|
|
1044
|
+
{ threadId },
|
|
1045
|
+
"resolving the thread"
|
|
1046
|
+
);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
logger?.warn("thread of a committed suggestion left unresolved", {
|
|
1049
|
+
threadId,
|
|
1050
|
+
error
|
|
1051
|
+
});
|
|
1052
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (failures.length === 0) return null;
|
|
1056
|
+
return `The suggestions were committed, but ${String(failures.length)} thread${failures.length === 1 ? "" : "s"} stayed unresolved: ${failures.join("; ")}`;
|
|
1057
|
+
};
|
|
1058
|
+
const editPullRequest = async (transport, { ownerRepo, number, edit }, options = {}) => {
|
|
1059
|
+
const split = splitOwnerRepo(ownerRepo);
|
|
1060
|
+
if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
|
|
1061
|
+
const repoPath = `repos/${encodeURIComponent(split.owner)}/${encodeURIComponent(split.name)}`;
|
|
1062
|
+
const pullPath = `${repoPath}/pulls/${String(number)}`;
|
|
1063
|
+
const issuePath = `${repoPath}/issues/${String(number)}`;
|
|
1064
|
+
switch (edit.kind) {
|
|
1065
|
+
case "title": {
|
|
1066
|
+
await transport.rest({
|
|
1067
|
+
method: "PATCH",
|
|
1068
|
+
path: pullPath,
|
|
1069
|
+
body: { title: edit.title }
|
|
1070
|
+
});
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
case "labels": {
|
|
1074
|
+
if (edit.add.length > 0) {
|
|
1075
|
+
await transport.rest({
|
|
1076
|
+
method: "POST",
|
|
1077
|
+
path: `${issuePath}/labels`,
|
|
1078
|
+
body: { labels: edit.add }
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
for (const label of edit.remove) {
|
|
1082
|
+
await transport.rest({
|
|
1083
|
+
method: "DELETE",
|
|
1084
|
+
path: `${issuePath}/labels/${encodeURIComponent(label)}`
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
case "reviewers": {
|
|
1090
|
+
if (edit.add.length > 0 || edit.addTeams.length > 0) {
|
|
1091
|
+
await transport.rest({
|
|
1092
|
+
method: "POST",
|
|
1093
|
+
path: `${pullPath}/requested_reviewers`,
|
|
1094
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1095
|
+
body: { reviewers: edit.add, team_reviewers: edit.addTeams }
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (edit.remove.length > 0 || edit.removeTeams.length > 0) {
|
|
1099
|
+
await transport.rest({
|
|
1100
|
+
method: "DELETE",
|
|
1101
|
+
path: `${pullPath}/requested_reviewers`,
|
|
1102
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1103
|
+
body: { reviewers: edit.remove, team_reviewers: edit.removeTeams }
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
case "draft": {
|
|
1109
|
+
await setDraft(transport, { ...split, number, draft: edit.draft });
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
case "body": {
|
|
1113
|
+
await transport.rest({
|
|
1114
|
+
method: "PATCH",
|
|
1115
|
+
path: pullPath,
|
|
1116
|
+
body: { body: edit.body }
|
|
1117
|
+
});
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
case "assignees": {
|
|
1121
|
+
if (edit.add.length > 0) {
|
|
1122
|
+
await transport.rest({
|
|
1123
|
+
method: "POST",
|
|
1124
|
+
path: `${issuePath}/assignees`,
|
|
1125
|
+
body: { assignees: edit.add }
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
if (edit.remove.length > 0) {
|
|
1129
|
+
await transport.rest({
|
|
1130
|
+
method: "DELETE",
|
|
1131
|
+
path: `${issuePath}/assignees`,
|
|
1132
|
+
body: { assignees: edit.remove }
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
case "state": {
|
|
1138
|
+
await transport.rest({
|
|
1139
|
+
method: "PATCH",
|
|
1140
|
+
path: pullPath,
|
|
1141
|
+
body: { state: edit.state }
|
|
1142
|
+
});
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
case "merge": {
|
|
1146
|
+
await transport.rest({
|
|
1147
|
+
method: "PUT",
|
|
1148
|
+
path: `${pullPath}/merge`,
|
|
1149
|
+
body: {
|
|
1150
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1151
|
+
merge_method: edit.method,
|
|
1152
|
+
sha: edit.expectedHeadSha,
|
|
1153
|
+
...edit.commitMessage === void 0 ? {} : {
|
|
1154
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1155
|
+
commit_title: edit.commitMessage.title,
|
|
1156
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1157
|
+
commit_message: edit.commitMessage.body
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
case "updateBranch": {
|
|
1164
|
+
await transport.rest({
|
|
1165
|
+
method: "PUT",
|
|
1166
|
+
path: `${pullPath}/update-branch`,
|
|
1167
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1168
|
+
body: { expected_head_sha: edit.expectedHeadSha }
|
|
1169
|
+
});
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
case "comment": {
|
|
1173
|
+
await transport.rest({
|
|
1174
|
+
method: "POST",
|
|
1175
|
+
path: `${issuePath}/comments`,
|
|
1176
|
+
body: { body: edit.body }
|
|
1177
|
+
});
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
1180
|
+
case "editComment": {
|
|
1181
|
+
const id = String(edit.databaseId);
|
|
1182
|
+
if (edit.target === "review") {
|
|
1183
|
+
await transport.rest({
|
|
1184
|
+
method: "PUT",
|
|
1185
|
+
path: `${pullPath}/reviews/${id}`,
|
|
1186
|
+
body: { body: edit.body }
|
|
1187
|
+
});
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
await transport.rest({
|
|
1191
|
+
method: "PATCH",
|
|
1192
|
+
path: edit.target === "issueComment" ? `${repoPath}/issues/comments/${id}` : `${repoPath}/pulls/comments/${id}`,
|
|
1193
|
+
body: { body: edit.body }
|
|
1194
|
+
});
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
case "reply": {
|
|
1198
|
+
await transport.rest({
|
|
1199
|
+
method: "POST",
|
|
1200
|
+
path: `${pullPath}/comments/${String(edit.inReplyTo)}/replies`,
|
|
1201
|
+
body: { body: edit.body }
|
|
1202
|
+
});
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
case "reaction": {
|
|
1206
|
+
await runMutation(
|
|
1207
|
+
transport,
|
|
1208
|
+
edit.reacted ? addReactionMutation : removeReactionMutation,
|
|
1209
|
+
{
|
|
1210
|
+
subjectId: edit.subjectId,
|
|
1211
|
+
content: reactionContentNames[edit.content]
|
|
1212
|
+
},
|
|
1213
|
+
"the reaction"
|
|
1214
|
+
);
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
case "resolveThread": {
|
|
1218
|
+
await runMutation(
|
|
1219
|
+
transport,
|
|
1220
|
+
edit.resolved ? resolveThreadMutation : unresolveThreadMutation,
|
|
1221
|
+
{ threadId: edit.threadId },
|
|
1222
|
+
"the thread change"
|
|
1223
|
+
);
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
case "viewed": {
|
|
1227
|
+
await setViewed(transport, {
|
|
1228
|
+
...split,
|
|
1229
|
+
number,
|
|
1230
|
+
path: edit.path,
|
|
1231
|
+
viewed: edit.viewed
|
|
1232
|
+
});
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
case "review": {
|
|
1236
|
+
if (edit.event !== "comment") {
|
|
1237
|
+
await assertVerdictOnHead(transport, {
|
|
1238
|
+
...split,
|
|
1239
|
+
number,
|
|
1240
|
+
commitId: edit.commitId
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
await transport.rest({
|
|
1244
|
+
method: "POST",
|
|
1245
|
+
path: `${pullPath}/reviews`,
|
|
1246
|
+
body: {
|
|
1247
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1248
|
+
commit_id: edit.commitId,
|
|
1249
|
+
...edit.body === "" ? {} : { body: edit.body },
|
|
1250
|
+
event: reviewEventNames[edit.event],
|
|
1251
|
+
comments: edit.comments.map(restComment)
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
case "applySuggestions": {
|
|
1257
|
+
return resolveCommittedThreads(
|
|
1258
|
+
transport,
|
|
1259
|
+
await commitSuggestions(transport, { ownerRepo, number, edit }),
|
|
1260
|
+
options.logger
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
case "inlineComment": {
|
|
1264
|
+
await transport.rest({
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
path: `${pullPath}/comments`,
|
|
1267
|
+
// eslint-disable-next-line camelcase -- GitHub's field name
|
|
1268
|
+
body: { commit_id: edit.commitId, ...restComment(edit.comment) }
|
|
1269
|
+
});
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
default: {
|
|
1273
|
+
throw new Error("unknown edit");
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
const applyPullRequestEdit = async (transport, request, options = {}) => {
|
|
1278
|
+
const followUpError = await editPullRequest(transport, request, options);
|
|
1279
|
+
const { edit } = request;
|
|
1280
|
+
if (edit.kind !== "viewed") return followUpError;
|
|
1281
|
+
try {
|
|
1282
|
+
options.onFileViewed?.(edit.path, edit.viewed);
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
options.logger?.error("viewed mark not applied to the last read", {
|
|
1285
|
+
error
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return followUpError;
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
const fetchCompareDiff = (transport, { ownerRepo, base, head }) => {
|
|
1292
|
+
const [owner, name, ...rest] = ownerRepo.split("/");
|
|
1293
|
+
if (owner === void 0 || name === void 0 || rest.length > 0) {
|
|
1294
|
+
throw new Error(`${ownerRepo} is not owner/repo`);
|
|
1295
|
+
}
|
|
1296
|
+
return transport.text({
|
|
1297
|
+
path: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${base}...${head}`,
|
|
1298
|
+
accept: "application/vnd.github.diff"
|
|
1299
|
+
});
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const maxDiffCells = 16e4;
|
|
1303
|
+
const toLines = (value) => {
|
|
1304
|
+
if (value === "") return [];
|
|
1305
|
+
const lines = value.split("\n");
|
|
1306
|
+
if (lines.at(-1) === "") lines.pop();
|
|
1307
|
+
return lines;
|
|
1308
|
+
};
|
|
1309
|
+
const context = (text) => ({ kind: "context", text });
|
|
1310
|
+
const removed = (text) => ({ kind: "removed", text });
|
|
1311
|
+
const added = (text) => ({ kind: "added", text });
|
|
1312
|
+
const wholeSides = (before, after) => [
|
|
1313
|
+
...before.map(removed),
|
|
1314
|
+
...after.map(added)
|
|
1315
|
+
];
|
|
1316
|
+
const shortestEdit = (before, after) => {
|
|
1317
|
+
const height = before.length;
|
|
1318
|
+
const width = after.length + 1;
|
|
1319
|
+
const common = new Uint32Array((height + 1) * width);
|
|
1320
|
+
for (let row2 = height - 1; row2 >= 0; row2--) {
|
|
1321
|
+
const beforeLine = before[row2];
|
|
1322
|
+
for (let column2 = after.length - 1; column2 >= 0; column2--) {
|
|
1323
|
+
common[row2 * width + column2] = beforeLine === after[column2] ? (common[(row2 + 1) * width + column2 + 1] ?? 0) + 1 : Math.max(
|
|
1324
|
+
common[(row2 + 1) * width + column2] ?? 0,
|
|
1325
|
+
common[row2 * width + column2 + 1] ?? 0
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const lines = [];
|
|
1330
|
+
let row = 0;
|
|
1331
|
+
let column = 0;
|
|
1332
|
+
while (row < height && column < after.length) {
|
|
1333
|
+
if (before[row] === after[column]) {
|
|
1334
|
+
lines.push(context(before[row] ?? ""));
|
|
1335
|
+
row++;
|
|
1336
|
+
column++;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if ((common[(row + 1) * width + column] ?? 0) >= (common[row * width + column + 1] ?? 0)) {
|
|
1340
|
+
lines.push(removed(before[row] ?? ""));
|
|
1341
|
+
row++;
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
lines.push(added(after[column] ?? ""));
|
|
1345
|
+
column++;
|
|
1346
|
+
}
|
|
1347
|
+
return [
|
|
1348
|
+
...lines,
|
|
1349
|
+
...before.slice(row).map(removed),
|
|
1350
|
+
...after.slice(column).map(added)
|
|
1351
|
+
];
|
|
1352
|
+
};
|
|
1353
|
+
const diffLines = ({
|
|
1354
|
+
before,
|
|
1355
|
+
after
|
|
1356
|
+
}) => {
|
|
1357
|
+
const beforeLines = toLines(before);
|
|
1358
|
+
const afterLines = toLines(after);
|
|
1359
|
+
let head = 0;
|
|
1360
|
+
while (head < beforeLines.length && head < afterLines.length && beforeLines[head] === afterLines[head]) {
|
|
1361
|
+
head++;
|
|
1362
|
+
}
|
|
1363
|
+
let tail = 0;
|
|
1364
|
+
while (tail < beforeLines.length - head && tail < afterLines.length - head && beforeLines[beforeLines.length - 1 - tail] === afterLines[afterLines.length - 1 - tail]) {
|
|
1365
|
+
tail++;
|
|
1366
|
+
}
|
|
1367
|
+
const beforeMiddle = beforeLines.slice(head, beforeLines.length - tail);
|
|
1368
|
+
const afterMiddle = afterLines.slice(head, afterLines.length - tail);
|
|
1369
|
+
return [
|
|
1370
|
+
...beforeLines.slice(0, head).map(context),
|
|
1371
|
+
...beforeMiddle.length * afterMiddle.length > maxDiffCells ? wholeSides(beforeMiddle, afterMiddle) : shortestEdit(beforeMiddle, afterMiddle),
|
|
1372
|
+
...beforeLines.slice(beforeLines.length - tail).map(context)
|
|
1373
|
+
];
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
const imgTag = /<img\b[^>]*>/gi;
|
|
1377
|
+
const decodeAttribute = (value) => value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1378
|
+
const attribute = (tag, name) => {
|
|
1379
|
+
const match = new RegExp(`\\s${name}="([^"]*)"`, "i").exec(tag);
|
|
1380
|
+
return match?.[1] === void 0 ? null : decodeAttribute(match[1]);
|
|
1381
|
+
};
|
|
1382
|
+
const imageProxiesOf = (html) => {
|
|
1383
|
+
const proxies = {};
|
|
1384
|
+
for (const [tag] of (html ?? "").matchAll(imgTag)) {
|
|
1385
|
+
const src = attribute(tag, "src");
|
|
1386
|
+
const canonical = attribute(tag, "data-canonical-src");
|
|
1387
|
+
if (src !== null && canonical !== null && src !== canonical) {
|
|
1388
|
+
proxies[canonical] = src;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return proxies;
|
|
1392
|
+
};
|
|
1393
|
+
const mergeImageProxies = (htmls) => Object.assign({}, ...htmls.map(imageProxiesOf));
|
|
1394
|
+
|
|
1395
|
+
const timelineItemTypes = [
|
|
1396
|
+
"ISSUE_COMMENT",
|
|
1397
|
+
"PULL_REQUEST_REVIEW",
|
|
1398
|
+
"PULL_REQUEST_COMMIT",
|
|
1399
|
+
"ASSIGNED_EVENT",
|
|
1400
|
+
"UNASSIGNED_EVENT",
|
|
1401
|
+
"LABELED_EVENT",
|
|
1402
|
+
"UNLABELED_EVENT",
|
|
1403
|
+
"REVIEW_REQUESTED_EVENT",
|
|
1404
|
+
"REVIEW_REQUEST_REMOVED_EVENT",
|
|
1405
|
+
"CLOSED_EVENT",
|
|
1406
|
+
"REOPENED_EVENT",
|
|
1407
|
+
"MERGED_EVENT",
|
|
1408
|
+
"READY_FOR_REVIEW_EVENT",
|
|
1409
|
+
"CONVERT_TO_DRAFT_EVENT",
|
|
1410
|
+
"HEAD_REF_FORCE_PUSHED_EVENT",
|
|
1411
|
+
"BASE_REF_CHANGED_EVENT",
|
|
1412
|
+
"RENAMED_TITLE_EVENT",
|
|
1413
|
+
"REVIEW_DISMISSED_EVENT",
|
|
1414
|
+
"AUTO_MERGE_ENABLED_EVENT",
|
|
1415
|
+
"AUTO_MERGE_DISABLED_EVENT"
|
|
1416
|
+
].join(", ");
|
|
1417
|
+
const assigneeFields = "__typename ... on User { login } ... on Bot { login } ... on Mannequin { login } ... on Organization { login }";
|
|
1418
|
+
const reviewerFields = "__typename ... on User { login } ... on Bot { login } ... on Mannequin { login } ... on Team { name }";
|
|
1419
|
+
const reactionFields = "viewerCanReact reactionGroups { content viewerHasReacted reactors { totalCount } }";
|
|
1420
|
+
const pullRequestQuery = ({
|
|
1421
|
+
name,
|
|
1422
|
+
viewer = false,
|
|
1423
|
+
repositoryFields = "",
|
|
1424
|
+
fields
|
|
1425
|
+
}) => `query ${name}($owner: String!, $name: String!, $number: Int!) {${viewer ? "\n viewer { login }" : ""}
|
|
1426
|
+
repository(owner: $owner, name: $name) {${repositoryFields}
|
|
1427
|
+
pullRequest(number: $number) {${fields}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}`;
|
|
1431
|
+
const pullRequestDetailQuery = pullRequestQuery({
|
|
1432
|
+
name: "PullRequestDetail",
|
|
1433
|
+
viewer: true,
|
|
1434
|
+
repositoryFields: `
|
|
1435
|
+
mergeCommitAllowed
|
|
1436
|
+
squashMergeAllowed
|
|
1437
|
+
rebaseMergeAllowed
|
|
1438
|
+
viewerDefaultMergeMethod`,
|
|
1439
|
+
fields: `
|
|
1440
|
+
id
|
|
1441
|
+
number
|
|
1442
|
+
url
|
|
1443
|
+
title
|
|
1444
|
+
body
|
|
1445
|
+
bodyHTML
|
|
1446
|
+
state
|
|
1447
|
+
isDraft
|
|
1448
|
+
author { login avatarUrl }
|
|
1449
|
+
headRefName
|
|
1450
|
+
baseRefName
|
|
1451
|
+
headRefOid
|
|
1452
|
+
baseRefOid
|
|
1453
|
+
createdAt
|
|
1454
|
+
updatedAt
|
|
1455
|
+
lastEditedAt
|
|
1456
|
+
additions
|
|
1457
|
+
deletions
|
|
1458
|
+
changedFiles
|
|
1459
|
+
mergeable
|
|
1460
|
+
mergeStateStatus
|
|
1461
|
+
viewerCanUpdate
|
|
1462
|
+
viewerCanUpdateBranch
|
|
1463
|
+
viewerCanApplySuggestion
|
|
1464
|
+
mergeCommitHeadline: viewerMergeHeadlineText(mergeType: MERGE)
|
|
1465
|
+
mergeCommitBody: viewerMergeBodyText(mergeType: MERGE)
|
|
1466
|
+
squashCommitHeadline: viewerMergeHeadlineText(mergeType: SQUASH)
|
|
1467
|
+
squashCommitBody: viewerMergeBodyText(mergeType: SQUASH)
|
|
1468
|
+
${reactionFields}
|
|
1469
|
+
reviewDecision
|
|
1470
|
+
labels(first: 50) { nodes { name color description } }
|
|
1471
|
+
assignees(first: 20) { nodes { login avatarUrl } }
|
|
1472
|
+
reviewRequests(first: 50) {
|
|
1473
|
+
nodes {
|
|
1474
|
+
requestedReviewer {
|
|
1475
|
+
__typename
|
|
1476
|
+
... on User { login avatarUrl }
|
|
1477
|
+
... on Bot { login avatarUrl }
|
|
1478
|
+
... on Mannequin { login avatarUrl }
|
|
1479
|
+
... on Team { slug name }
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
latestReviews(first: 50) { nodes { author { login } state submittedAt } }
|
|
1484
|
+
commits { totalCount }`
|
|
1485
|
+
});
|
|
1486
|
+
const pullRequestChecksQuery = pullRequestQuery({
|
|
1487
|
+
name: "PullRequestChecks",
|
|
1488
|
+
fields: `
|
|
1489
|
+
headCommits: commits(last: 1) {
|
|
1490
|
+
nodes {
|
|
1491
|
+
commit {
|
|
1492
|
+
statusCheckRollup {
|
|
1493
|
+
contexts(first: 100) {
|
|
1494
|
+
totalCount
|
|
1495
|
+
nodes {
|
|
1496
|
+
__typename
|
|
1497
|
+
... on CheckRun { name status conclusion title detailsUrl }
|
|
1498
|
+
... on StatusContext { context state description targetUrl }
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}`
|
|
1505
|
+
});
|
|
1506
|
+
const pullRequestTimelineQuery = pullRequestQuery({
|
|
1507
|
+
name: "PullRequestTimeline",
|
|
1508
|
+
fields: `
|
|
1509
|
+
timelineItems(last: 100, itemTypes: [${timelineItemTypes}]) {
|
|
1510
|
+
filteredCount
|
|
1511
|
+
pageInfo { hasPreviousPage }
|
|
1512
|
+
nodes {
|
|
1513
|
+
__typename
|
|
1514
|
+
... on IssueComment {
|
|
1515
|
+
id databaseId author { login avatarUrl } body bodyHTML createdAt lastEditedAt url viewerCanUpdate
|
|
1516
|
+
${reactionFields}
|
|
1517
|
+
}
|
|
1518
|
+
... on PullRequestReview {
|
|
1519
|
+
id databaseId author { login avatarUrl } state body bodyHTML submittedAt createdAt lastEditedAt url
|
|
1520
|
+
viewerCanUpdate
|
|
1521
|
+
${reactionFields}
|
|
1522
|
+
commit { oid }
|
|
1523
|
+
}
|
|
1524
|
+
... on PullRequestCommit {
|
|
1525
|
+
commit {
|
|
1526
|
+
abbreviatedOid
|
|
1527
|
+
messageHeadline
|
|
1528
|
+
committedDate
|
|
1529
|
+
author { name user { login avatarUrl } }
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
... on AssignedEvent { createdAt actor { login avatarUrl } assignee { ${assigneeFields} } }
|
|
1533
|
+
... on UnassignedEvent { createdAt actor { login avatarUrl } assignee { ${assigneeFields} } }
|
|
1534
|
+
... on LabeledEvent { createdAt actor { login avatarUrl } label { name color description } }
|
|
1535
|
+
... on UnlabeledEvent { createdAt actor { login avatarUrl } label { name color description } }
|
|
1536
|
+
... on ReviewRequestedEvent { createdAt actor { login avatarUrl } requestedReviewer { ${reviewerFields} } }
|
|
1537
|
+
... on ReviewRequestRemovedEvent { createdAt actor { login avatarUrl } requestedReviewer { ${reviewerFields} } }
|
|
1538
|
+
... on ClosedEvent { createdAt actor { login avatarUrl } }
|
|
1539
|
+
... on ReopenedEvent { createdAt actor { login avatarUrl } }
|
|
1540
|
+
... on MergedEvent { createdAt actor { login avatarUrl } }
|
|
1541
|
+
... on ReadyForReviewEvent { createdAt actor { login avatarUrl } }
|
|
1542
|
+
... on ConvertToDraftEvent { createdAt actor { login avatarUrl } }
|
|
1543
|
+
... on AutoMergeEnabledEvent { createdAt actor { login avatarUrl } }
|
|
1544
|
+
... on AutoMergeDisabledEvent { createdAt actor { login avatarUrl } }
|
|
1545
|
+
... on HeadRefForcePushedEvent { createdAt actor { login avatarUrl } afterCommit { abbreviatedOid } }
|
|
1546
|
+
... on BaseRefChangedEvent { createdAt actor { login avatarUrl } currentRefName previousRefName }
|
|
1547
|
+
... on RenamedTitleEvent { createdAt actor { login avatarUrl } currentTitle previousTitle }
|
|
1548
|
+
... on ReviewDismissedEvent { createdAt actor { login avatarUrl } review { author { login } } }
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
reviewThreads(first: 100) {
|
|
1552
|
+
totalCount
|
|
1553
|
+
nodes {
|
|
1554
|
+
id
|
|
1555
|
+
isResolved
|
|
1556
|
+
isOutdated
|
|
1557
|
+
path
|
|
1558
|
+
line
|
|
1559
|
+
startLine
|
|
1560
|
+
diffSide
|
|
1561
|
+
startDiffSide
|
|
1562
|
+
originalLine
|
|
1563
|
+
originalStartLine
|
|
1564
|
+
viewerCanResolve
|
|
1565
|
+
viewerCanUnresolve
|
|
1566
|
+
viewerCanReply
|
|
1567
|
+
comments(first: 20) {
|
|
1568
|
+
totalCount
|
|
1569
|
+
nodes {
|
|
1570
|
+
id databaseId author { login avatarUrl } body bodyHTML createdAt lastEditedAt url diffHunk viewerCanUpdate
|
|
1571
|
+
${reactionFields}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}`
|
|
1576
|
+
});
|
|
1577
|
+
const pullRequestCommitsQuery = pullRequestQuery({
|
|
1578
|
+
name: "PullRequestCommits",
|
|
1579
|
+
fields: `
|
|
1580
|
+
commits(first: 100) {
|
|
1581
|
+
totalCount
|
|
1582
|
+
nodes {
|
|
1583
|
+
commit {
|
|
1584
|
+
oid
|
|
1585
|
+
parents(first: 1) { totalCount nodes { oid } }
|
|
1586
|
+
messageHeadline
|
|
1587
|
+
messageBody
|
|
1588
|
+
committedDate
|
|
1589
|
+
author { name user { login avatarUrl } }
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}`
|
|
1593
|
+
});
|
|
1594
|
+
const pullRequestFilesQuery = pullRequestQuery({
|
|
1595
|
+
name: "PullRequestFiles",
|
|
1596
|
+
fields: `
|
|
1597
|
+
files(first: 100) {
|
|
1598
|
+
nodes { path additions deletions changeType viewerViewedState }
|
|
1599
|
+
}
|
|
1600
|
+
latestReviews(first: 100) {
|
|
1601
|
+
nodes { viewerDidAuthor submittedAt commit { oid } }
|
|
1602
|
+
}`
|
|
1603
|
+
});
|
|
1604
|
+
const present = (items) => (items ?? []).filter((item) => item !== null);
|
|
1605
|
+
const toPerson = ({ login, avatarUrl }) => ({
|
|
1606
|
+
login,
|
|
1607
|
+
avatarUrl: avatarUrl ?? null
|
|
1608
|
+
});
|
|
1609
|
+
const toLabel = ({ name, color, description }) => ({
|
|
1610
|
+
name,
|
|
1611
|
+
color,
|
|
1612
|
+
description: description === "" ? null : description
|
|
1613
|
+
});
|
|
1614
|
+
const toState = (state, isDraft) => {
|
|
1615
|
+
if (state === "MERGED") return "merged";
|
|
1616
|
+
if (state === "CLOSED") return "closed";
|
|
1617
|
+
return isDraft ? "draft" : "open";
|
|
1618
|
+
};
|
|
1619
|
+
const reviewDecisions = /* @__PURE__ */ new Map([
|
|
1620
|
+
["APPROVED", "approved"],
|
|
1621
|
+
["CHANGES_REQUESTED", "changesRequested"],
|
|
1622
|
+
["REVIEW_REQUIRED", "reviewRequired"]
|
|
1623
|
+
]);
|
|
1624
|
+
const mergeables$1 = /* @__PURE__ */ new Map([
|
|
1625
|
+
["CONFLICTING", "conflicting"],
|
|
1626
|
+
["MERGEABLE", "mergeable"]
|
|
1627
|
+
]);
|
|
1628
|
+
const reviewStates$1 = /* @__PURE__ */ new Map([
|
|
1629
|
+
["APPROVED", "approved"],
|
|
1630
|
+
["CHANGES_REQUESTED", "changesRequested"],
|
|
1631
|
+
["COMMENTED", "commented"],
|
|
1632
|
+
["DISMISSED", "dismissed"],
|
|
1633
|
+
["PENDING", "pending"]
|
|
1634
|
+
]);
|
|
1635
|
+
const fileChanges = /* @__PURE__ */ new Map([
|
|
1636
|
+
["ADDED", "added"],
|
|
1637
|
+
["CHANGED", "changed"],
|
|
1638
|
+
["COPIED", "copied"],
|
|
1639
|
+
["DELETED", "deleted"],
|
|
1640
|
+
["MODIFIED", "modified"],
|
|
1641
|
+
["RENAMED", "renamed"]
|
|
1642
|
+
]);
|
|
1643
|
+
const viewedStates = /* @__PURE__ */ new Map([
|
|
1644
|
+
["DISMISSED", "dismissed"],
|
|
1645
|
+
["VIEWED", "viewed"]
|
|
1646
|
+
]);
|
|
1647
|
+
const toReviewer = (reviewer) => reviewer.__typename === "Team" ? { kind: "team", slug: reviewer.slug, name: reviewer.name } : {
|
|
1648
|
+
kind: "user",
|
|
1649
|
+
login: reviewer.login,
|
|
1650
|
+
avatarUrl: reviewer.avatarUrl
|
|
1651
|
+
};
|
|
1652
|
+
const nonEmpty = (text) => text === null || text === "" ? null : text;
|
|
1653
|
+
const toCheck = (context) => {
|
|
1654
|
+
if (context.__typename === "StatusContext") {
|
|
1655
|
+
return {
|
|
1656
|
+
name: context.context,
|
|
1657
|
+
outcome: checkOutcomes.get(context.state) ?? "pending",
|
|
1658
|
+
running: false,
|
|
1659
|
+
description: nonEmpty(context.description),
|
|
1660
|
+
url: context.targetUrl
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
name: context.name,
|
|
1665
|
+
outcome: context.status === "COMPLETED" && context.conclusion !== null ? checkOutcomes.get(context.conclusion) ?? "pending" : "pending",
|
|
1666
|
+
running: context.status === "IN_PROGRESS",
|
|
1667
|
+
description: nonEmpty(context.title),
|
|
1668
|
+
url: context.detailsUrl
|
|
1669
|
+
};
|
|
1670
|
+
};
|
|
1671
|
+
const checkOrder = {
|
|
1672
|
+
failed: 0,
|
|
1673
|
+
pending: 1,
|
|
1674
|
+
passed: 2
|
|
1675
|
+
};
|
|
1676
|
+
const ghost = { login: "ghost", avatarUrl: null };
|
|
1677
|
+
const toAuthor = (actor) => actor === null ? ghost : toPerson(actor);
|
|
1678
|
+
const reactionContents = /* @__PURE__ */ new Map([
|
|
1679
|
+
["CONFUSED", "confused"],
|
|
1680
|
+
["EYES", "eyes"],
|
|
1681
|
+
["HEART", "heart"],
|
|
1682
|
+
["HOORAY", "hooray"],
|
|
1683
|
+
["LAUGH", "laugh"],
|
|
1684
|
+
["ROCKET", "rocket"],
|
|
1685
|
+
["THUMBS_DOWN", "thumbsDown"],
|
|
1686
|
+
["THUMBS_UP", "thumbsUp"]
|
|
1687
|
+
]);
|
|
1688
|
+
const toReactable = (subject) => ({
|
|
1689
|
+
id: subject.id,
|
|
1690
|
+
viewerCanReact: subject.viewerCanReact,
|
|
1691
|
+
reactions: (subject.reactionGroups ?? []).flatMap((group) => {
|
|
1692
|
+
const content = reactionContents.get(group.content);
|
|
1693
|
+
if (content === void 0 || group.reactors.totalCount === 0) return [];
|
|
1694
|
+
return [
|
|
1695
|
+
{
|
|
1696
|
+
content,
|
|
1697
|
+
count: group.reactors.totalCount,
|
|
1698
|
+
viewerHasReacted: group.viewerHasReacted
|
|
1699
|
+
}
|
|
1700
|
+
];
|
|
1701
|
+
})
|
|
1702
|
+
});
|
|
1703
|
+
const toComment = (comment) => ({
|
|
1704
|
+
...toReactable(comment),
|
|
1705
|
+
databaseId: comment.databaseId,
|
|
1706
|
+
author: toAuthor(comment.author),
|
|
1707
|
+
body: comment.body,
|
|
1708
|
+
createdAt: comment.createdAt,
|
|
1709
|
+
lastEditedAt: comment.lastEditedAt,
|
|
1710
|
+
url: comment.url,
|
|
1711
|
+
viewerCanUpdate: comment.viewerCanUpdate
|
|
1712
|
+
});
|
|
1713
|
+
const toReviewItem = (review) => {
|
|
1714
|
+
if (review.state === "PENDING") return null;
|
|
1715
|
+
if (review.state === "COMMENTED" && review.body.trim() === "") return null;
|
|
1716
|
+
return {
|
|
1717
|
+
kind: "review",
|
|
1718
|
+
...toReactable(review),
|
|
1719
|
+
databaseId: review.databaseId,
|
|
1720
|
+
author: toAuthor(review.author),
|
|
1721
|
+
state: reviewStates$1.get(review.state) ?? "commented",
|
|
1722
|
+
body: review.body,
|
|
1723
|
+
createdAt: review.submittedAt ?? review.createdAt,
|
|
1724
|
+
lastEditedAt: review.lastEditedAt,
|
|
1725
|
+
url: review.url,
|
|
1726
|
+
viewerCanUpdate: review.viewerCanUpdate && review.body !== "",
|
|
1727
|
+
commitOid: review.commit?.oid ?? null
|
|
1728
|
+
};
|
|
1729
|
+
};
|
|
1730
|
+
const toSide = (side) => side === "LEFT" ? "LEFT" : "RIGHT";
|
|
1731
|
+
const toThreadItem = (thread) => {
|
|
1732
|
+
const comments = present(thread.comments.nodes);
|
|
1733
|
+
const [first] = comments;
|
|
1734
|
+
if (first === void 0) return null;
|
|
1735
|
+
return {
|
|
1736
|
+
kind: "thread",
|
|
1737
|
+
id: thread.id,
|
|
1738
|
+
path: thread.path,
|
|
1739
|
+
line: thread.line,
|
|
1740
|
+
startLine: thread.startLine,
|
|
1741
|
+
side: toSide(thread.diffSide),
|
|
1742
|
+
startSide: thread.startDiffSide === null ? null : toSide(thread.startDiffSide),
|
|
1743
|
+
originalLine: thread.originalLine,
|
|
1744
|
+
originalStartLine: thread.originalStartLine,
|
|
1745
|
+
resolved: thread.isResolved,
|
|
1746
|
+
outdated: thread.isOutdated,
|
|
1747
|
+
viewerCanResolve: thread.viewerCanResolve,
|
|
1748
|
+
viewerCanUnresolve: thread.viewerCanUnresolve,
|
|
1749
|
+
viewerCanReply: thread.viewerCanReply,
|
|
1750
|
+
diffHunk: first.diffHunk,
|
|
1751
|
+
comments: comments.map(toComment),
|
|
1752
|
+
commentsTotal: thread.comments.totalCount,
|
|
1753
|
+
createdAt: first.createdAt
|
|
1754
|
+
};
|
|
1755
|
+
};
|
|
1756
|
+
const nameOf = (named) => {
|
|
1757
|
+
if (named === null) return null;
|
|
1758
|
+
return "login" in named ? named.login : named.name;
|
|
1759
|
+
};
|
|
1760
|
+
const commitAuthor = (commit) => {
|
|
1761
|
+
const { author } = commit;
|
|
1762
|
+
if (author === null) return null;
|
|
1763
|
+
if (author.user !== null) return toPerson(author.user);
|
|
1764
|
+
return author.name === null ? null : { login: author.name, avatarUrl: null };
|
|
1765
|
+
};
|
|
1766
|
+
const simpleActions = {
|
|
1767
|
+
AutoMergeDisabledEvent: "autoMergeDisabled",
|
|
1768
|
+
AutoMergeEnabledEvent: "autoMergeEnabled",
|
|
1769
|
+
ClosedEvent: "closed",
|
|
1770
|
+
ConvertToDraftEvent: "convertedToDraft",
|
|
1771
|
+
MergedEvent: "merged",
|
|
1772
|
+
ReadyForReviewEvent: "readyForReview",
|
|
1773
|
+
ReopenedEvent: "reopened"
|
|
1774
|
+
};
|
|
1775
|
+
const event = (action, { actor, createdAt }, extra = {}) => ({
|
|
1776
|
+
kind: "event",
|
|
1777
|
+
action,
|
|
1778
|
+
actor: actor === null ? null : toPerson(actor),
|
|
1779
|
+
subject: null,
|
|
1780
|
+
label: null,
|
|
1781
|
+
detail: null,
|
|
1782
|
+
createdAt,
|
|
1783
|
+
...extra
|
|
1784
|
+
});
|
|
1785
|
+
const toEvent = (node) => {
|
|
1786
|
+
switch (node.__typename) {
|
|
1787
|
+
case "PullRequestCommit": {
|
|
1788
|
+
const { commit } = node;
|
|
1789
|
+
return event(
|
|
1790
|
+
"committed",
|
|
1791
|
+
{ actor: commitAuthor(commit), createdAt: commit.committedDate },
|
|
1792
|
+
{ subject: commit.messageHeadline, detail: commit.abbreviatedOid }
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
case "AssignedEvent":
|
|
1796
|
+
case "UnassignedEvent": {
|
|
1797
|
+
return event(
|
|
1798
|
+
node.__typename === "AssignedEvent" ? "assigned" : "unassigned",
|
|
1799
|
+
node,
|
|
1800
|
+
{ subject: nameOf(node.assignee) }
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
case "LabeledEvent":
|
|
1804
|
+
case "UnlabeledEvent": {
|
|
1805
|
+
return event(
|
|
1806
|
+
node.__typename === "LabeledEvent" ? "labeled" : "unlabeled",
|
|
1807
|
+
node,
|
|
1808
|
+
{
|
|
1809
|
+
subject: node.label?.name ?? null,
|
|
1810
|
+
label: node.label === null ? null : toLabel(node.label)
|
|
1811
|
+
}
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
case "ReviewRequestedEvent":
|
|
1815
|
+
case "ReviewRequestRemovedEvent": {
|
|
1816
|
+
return event(
|
|
1817
|
+
node.__typename === "ReviewRequestedEvent" ? "reviewRequested" : "reviewRequestRemoved",
|
|
1818
|
+
node,
|
|
1819
|
+
{ subject: nameOf(node.requestedReviewer) }
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
case "HeadRefForcePushedEvent": {
|
|
1823
|
+
return event("headRefForcePushed", node, {
|
|
1824
|
+
detail: node.afterCommit?.abbreviatedOid ?? null
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
case "BaseRefChangedEvent": {
|
|
1828
|
+
return event("baseRefChanged", node, {
|
|
1829
|
+
subject: node.currentRefName,
|
|
1830
|
+
detail: node.previousRefName
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
case "RenamedTitleEvent": {
|
|
1834
|
+
return event("renamed", node, {
|
|
1835
|
+
subject: node.currentTitle,
|
|
1836
|
+
detail: node.previousTitle
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
case "ReviewDismissedEvent": {
|
|
1840
|
+
return event("reviewDismissed", node, {
|
|
1841
|
+
subject: node.review?.author?.login ?? null
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
case "AutoMergeDisabledEvent":
|
|
1845
|
+
case "AutoMergeEnabledEvent":
|
|
1846
|
+
case "ClosedEvent":
|
|
1847
|
+
case "ConvertToDraftEvent":
|
|
1848
|
+
case "MergedEvent":
|
|
1849
|
+
case "ReadyForReviewEvent":
|
|
1850
|
+
case "ReopenedEvent": {
|
|
1851
|
+
return event(simpleActions[node.__typename], node);
|
|
1852
|
+
}
|
|
1853
|
+
default: {
|
|
1854
|
+
return node;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
const toTimelineNode = (node) => {
|
|
1859
|
+
if (node.__typename === "IssueComment") {
|
|
1860
|
+
return { kind: "comment", ...toComment(node) };
|
|
1861
|
+
}
|
|
1862
|
+
if (node.__typename === "PullRequestReview") return toReviewItem(node);
|
|
1863
|
+
return toEvent(node);
|
|
1864
|
+
};
|
|
1865
|
+
const isTruncated = (connection) => connection !== null && connection.totalCount > present(connection.nodes).length;
|
|
1866
|
+
const bodyHtmlsOf = (pullRequest) => [
|
|
1867
|
+
...present(pullRequest.timelineItems?.nodes).map(
|
|
1868
|
+
(node) => "bodyHTML" in node ? node.bodyHTML : void 0
|
|
1869
|
+
),
|
|
1870
|
+
...present(pullRequest.reviewThreads?.nodes).flatMap(
|
|
1871
|
+
(thread) => present(thread.comments.nodes).map((comment) => comment.bodyHTML)
|
|
1872
|
+
)
|
|
1873
|
+
];
|
|
1874
|
+
const toTimeline = (pullRequest) => {
|
|
1875
|
+
const items = [
|
|
1876
|
+
...present(pullRequest.timelineItems?.nodes).map(toTimelineNode),
|
|
1877
|
+
...present(pullRequest.reviewThreads?.nodes).map(toThreadItem)
|
|
1878
|
+
].filter((item) => item !== null);
|
|
1879
|
+
return {
|
|
1880
|
+
timeline: items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)),
|
|
1881
|
+
images: mergeImageProxies(bodyHtmlsOf(pullRequest)),
|
|
1882
|
+
timelineTruncated: pullRequest.timelineItems?.pageInfo.hasPreviousPage === true || isTruncated(pullRequest.reviewThreads)
|
|
1883
|
+
};
|
|
1884
|
+
};
|
|
1885
|
+
const toChecks = (contexts) => present(contexts?.nodes).map(toCheck).map((check, index) => ({ check, index })).toSorted(
|
|
1886
|
+
(a, b) => checkOrder[a.check.outcome] - checkOrder[b.check.outcome] || a.index - b.index
|
|
1887
|
+
).map(({ check }) => check);
|
|
1888
|
+
const toFiles = (pullRequest) => present(pullRequest.files?.nodes).map((file) => ({
|
|
1889
|
+
path: file.path,
|
|
1890
|
+
additions: file.additions,
|
|
1891
|
+
deletions: file.deletions,
|
|
1892
|
+
change: fileChanges.get(file.changeType) ?? "changed",
|
|
1893
|
+
viewed: viewedStates.get(file.viewerViewedState) ?? "unviewed"
|
|
1894
|
+
}));
|
|
1895
|
+
const toRequestedReviewers = (pullRequest) => present(pullRequest.reviewRequests?.nodes).map(({ requestedReviewer }) => requestedReviewer).filter((reviewer) => reviewer !== null).map(toReviewer);
|
|
1896
|
+
const toReviewDecision = (decision) => decision === null ? null : reviewDecisions.get(decision) ?? null;
|
|
1897
|
+
const mergeStates = /* @__PURE__ */ new Map([
|
|
1898
|
+
["BEHIND", "behind"],
|
|
1899
|
+
["BLOCKED", "blocked"],
|
|
1900
|
+
["CLEAN", "clean"],
|
|
1901
|
+
["DIRTY", "dirty"],
|
|
1902
|
+
["DRAFT", "draft"],
|
|
1903
|
+
["HAS_HOOKS", "hasHooks"],
|
|
1904
|
+
["UNSTABLE", "unstable"]
|
|
1905
|
+
]);
|
|
1906
|
+
const mergeMethodNames = /* @__PURE__ */ new Map([
|
|
1907
|
+
["MERGE", "merge"],
|
|
1908
|
+
["REBASE", "rebase"],
|
|
1909
|
+
["SQUASH", "squash"]
|
|
1910
|
+
]);
|
|
1911
|
+
const toMergeMethods = (repository) => {
|
|
1912
|
+
const allowed = [];
|
|
1913
|
+
if (repository.mergeCommitAllowed === true) allowed.push("merge");
|
|
1914
|
+
if (repository.squashMergeAllowed === true) allowed.push("squash");
|
|
1915
|
+
if (repository.rebaseMergeAllowed === true) allowed.push("rebase");
|
|
1916
|
+
const preferred = mergeMethodNames.get(
|
|
1917
|
+
repository.viewerDefaultMergeMethod ?? ""
|
|
1918
|
+
);
|
|
1919
|
+
return preferred !== void 0 && allowed.includes(preferred) ? [preferred, ...allowed.filter((method) => method !== preferred)] : allowed;
|
|
1920
|
+
};
|
|
1921
|
+
const toCommit = ({ commit }) => ({
|
|
1922
|
+
oid: commit.oid,
|
|
1923
|
+
parentOid: present(commit.parents.nodes)[0]?.oid ?? null,
|
|
1924
|
+
merge: commit.parents.totalCount > 1,
|
|
1925
|
+
headline: commit.messageHeadline,
|
|
1926
|
+
body: commit.messageBody.trim(),
|
|
1927
|
+
author: commitAuthor(commit),
|
|
1928
|
+
committedAt: commit.committedDate
|
|
1929
|
+
});
|
|
1930
|
+
const viewerLastReview = (pullRequest) => {
|
|
1931
|
+
const review = present(pullRequest.latestReviews?.nodes).find(
|
|
1932
|
+
({ viewerDidAuthor }) => viewerDidAuthor
|
|
1933
|
+
);
|
|
1934
|
+
const oid = review?.commit?.oid;
|
|
1935
|
+
if (review === void 0 || oid === void 0) return null;
|
|
1936
|
+
return { oid, submittedAt: review.submittedAt };
|
|
1937
|
+
};
|
|
1938
|
+
const toDetail = (pullRequest, repository, ownerRepo) => ({
|
|
1939
|
+
...toReactable(pullRequest),
|
|
1940
|
+
ownerRepo,
|
|
1941
|
+
number: pullRequest.number,
|
|
1942
|
+
url: pullRequest.url,
|
|
1943
|
+
title: pullRequest.title,
|
|
1944
|
+
body: pullRequest.body,
|
|
1945
|
+
state: toState(pullRequest.state, pullRequest.isDraft),
|
|
1946
|
+
author: pullRequest.author === null ? null : toPerson(pullRequest.author),
|
|
1947
|
+
headRef: pullRequest.headRefName,
|
|
1948
|
+
baseRef: pullRequest.baseRefName,
|
|
1949
|
+
headSha: pullRequest.headRefOid,
|
|
1950
|
+
baseSha: pullRequest.baseRefOid,
|
|
1951
|
+
createdAt: pullRequest.createdAt,
|
|
1952
|
+
updatedAt: pullRequest.updatedAt,
|
|
1953
|
+
lastEditedAt: pullRequest.lastEditedAt,
|
|
1954
|
+
additions: pullRequest.additions,
|
|
1955
|
+
deletions: pullRequest.deletions,
|
|
1956
|
+
changedFiles: pullRequest.changedFiles,
|
|
1957
|
+
mergeable: mergeables$1.get(pullRequest.mergeable) ?? "unknown",
|
|
1958
|
+
mergeState: mergeStates.get(pullRequest.mergeStateStatus) ?? "unknown",
|
|
1959
|
+
mergeMethods: toMergeMethods(repository),
|
|
1960
|
+
mergeCommitMessages: {
|
|
1961
|
+
merge: {
|
|
1962
|
+
title: pullRequest.mergeCommitHeadline,
|
|
1963
|
+
body: pullRequest.mergeCommitBody
|
|
1964
|
+
},
|
|
1965
|
+
squash: {
|
|
1966
|
+
title: pullRequest.squashCommitHeadline,
|
|
1967
|
+
body: pullRequest.squashCommitBody
|
|
1968
|
+
}
|
|
1969
|
+
},
|
|
1970
|
+
viewerCanUpdate: pullRequest.viewerCanUpdate,
|
|
1971
|
+
viewerCanUpdateBranch: pullRequest.viewerCanUpdateBranch,
|
|
1972
|
+
viewerCanApplySuggestion: pullRequest.viewerCanApplySuggestion,
|
|
1973
|
+
reviewDecision: toReviewDecision(pullRequest.reviewDecision),
|
|
1974
|
+
labels: present(pullRequest.labels?.nodes).map(toLabel),
|
|
1975
|
+
assignees: present(pullRequest.assignees?.nodes).map(toPerson),
|
|
1976
|
+
requestedReviewers: toRequestedReviewers(pullRequest),
|
|
1977
|
+
reviews: present(pullRequest.latestReviews?.nodes).map((review) => ({
|
|
1978
|
+
author: review.author?.login ?? "ghost",
|
|
1979
|
+
state: reviewStates$1.get(review.state) ?? "commented",
|
|
1980
|
+
submittedAt: review.submittedAt
|
|
1981
|
+
})),
|
|
1982
|
+
commitsTotal: pullRequest.commits.totalCount
|
|
1983
|
+
});
|
|
1984
|
+
const toChecksPart = (pullRequest) => {
|
|
1985
|
+
const contexts = pullRequest.headCommits.nodes?.[0]?.commit.statusCheckRollup?.contexts ?? null;
|
|
1986
|
+
return { checks: toChecks(contexts), checksTotal: contexts?.totalCount ?? 0 };
|
|
1987
|
+
};
|
|
1988
|
+
const pullRequestCandidatesQuery = `query PullRequestCandidates($owner: String!, $name: String!, $number: Int!) {
|
|
1989
|
+
repositoryOwner(login: $owner) {
|
|
1990
|
+
... on Organization {
|
|
1991
|
+
teams(first: 100, orderBy: { field: NAME, direction: ASC }) {
|
|
1992
|
+
nodes { slug name }
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
repository(owner: $owner, name: $name) {
|
|
1997
|
+
labels(first: 100, orderBy: { field: NAME, direction: ASC }) {
|
|
1998
|
+
nodes { name color description }
|
|
1999
|
+
}
|
|
2000
|
+
assignableUsers(first: 100) { nodes { login avatarUrl } }
|
|
2001
|
+
pullRequest(number: $number) {
|
|
2002
|
+
author { login }
|
|
2003
|
+
suggestedReviewers { reviewer { login avatarUrl } }
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
}`;
|
|
2007
|
+
const reviewerCandidates = (pullRequest, assignable) => {
|
|
2008
|
+
const author = pullRequest.author?.login;
|
|
2009
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2010
|
+
const unseen = (actors) => actors.flatMap((actor) => {
|
|
2011
|
+
if (actor.login === author || seen.has(actor.login)) return [];
|
|
2012
|
+
seen.add(actor.login);
|
|
2013
|
+
return [toPerson(actor)];
|
|
2014
|
+
});
|
|
2015
|
+
const suggested = unseen(
|
|
2016
|
+
present(pullRequest.suggestedReviewers).map(({ reviewer }) => reviewer).filter((reviewer) => reviewer !== null)
|
|
2017
|
+
);
|
|
2018
|
+
const others = unseen(
|
|
2019
|
+
assignable.toSorted((a, b) => a.login.localeCompare(b.login))
|
|
2020
|
+
);
|
|
2021
|
+
return { suggested, others };
|
|
2022
|
+
};
|
|
2023
|
+
const gitHubRead = ({
|
|
2024
|
+
fetchedAt,
|
|
2025
|
+
errors
|
|
2026
|
+
}) => ({
|
|
2027
|
+
fetchedAt,
|
|
2028
|
+
checkedAt: fetchedAt,
|
|
2029
|
+
source: "github",
|
|
2030
|
+
errors
|
|
2031
|
+
});
|
|
2032
|
+
const readPart = ({
|
|
2033
|
+
data,
|
|
2034
|
+
errors,
|
|
2035
|
+
ownerRepo,
|
|
2036
|
+
number
|
|
2037
|
+
}) => {
|
|
2038
|
+
const repository = data?.repository ?? null;
|
|
2039
|
+
const pullRequest = repository?.pullRequest ?? null;
|
|
2040
|
+
if (repository === null || pullRequest === null) {
|
|
2041
|
+
const reason = errors.length > 0 ? errors.join("; ") : "not found";
|
|
2042
|
+
throw new Error(
|
|
2043
|
+
`no pull request #${String(number)} in ${ownerRepo}: ${reason}`
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
return { pullRequest, repository };
|
|
2047
|
+
};
|
|
2048
|
+
const parsePullRequestDetail = (params) => {
|
|
2049
|
+
const { pullRequest, repository } = readPart(params);
|
|
2050
|
+
return {
|
|
2051
|
+
pullRequest: toDetail(pullRequest, repository, params.ownerRepo),
|
|
2052
|
+
viewer: params.data?.viewer?.login ?? "",
|
|
2053
|
+
images: imageProxiesOf(pullRequest.bodyHTML),
|
|
2054
|
+
...gitHubRead(params)
|
|
2055
|
+
};
|
|
2056
|
+
};
|
|
2057
|
+
const parsePullRequestChecks = (params) => ({
|
|
2058
|
+
...toChecksPart(readPart(params).pullRequest),
|
|
2059
|
+
...gitHubRead(params)
|
|
2060
|
+
});
|
|
2061
|
+
const parsePullRequestTimeline = (params) => ({
|
|
2062
|
+
...toTimeline(readPart(params).pullRequest),
|
|
2063
|
+
...gitHubRead(params)
|
|
2064
|
+
});
|
|
2065
|
+
const parsePullRequestCommits = (params) => ({
|
|
2066
|
+
commits: present(readPart(params).pullRequest.commits.nodes).map(toCommit),
|
|
2067
|
+
...gitHubRead(params)
|
|
2068
|
+
});
|
|
2069
|
+
const parsePullRequestFiles = (params) => {
|
|
2070
|
+
const { pullRequest } = readPart(params);
|
|
2071
|
+
return {
|
|
2072
|
+
files: toFiles(pullRequest),
|
|
2073
|
+
viewerLastReview: viewerLastReview(pullRequest),
|
|
2074
|
+
...gitHubRead(params)
|
|
2075
|
+
};
|
|
2076
|
+
};
|
|
2077
|
+
const readGitHub = async (transport, { ownerRepo, number }, query, parse) => {
|
|
2078
|
+
const split = splitOwnerRepo(ownerRepo);
|
|
2079
|
+
if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
|
|
2080
|
+
const { data, errors } = await transport.graphql(
|
|
2081
|
+
query,
|
|
2082
|
+
{
|
|
2083
|
+
owner: split.owner,
|
|
2084
|
+
name: split.name,
|
|
2085
|
+
number
|
|
2086
|
+
}
|
|
2087
|
+
);
|
|
2088
|
+
return parse({
|
|
2089
|
+
data,
|
|
2090
|
+
errors,
|
|
2091
|
+
ownerRepo,
|
|
2092
|
+
number,
|
|
2093
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2094
|
+
});
|
|
2095
|
+
};
|
|
2096
|
+
const partReaders = {
|
|
2097
|
+
detail: (transport, ref) => readGitHub(transport, ref, pullRequestDetailQuery, parsePullRequestDetail),
|
|
2098
|
+
checks: (transport, ref) => readGitHub(transport, ref, pullRequestChecksQuery, parsePullRequestChecks),
|
|
2099
|
+
timeline: (transport, ref) => readGitHub(
|
|
2100
|
+
transport,
|
|
2101
|
+
ref,
|
|
2102
|
+
pullRequestTimelineQuery,
|
|
2103
|
+
parsePullRequestTimeline
|
|
2104
|
+
),
|
|
2105
|
+
commits: (transport, ref) => readGitHub(
|
|
2106
|
+
transport,
|
|
2107
|
+
ref,
|
|
2108
|
+
pullRequestCommitsQuery,
|
|
2109
|
+
parsePullRequestCommits
|
|
2110
|
+
),
|
|
2111
|
+
files: (transport, ref) => readGitHub(transport, ref, pullRequestFilesQuery, parsePullRequestFiles)
|
|
2112
|
+
};
|
|
2113
|
+
const fetchPullRequestPart = (transport, ref, part) => partReaders[part](transport, ref);
|
|
2114
|
+
const parsePullRequestCandidates = ({
|
|
2115
|
+
data,
|
|
2116
|
+
errors
|
|
2117
|
+
}) => {
|
|
2118
|
+
const repository = data?.repository ?? null;
|
|
2119
|
+
if (repository === null) {
|
|
2120
|
+
throw new Error(errors.length > 0 ? errors.join("; ") : "not found");
|
|
2121
|
+
}
|
|
2122
|
+
const assignable = present(repository.assignableUsers?.nodes);
|
|
2123
|
+
const reviewers = reviewerCandidates(
|
|
2124
|
+
repository.pullRequest ?? { author: null, suggestedReviewers: null },
|
|
2125
|
+
assignable
|
|
2126
|
+
);
|
|
2127
|
+
return {
|
|
2128
|
+
repoLabels: present(repository.labels?.nodes).map(toLabel),
|
|
2129
|
+
suggestedReviewers: reviewers.suggested,
|
|
2130
|
+
reviewerCandidates: reviewers.others,
|
|
2131
|
+
teamCandidates: present(data?.repositoryOwner?.teams?.nodes).map(
|
|
2132
|
+
({ slug, name }) => ({ slug, name })
|
|
2133
|
+
),
|
|
2134
|
+
assigneeCandidates: assignable.toSorted((a, b) => a.login.localeCompare(b.login)).map(toPerson),
|
|
2135
|
+
errors
|
|
2136
|
+
};
|
|
2137
|
+
};
|
|
2138
|
+
const fetchPullRequestCandidates = async (transport, {
|
|
2139
|
+
ownerRepo,
|
|
2140
|
+
number
|
|
2141
|
+
}) => {
|
|
2142
|
+
const split = splitOwnerRepo(ownerRepo);
|
|
2143
|
+
if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
|
|
2144
|
+
const { data, errors } = await transport.graphql(
|
|
2145
|
+
pullRequestCandidatesQuery,
|
|
2146
|
+
{ owner: split.owner, name: split.name, number }
|
|
2147
|
+
);
|
|
2148
|
+
return parsePullRequestCandidates({ data, errors });
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
const pullRequestHeadQuery = `query PullRequestHead($owner: String!, $name: String!, $number: Int!) {
|
|
2152
|
+
repository(owner: $owner, name: $name) {
|
|
2153
|
+
pullRequest(number: $number) {
|
|
2154
|
+
state
|
|
2155
|
+
headRefName
|
|
2156
|
+
isCrossRepository
|
|
2157
|
+
headRepositoryOwner { login }
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}`;
|
|
2161
|
+
const headStates = {
|
|
2162
|
+
CLOSED: "closed",
|
|
2163
|
+
MERGED: "merged",
|
|
2164
|
+
OPEN: "open"
|
|
2165
|
+
};
|
|
2166
|
+
const fetchPullRequestHead = async (transport, { ownerRepo, number }) => {
|
|
2167
|
+
const [owner = "", name = ""] = ownerRepo.split("/");
|
|
2168
|
+
const { data, errors } = await transport.graphql(
|
|
2169
|
+
pullRequestHeadQuery,
|
|
2170
|
+
{ owner, name, number }
|
|
2171
|
+
);
|
|
2172
|
+
const pullRequest = data?.repository?.pullRequest ?? null;
|
|
2173
|
+
if (pullRequest === null) {
|
|
2174
|
+
const reason = errors.length > 0 ? errors.join("; ") : "not found";
|
|
2175
|
+
throw new Error(`${ownerRepo}#${String(number)}: ${reason}`);
|
|
2176
|
+
}
|
|
2177
|
+
return {
|
|
2178
|
+
state: headStates[pullRequest.state],
|
|
2179
|
+
branch: pullRequest.headRefName,
|
|
2180
|
+
fork: pullRequest.isCrossRepository ? pullRequest.headRepositoryOwner?.login ?? "a deleted fork" : null
|
|
2181
|
+
};
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
const revisionsPage = 50;
|
|
2185
|
+
const pullRequestRevisionsQuery = `query PullRequestRevisions($id: ID!) {
|
|
2186
|
+
node(id: $id) {
|
|
2187
|
+
... on Comment {
|
|
2188
|
+
includesCreatedEdit
|
|
2189
|
+
userContentEdits(first: ${String(revisionsPage)}) {
|
|
2190
|
+
totalCount
|
|
2191
|
+
nodes {
|
|
2192
|
+
editedAt
|
|
2193
|
+
deletedAt
|
|
2194
|
+
editor { login avatarUrl }
|
|
2195
|
+
deletedBy { login }
|
|
2196
|
+
diff
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}`;
|
|
2202
|
+
const normalizeNewlines = (text) => text?.replaceAll("\r\n", "\n") ?? null;
|
|
2203
|
+
const parsePullRequestRevisions = ({
|
|
2204
|
+
data,
|
|
2205
|
+
errors,
|
|
2206
|
+
subjectId
|
|
2207
|
+
}) => {
|
|
2208
|
+
const edits = data?.node?.userContentEdits ?? null;
|
|
2209
|
+
if (edits === null) {
|
|
2210
|
+
const reason = errors.length > 0 ? errors.join("; ") : "not found";
|
|
2211
|
+
throw new Error(`no edit history for ${subjectId}: ${reason}`);
|
|
2212
|
+
}
|
|
2213
|
+
const nodes = (edits.nodes ?? []).flatMap(
|
|
2214
|
+
(node, rawIndex) => node === null ? [] : [{ ...node, rawIndex }]
|
|
2215
|
+
);
|
|
2216
|
+
const bodies = nodes.map(
|
|
2217
|
+
(node) => node.deletedAt === null ? normalizeNewlines(node.diff) : null
|
|
2218
|
+
);
|
|
2219
|
+
const lastIndex = edits.totalCount - 1;
|
|
2220
|
+
const revisions = nodes.map((node, index) => {
|
|
2221
|
+
const body = bodies[index] ?? null;
|
|
2222
|
+
const before = bodies.slice(index + 1).find((older) => older !== null);
|
|
2223
|
+
return {
|
|
2224
|
+
editedAt: node.editedAt,
|
|
2225
|
+
editor: node.editor === null ? null : {
|
|
2226
|
+
login: node.editor.login,
|
|
2227
|
+
avatarUrl: node.editor.avatarUrl ?? null
|
|
2228
|
+
},
|
|
2229
|
+
original: data?.node?.includesCreatedEdit === true && node.rawIndex === lastIndex,
|
|
2230
|
+
body,
|
|
2231
|
+
deletedBy: node.deletedBy?.login ?? null,
|
|
2232
|
+
changes: body === null || before === void 0 ? null : diffLines({ before, after: body })
|
|
2233
|
+
};
|
|
2234
|
+
});
|
|
2235
|
+
return { revisions, revisionsTotal: edits.totalCount };
|
|
2236
|
+
};
|
|
2237
|
+
const fetchPullRequestRevisions = async (transport, subjectId) => {
|
|
2238
|
+
const { data, errors } = await transport.graphql(
|
|
2239
|
+
pullRequestRevisionsQuery,
|
|
2240
|
+
{ id: subjectId }
|
|
2241
|
+
);
|
|
2242
|
+
return parsePullRequestRevisions({ data, errors, subjectId });
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
const firstLine = (text) => text.split("\n", 1)[0]?.trim() ?? "";
|
|
2246
|
+
const isSuccess = (status) => status >= 200 && status < 300;
|
|
2247
|
+
const parseJson = (text) => {
|
|
2248
|
+
try {
|
|
2249
|
+
return JSON.parse(text);
|
|
2250
|
+
} catch {
|
|
2251
|
+
return null;
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
const interpretGraphql = ({
|
|
2255
|
+
status,
|
|
2256
|
+
body
|
|
2257
|
+
}) => {
|
|
2258
|
+
const parsed = parseJson(body);
|
|
2259
|
+
if (parsed === null || !isSuccess(status) && parsed.data == null) {
|
|
2260
|
+
const detail = parsed?.message ?? firstLine(body);
|
|
2261
|
+
throw new Error(
|
|
2262
|
+
`GitHub graphql failed (HTTP ${String(status)})${detail === "" ? "" : `: ${detail}`}`
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
return {
|
|
2266
|
+
data: parsed.data ?? null,
|
|
2267
|
+
errors: (parsed.errors ?? []).map(
|
|
2268
|
+
({ message }) => message ?? "github returned an unnamed error"
|
|
2269
|
+
)
|
|
2270
|
+
};
|
|
2271
|
+
};
|
|
2272
|
+
const restErrorMessage = ({ status, body }) => {
|
|
2273
|
+
const parsed = parseJson(body);
|
|
2274
|
+
const details = (parsed?.errors ?? []).map(
|
|
2275
|
+
(error) => typeof error === "string" ? error : error.message ?? [error.field, error.code].filter(Boolean).join(" ")
|
|
2276
|
+
).filter((detail) => detail !== "");
|
|
2277
|
+
const headline = parsed?.message ?? (firstLine(body) || `HTTP ${String(status)}`);
|
|
2278
|
+
return [headline, ...details].filter((part) => part !== "").join(": ");
|
|
2279
|
+
};
|
|
2280
|
+
const createGitHubTransport = (send) => ({
|
|
2281
|
+
graphql: async (query, variables) => interpretGraphql(
|
|
2282
|
+
await send({
|
|
2283
|
+
method: "POST",
|
|
2284
|
+
path: "graphql",
|
|
2285
|
+
accept: "application/json",
|
|
2286
|
+
body: JSON.stringify(
|
|
2287
|
+
variables === void 0 ? { query } : { query, variables }
|
|
2288
|
+
)
|
|
2289
|
+
})
|
|
2290
|
+
),
|
|
2291
|
+
rest: async ({
|
|
2292
|
+
method,
|
|
2293
|
+
path,
|
|
2294
|
+
body
|
|
2295
|
+
}) => {
|
|
2296
|
+
const answer = await send({
|
|
2297
|
+
method,
|
|
2298
|
+
path,
|
|
2299
|
+
accept: "application/vnd.github+json",
|
|
2300
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
2301
|
+
});
|
|
2302
|
+
if (!isSuccess(answer.status)) {
|
|
2303
|
+
throw new Error(
|
|
2304
|
+
`GitHub refused ${method} ${path}: ${restErrorMessage(answer)}`
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
return parseJson(answer.body);
|
|
2308
|
+
},
|
|
2309
|
+
text: async ({ path, accept }) => {
|
|
2310
|
+
const answer = await send({ method: "GET", path, accept, body: void 0 });
|
|
2311
|
+
if (!isSuccess(answer.status)) {
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
`GitHub refused GET ${path}: ${restErrorMessage(answer)}`
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
return answer.body;
|
|
2317
|
+
}
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
const pullRequestParts = [
|
|
2321
|
+
"detail",
|
|
2322
|
+
"checks",
|
|
2323
|
+
"timeline",
|
|
2324
|
+
"commits",
|
|
2325
|
+
"files"
|
|
2326
|
+
];
|
|
2327
|
+
const isPullRequestPart = (value) => pullRequestParts.includes(value);
|
|
2328
|
+
|
|
2329
|
+
const pullRequestPartsStaleAfter = (edit, pullRequestId) => {
|
|
2330
|
+
switch (edit.kind) {
|
|
2331
|
+
case "applySuggestions":
|
|
2332
|
+
case "updateBranch": {
|
|
2333
|
+
return pullRequestParts;
|
|
2334
|
+
}
|
|
2335
|
+
case "body": {
|
|
2336
|
+
return ["detail"];
|
|
2337
|
+
}
|
|
2338
|
+
case "reaction": {
|
|
2339
|
+
return edit.subjectId === pullRequestId ? ["detail"] : ["timeline"];
|
|
2340
|
+
}
|
|
2341
|
+
case "viewed": {
|
|
2342
|
+
return [];
|
|
2343
|
+
}
|
|
2344
|
+
case "comment":
|
|
2345
|
+
case "editComment":
|
|
2346
|
+
case "inlineComment":
|
|
2347
|
+
case "reply":
|
|
2348
|
+
case "resolveThread": {
|
|
2349
|
+
return ["timeline"];
|
|
2350
|
+
}
|
|
2351
|
+
case "review": {
|
|
2352
|
+
return ["detail", "timeline", "files"];
|
|
2353
|
+
}
|
|
2354
|
+
case "assignees":
|
|
2355
|
+
case "draft":
|
|
2356
|
+
case "labels":
|
|
2357
|
+
case "merge":
|
|
2358
|
+
case "reviewers":
|
|
2359
|
+
case "state":
|
|
2360
|
+
case "title": {
|
|
2361
|
+
return ["detail", "timeline"];
|
|
2362
|
+
}
|
|
2363
|
+
default: {
|
|
2364
|
+
return edit;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
const withFileViewed = (files, path, viewed) => ({
|
|
2369
|
+
...files,
|
|
2370
|
+
files: files.files.map(
|
|
2371
|
+
(file) => file.path === path ? { ...file, viewed: viewed ? "viewed" : "unviewed" } : file
|
|
2372
|
+
)
|
|
2373
|
+
});
|
|
2374
|
+
const pullRequestEditMovesBoard = (edit) => {
|
|
2375
|
+
switch (edit.kind) {
|
|
2376
|
+
case "applySuggestions":
|
|
2377
|
+
case "draft":
|
|
2378
|
+
case "merge":
|
|
2379
|
+
case "review":
|
|
2380
|
+
case "reviewers":
|
|
2381
|
+
case "state":
|
|
2382
|
+
case "updateBranch": {
|
|
2383
|
+
return true;
|
|
2384
|
+
}
|
|
2385
|
+
case "assignees":
|
|
2386
|
+
case "body":
|
|
2387
|
+
case "comment":
|
|
2388
|
+
case "editComment":
|
|
2389
|
+
case "inlineComment":
|
|
2390
|
+
case "labels":
|
|
2391
|
+
case "reaction":
|
|
2392
|
+
case "reply":
|
|
2393
|
+
case "resolveThread":
|
|
2394
|
+
case "title":
|
|
2395
|
+
case "viewed": {
|
|
2396
|
+
return false;
|
|
2397
|
+
}
|
|
2398
|
+
default: {
|
|
2399
|
+
return edit;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
|
|
2404
|
+
const partFields = {
|
|
2405
|
+
detail: (pr) => [
|
|
2406
|
+
pr.title,
|
|
2407
|
+
pr.headRef,
|
|
2408
|
+
pr.baseRef,
|
|
2409
|
+
pr.headSha,
|
|
2410
|
+
pr.baseSha,
|
|
2411
|
+
pr.state,
|
|
2412
|
+
pr.reviewState,
|
|
2413
|
+
pr.author,
|
|
2414
|
+
pr.checks.conclusion,
|
|
2415
|
+
pr.approvedBy,
|
|
2416
|
+
pr.changesRequestedBy,
|
|
2417
|
+
pr.requestedReviewers,
|
|
2418
|
+
pr.reRequestedReviewers,
|
|
2419
|
+
pr.requestedTeams,
|
|
2420
|
+
pr.assignees,
|
|
2421
|
+
pr.labels,
|
|
2422
|
+
pr.mergeable,
|
|
2423
|
+
pr.unresolvedThreads === 0,
|
|
2424
|
+
pr.reviewActivityAt,
|
|
2425
|
+
pr.bodyEditedAt,
|
|
2426
|
+
pr.closedAt
|
|
2427
|
+
],
|
|
2428
|
+
checks: (pr) => [pr.headSha, pr.checks],
|
|
2429
|
+
timeline: (pr) => [
|
|
2430
|
+
pr.headSha,
|
|
2431
|
+
pr.baseSha,
|
|
2432
|
+
pr.baseRef,
|
|
2433
|
+
pr.title,
|
|
2434
|
+
pr.state,
|
|
2435
|
+
pr.labels,
|
|
2436
|
+
pr.assignees,
|
|
2437
|
+
pr.requestedReviewers,
|
|
2438
|
+
pr.reRequestedReviewers,
|
|
2439
|
+
pr.requestedTeams,
|
|
2440
|
+
pr.approvedBy,
|
|
2441
|
+
pr.changesRequestedBy,
|
|
2442
|
+
pr.reviewActivityAt,
|
|
2443
|
+
pr.commentActivityAt,
|
|
2444
|
+
pr.reviewCommentActivityAt,
|
|
2445
|
+
pr.unresolvedThreads
|
|
2446
|
+
],
|
|
2447
|
+
commits: (pr) => [pr.headSha, pr.baseSha],
|
|
2448
|
+
files: (pr) => [pr.headSha, pr.baseSha]
|
|
2449
|
+
};
|
|
2450
|
+
const partFingerprint = (pr, part) => JSON.stringify(partFields[part](pr));
|
|
2451
|
+
const changedPullRequestParts = (previous, next) => pullRequestParts.filter(
|
|
2452
|
+
(part) => partFingerprint(previous, part) !== partFingerprint(next, part)
|
|
2453
|
+
);
|
|
2454
|
+
|
|
2455
|
+
const upperOrNull = (value) => value === null ? null : value.toUpperCase();
|
|
2456
|
+
const checksFromReviewflow = ({
|
|
2457
|
+
checkRuns,
|
|
2458
|
+
statuses
|
|
2459
|
+
}, at) => {
|
|
2460
|
+
const nodes = [
|
|
2461
|
+
...checkRuns.map((run) => ({
|
|
2462
|
+
__typename: "CheckRun",
|
|
2463
|
+
name: run.name,
|
|
2464
|
+
status: upperOrNull(run.status) ?? (run.conclusion === null ? "QUEUED" : "COMPLETED"),
|
|
2465
|
+
conclusion: upperOrNull(run.conclusion),
|
|
2466
|
+
title: run.title,
|
|
2467
|
+
detailsUrl: run.detailsUrl
|
|
2468
|
+
})),
|
|
2469
|
+
...statuses.map((status) => ({
|
|
2470
|
+
__typename: "StatusContext",
|
|
2471
|
+
context: status.context,
|
|
2472
|
+
state: status.state.toUpperCase(),
|
|
2473
|
+
description: status.description,
|
|
2474
|
+
targetUrl: status.targetUrl
|
|
2475
|
+
}))
|
|
2476
|
+
];
|
|
2477
|
+
const parsed = parsePullRequestChecks({
|
|
2478
|
+
data: {
|
|
2479
|
+
repository: {
|
|
2480
|
+
pullRequest: {
|
|
2481
|
+
headCommits: {
|
|
2482
|
+
nodes: [
|
|
2483
|
+
{
|
|
2484
|
+
commit: {
|
|
2485
|
+
statusCheckRollup: {
|
|
2486
|
+
contexts: { totalCount: nodes.length, nodes }
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
]
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
},
|
|
2495
|
+
errors: [],
|
|
2496
|
+
ownerRepo: "",
|
|
2497
|
+
number: 0,
|
|
2498
|
+
fetchedAt: at
|
|
2499
|
+
});
|
|
2500
|
+
return { ...parsed, source: "reviewflow" };
|
|
2501
|
+
};
|
|
2502
|
+
|
|
2503
|
+
const reviewflowErrorCode = "ERR_REVIEWFLOW";
|
|
2504
|
+
const timeoutMs = 15e3;
|
|
2505
|
+
const maxLookupTargets = 200;
|
|
2506
|
+
const maxExtraPagesPerBucket = 5;
|
|
2507
|
+
const fail = (message, { status, retryAfterMs } = {}) => Object.assign(new Error(message), {
|
|
2508
|
+
code: reviewflowErrorCode,
|
|
2509
|
+
status,
|
|
2510
|
+
retryAfterMs
|
|
2511
|
+
});
|
|
2512
|
+
const isReviewflowFailure = (error) => error instanceof Error && error.code === reviewflowErrorCode;
|
|
2513
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2514
|
+
const stringOrNull = (value) => typeof value === "string" ? value : null;
|
|
2515
|
+
const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2516
|
+
const prStates = /* @__PURE__ */ new Set(["closed", "draft", "merged", "open"]);
|
|
2517
|
+
const reviewStates = /* @__PURE__ */ new Set([
|
|
2518
|
+
"approved",
|
|
2519
|
+
"changesRequested",
|
|
2520
|
+
"none",
|
|
2521
|
+
"requested"
|
|
2522
|
+
]);
|
|
2523
|
+
const mergeables = /* @__PURE__ */ new Set(["conflicting", "mergeable", "unknown"]);
|
|
2524
|
+
const checksConclusions = {
|
|
2525
|
+
failed: "failed",
|
|
2526
|
+
"in-progress": "inProgress",
|
|
2527
|
+
passed: "passed"
|
|
2528
|
+
};
|
|
2529
|
+
const enumOrNull = (value, allowed) => typeof value === "string" && allowed.has(value) ? value : null;
|
|
2530
|
+
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
2531
|
+
const logins = (value) => Array.isArray(value) ? value.flatMap(
|
|
2532
|
+
(person) => isRecord$1(person) && typeof person.login === "string" ? [person.login] : []
|
|
2533
|
+
) : [];
|
|
2534
|
+
const readCheckRuns = (value) => Array.isArray(value) ? value.flatMap(
|
|
2535
|
+
(run) => isRecord$1(run) && typeof run.name === "string" ? [
|
|
2536
|
+
{
|
|
2537
|
+
name: run.name,
|
|
2538
|
+
status: stringOrNull(run.status),
|
|
2539
|
+
conclusion: stringOrNull(run.conclusion),
|
|
2540
|
+
title: stringOrNull(run.title),
|
|
2541
|
+
detailsUrl: stringOrNull(run.detailsUrl)
|
|
2542
|
+
}
|
|
2543
|
+
] : []
|
|
2544
|
+
) : null;
|
|
2545
|
+
const readCommitStatuses = (value) => Array.isArray(value) ? value.flatMap(
|
|
2546
|
+
(status) => isRecord$1(status) && typeof status.context === "string" && typeof status.state === "string" ? [
|
|
2547
|
+
{
|
|
2548
|
+
context: status.context,
|
|
2549
|
+
state: status.state,
|
|
2550
|
+
description: stringOrNull(status.description),
|
|
2551
|
+
targetUrl: stringOrNull(status.targetUrl)
|
|
2552
|
+
}
|
|
2553
|
+
] : []
|
|
2554
|
+
) : null;
|
|
2555
|
+
const readStatusLinks = (value) => Array.isArray(value) ? value.flatMap(
|
|
2556
|
+
(link) => isRecord$1(link) && link.type === "success" && typeof link.name === "string" && typeof link.label === "string" && typeof link.url === "string" ? [{ name: link.name, label: link.label, url: link.url }] : []
|
|
2557
|
+
) : [];
|
|
2558
|
+
const readLabels = (value) => Array.isArray(value) ? value.flatMap(
|
|
2559
|
+
(label) => isRecord$1(label) && typeof label.name === "string" ? [label.name] : []
|
|
2560
|
+
) : [];
|
|
2561
|
+
const readChanges = (value) => isRecord$1(value) && typeof value.changedFiles === "number" && typeof value.additions === "number" && typeof value.deletions === "number" ? {
|
|
2562
|
+
changedFiles: value.changedFiles,
|
|
2563
|
+
additions: value.additions,
|
|
2564
|
+
deletions: value.deletions
|
|
2565
|
+
} : null;
|
|
2566
|
+
const readPrDetail = (value) => {
|
|
2567
|
+
if (!isRecord$1(value) || typeof value._id !== "string" || typeof value.ownerRepo !== "string" || typeof value.number !== "number" || typeof value.url !== "string" || !isRecord$1(value.checks)) {
|
|
2568
|
+
throw fail("reviewflow returned a pull request the client cannot read");
|
|
2569
|
+
}
|
|
2570
|
+
const { checks } = value;
|
|
2571
|
+
const creator = isRecord$1(value.creator) ? value.creator : null;
|
|
2572
|
+
const flowDates = isRecord$1(value.flowDates) ? value.flowDates : {};
|
|
2573
|
+
return {
|
|
2574
|
+
id: value._id,
|
|
2575
|
+
ownerRepo: value.ownerRepo,
|
|
2576
|
+
number: value.number,
|
|
2577
|
+
title: typeof value.title === "string" ? value.title : "",
|
|
2578
|
+
url: value.url,
|
|
2579
|
+
headRef: stringOrNull(value.headRef),
|
|
2580
|
+
baseRef: stringOrNull(value.baseRef),
|
|
2581
|
+
headSha: stringOrNull(value.headSha),
|
|
2582
|
+
baseSha: stringOrNull(value.baseSha),
|
|
2583
|
+
state: enumOrNull(value.state, prStates),
|
|
2584
|
+
reviewState: enumOrNull(
|
|
2585
|
+
value.reviewState,
|
|
2586
|
+
reviewStates
|
|
2587
|
+
),
|
|
2588
|
+
author: stringOrNull(creator?.login),
|
|
2589
|
+
checks: {
|
|
2590
|
+
conclusion: typeof checks.conclusion === "string" ? checksConclusions[checks.conclusion] ?? "unknown" : "unknown",
|
|
2591
|
+
passed: numberOr(checks.passed, 0),
|
|
2592
|
+
failed: numberOr(checks.failed, 0),
|
|
2593
|
+
pending: numberOr(checks.pending, 0),
|
|
2594
|
+
running: numberOr(checks.runningCount, 0),
|
|
2595
|
+
failedNames: strings(checks.failedNames),
|
|
2596
|
+
checkRuns: readCheckRuns(checks.checkRuns),
|
|
2597
|
+
statuses: readCommitStatuses(checks.statuses)
|
|
2598
|
+
},
|
|
2599
|
+
lintFailed: value.lintFailed === true,
|
|
2600
|
+
approvedBy: logins(value.approvedBy),
|
|
2601
|
+
changesRequestedBy: logins(value.changesRequestedBy),
|
|
2602
|
+
requestedReviewers: logins(value.requestedReviewers),
|
|
2603
|
+
reRequestedReviewers: logins(value.reRequestedReviewers),
|
|
2604
|
+
requestedTeams: strings(value.requestedTeams),
|
|
2605
|
+
assignees: logins(value.assignees),
|
|
2606
|
+
labels: readLabels(value.labels),
|
|
2607
|
+
statusLinks: readStatusLinks(value.statusLinks),
|
|
2608
|
+
openedAt: stringOrNull(flowDates.openedAt),
|
|
2609
|
+
approvedAt: stringOrNull(flowDates.approvedAt),
|
|
2610
|
+
changes: readChanges(value.changes),
|
|
2611
|
+
unresolvedThreads: typeof value.unresolvedThreads === "number" ? value.unresolvedThreads : null,
|
|
2612
|
+
mergeable: enumOrNull(value.mergeable, mergeables) ?? "unknown",
|
|
2613
|
+
closedAt: stringOrNull(flowDates.closedAt),
|
|
2614
|
+
commentActivityAt: stringOrNull(flowDates.lastCommentActivityAt),
|
|
2615
|
+
reviewActivityAt: stringOrNull(flowDates.lastReviewSubmittedAt),
|
|
2616
|
+
reviewCommentActivityAt: stringOrNull(
|
|
2617
|
+
flowDates.lastReviewCommentActivityAt
|
|
2618
|
+
),
|
|
2619
|
+
bodyEditedAt: stringOrNull(flowDates.lastBodyEditedAt),
|
|
2620
|
+
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : ""
|
|
2621
|
+
};
|
|
2622
|
+
};
|
|
2623
|
+
const lookupReasons = /* @__PURE__ */ new Set(["forbidden", "noPr", "notTracked"]);
|
|
2624
|
+
const readLookupResult = (value) => {
|
|
2625
|
+
if (isRecord$1(value) && value.found === true) {
|
|
2626
|
+
return { found: true, pr: readPrDetail(value.pr) };
|
|
2627
|
+
}
|
|
2628
|
+
if (isRecord$1(value) && value.found === false && typeof value.reason === "string" && lookupReasons.has(value.reason)) {
|
|
2629
|
+
return {
|
|
2630
|
+
found: false,
|
|
2631
|
+
reason: value.reason
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
return { found: false, reason: "notTracked" };
|
|
2635
|
+
};
|
|
2636
|
+
const readBucketPage = (value) => {
|
|
2637
|
+
if (!isRecord$1(value) || typeof value.key !== "string" || !Array.isArray(value.prs)) {
|
|
2638
|
+
throw fail("reviewflow returned a board bucket the client cannot read");
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
key: value.key,
|
|
2642
|
+
title: typeof value.title === "string" ? value.title : value.key,
|
|
2643
|
+
total: numberOr(value.total, value.prs.length),
|
|
2644
|
+
prs: value.prs.map(readPrDetail),
|
|
2645
|
+
nextPage: stringOrNull(value.nextPage)
|
|
2646
|
+
};
|
|
2647
|
+
};
|
|
2648
|
+
const readMe = (value) => {
|
|
2649
|
+
if (!isRecord$1(value) || !isRecord$1(value.user) || typeof value.user.login !== "string" || !Array.isArray(value.accounts)) {
|
|
2650
|
+
throw fail("reviewflow returned a /me the client cannot read");
|
|
2651
|
+
}
|
|
2652
|
+
return {
|
|
2653
|
+
login: value.user.login,
|
|
2654
|
+
accounts: value.accounts.filter(isRecord$1).map((account) => ({
|
|
2655
|
+
login: String(account.login),
|
|
2656
|
+
type: account.type === "Organization" ? "Organization" : "User"
|
|
2657
|
+
}))
|
|
2658
|
+
};
|
|
2659
|
+
};
|
|
2660
|
+
const readRetryAfterMs = (response) => {
|
|
2661
|
+
const seconds = Number(response.headers.get("retry-after") ?? Number.NaN);
|
|
2662
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
|
|
2663
|
+
};
|
|
2664
|
+
const responseFailure = async (response) => {
|
|
2665
|
+
const body = await response.json().catch(() => null);
|
|
2666
|
+
const error = isRecord$1(body) && isRecord$1(body.error) ? body.error : null;
|
|
2667
|
+
const detail = error === null ? "" : ` ${String(error.code)}: ${typeof error.message === "string" ? error.message : ""}`;
|
|
2668
|
+
const retryAfter = response.headers.get("retry-after");
|
|
2669
|
+
const retry = retryAfter === null ? "" : ` (retry after ${retryAfter}s)`;
|
|
2670
|
+
return fail(
|
|
2671
|
+
`reviewflow answered ${String(response.status)}${detail}${retry}`,
|
|
2672
|
+
{ status: response.status, retryAfterMs: readRetryAfterMs(response) }
|
|
2673
|
+
);
|
|
2674
|
+
};
|
|
2675
|
+
const chunk = (items, size) => Array.from(
|
|
2676
|
+
{ length: Math.ceil(items.length / size) },
|
|
2677
|
+
(_, index) => items.slice(index * size, (index + 1) * size)
|
|
2678
|
+
);
|
|
2679
|
+
const createReviewflowClient = ({
|
|
2680
|
+
url,
|
|
2681
|
+
token,
|
|
2682
|
+
fetch = globalThis.fetch
|
|
2683
|
+
}) => {
|
|
2684
|
+
const request = async (path, init = {}) => {
|
|
2685
|
+
const response = await fetch(`${url}/api${path}`, {
|
|
2686
|
+
method: init.method ?? "GET",
|
|
2687
|
+
headers: {
|
|
2688
|
+
authorization: `Bearer ${token}`,
|
|
2689
|
+
accept: "application/json",
|
|
2690
|
+
...init.body === void 0 ? {} : { "content-type": "application/json" }
|
|
2691
|
+
},
|
|
2692
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
2693
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2694
|
+
}).catch((error) => {
|
|
2695
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
2696
|
+
throw fail(`reviewflow could not be reached at ${url}: ${cause}`);
|
|
2697
|
+
});
|
|
2698
|
+
if (!response.ok) throw await responseFailure(response);
|
|
2699
|
+
return response.json().catch(() => {
|
|
2700
|
+
throw fail("reviewflow answered with a body that is not json");
|
|
2701
|
+
});
|
|
2702
|
+
};
|
|
2703
|
+
const readBucket = async (account, first) => {
|
|
2704
|
+
const prs = [...first.prs];
|
|
2705
|
+
let { nextPage } = first;
|
|
2706
|
+
for (let page = 0; nextPage !== null && page < maxExtraPagesPerBucket; page++) {
|
|
2707
|
+
const query = new URLSearchParams({
|
|
2708
|
+
bucket: first.key,
|
|
2709
|
+
account,
|
|
2710
|
+
page: nextPage
|
|
2711
|
+
});
|
|
2712
|
+
const next = readBucketPage(
|
|
2713
|
+
await request(`/board-bucket/v1?${query.toString()}`)
|
|
2714
|
+
);
|
|
2715
|
+
prs.push(...next.prs);
|
|
2716
|
+
({ nextPage } = next);
|
|
2717
|
+
}
|
|
2718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2719
|
+
return {
|
|
2720
|
+
key: first.key,
|
|
2721
|
+
title: first.title,
|
|
2722
|
+
total: first.total,
|
|
2723
|
+
prs: prs.filter((pr) => !seen.has(pr.id) && seen.add(pr.id))
|
|
2724
|
+
};
|
|
2725
|
+
};
|
|
2726
|
+
return {
|
|
2727
|
+
me: async () => readMe(await request("/me/v1")),
|
|
2728
|
+
lookup: async (targets) => {
|
|
2729
|
+
const answers = await Promise.all(
|
|
2730
|
+
chunk(targets, maxLookupTargets).map(async (part) => {
|
|
2731
|
+
const body = await request("/prs-lookup/v1", {
|
|
2732
|
+
method: "POST",
|
|
2733
|
+
body: { targets: part }
|
|
2734
|
+
});
|
|
2735
|
+
if (!isRecord$1(body) || !Array.isArray(body.results) || body.results.length !== part.length) {
|
|
2736
|
+
throw fail("reviewflow returned a lookup the client cannot read");
|
|
2737
|
+
}
|
|
2738
|
+
return body.results.map(readLookupResult);
|
|
2739
|
+
})
|
|
2740
|
+
);
|
|
2741
|
+
return answers.flat();
|
|
2742
|
+
},
|
|
2743
|
+
pr: async (ownerRepo, number) => {
|
|
2744
|
+
const path = ownerRepo.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
2745
|
+
try {
|
|
2746
|
+
return readPrDetail(await request(`/pr/v1/${path}/${String(number)}`));
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
if (isReviewflowFailure(error) && (error.status === 403 || error.status === 404)) {
|
|
2749
|
+
return null;
|
|
2750
|
+
}
|
|
2751
|
+
throw error;
|
|
2752
|
+
}
|
|
2753
|
+
},
|
|
2754
|
+
board: async (account) => {
|
|
2755
|
+
const body = await request(
|
|
2756
|
+
`/board/v1?${new URLSearchParams({ account }).toString()}`
|
|
2757
|
+
);
|
|
2758
|
+
if (!isRecord$1(body) || !Array.isArray(body.groups)) {
|
|
2759
|
+
throw fail("reviewflow returned a board the client cannot read");
|
|
2760
|
+
}
|
|
2761
|
+
return Promise.all(
|
|
2762
|
+
body.groups.map(async (group) => {
|
|
2763
|
+
if (!isRecord$1(group) || typeof group.key !== "string" || !Array.isArray(group.buckets)) {
|
|
2764
|
+
throw fail(
|
|
2765
|
+
"reviewflow returned a board group the client cannot read"
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
return {
|
|
2769
|
+
key: group.key,
|
|
2770
|
+
title: typeof group.title === "string" ? group.title : group.key,
|
|
2771
|
+
buckets: await Promise.all(
|
|
2772
|
+
group.buckets.map(
|
|
2773
|
+
(bucket) => readBucket(account, readBucketPage(bucket))
|
|
2774
|
+
)
|
|
2775
|
+
)
|
|
2776
|
+
};
|
|
2777
|
+
})
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
};
|
|
2782
|
+
|
|
2783
|
+
const streamIdleTimeoutMs = 6e4;
|
|
2784
|
+
const streamClosedWithinDays = 7;
|
|
2785
|
+
const parseFrame = (block) => {
|
|
2786
|
+
let event = "message";
|
|
2787
|
+
const data = [];
|
|
2788
|
+
for (const line of block.split("\n")) {
|
|
2789
|
+
if (line.startsWith(":")) continue;
|
|
2790
|
+
const separator = line.indexOf(":");
|
|
2791
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
2792
|
+
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
|
2793
|
+
if (field === "event") event = value;
|
|
2794
|
+
else if (field === "data") data.push(value);
|
|
2795
|
+
}
|
|
2796
|
+
return { event, data: data.join("\n") };
|
|
2797
|
+
};
|
|
2798
|
+
const parseData = (frame) => {
|
|
2799
|
+
try {
|
|
2800
|
+
return JSON.parse(frame.data);
|
|
2801
|
+
} catch {
|
|
2802
|
+
return null;
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
const toStreamEvent = (frame, logger) => {
|
|
2806
|
+
switch (frame.event) {
|
|
2807
|
+
case "pr": {
|
|
2808
|
+
try {
|
|
2809
|
+
return { type: "pr", pr: readPrDetail(parseData(frame)) };
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
logger?.warn(
|
|
2812
|
+
"reviewflow streamed a pull request the client cannot read",
|
|
2813
|
+
{ error }
|
|
2814
|
+
);
|
|
2815
|
+
return null;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
case "prRemoved": {
|
|
2819
|
+
const data = parseData(frame);
|
|
2820
|
+
return typeof data?._id === "string" ? { type: "prRemoved", id: data._id } : null;
|
|
2821
|
+
}
|
|
2822
|
+
case "ready":
|
|
2823
|
+
return { type: "ready" };
|
|
2824
|
+
case "scopeChanged":
|
|
2825
|
+
return { type: "end", reason: "scopeChanged" };
|
|
2826
|
+
case "reset": {
|
|
2827
|
+
const data = parseData(frame);
|
|
2828
|
+
return {
|
|
2829
|
+
type: "end",
|
|
2830
|
+
reason: `reset: ${typeof data?.reason === "string" ? data.reason : "unknown"}`
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
default:
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
async function* readReviewflowStream({
|
|
2838
|
+
url,
|
|
2839
|
+
token,
|
|
2840
|
+
signal,
|
|
2841
|
+
fetch = globalThis.fetch,
|
|
2842
|
+
idleTimeoutMs = streamIdleTimeoutMs,
|
|
2843
|
+
logger
|
|
2844
|
+
}) {
|
|
2845
|
+
const controller = new AbortController();
|
|
2846
|
+
let reader;
|
|
2847
|
+
const cancel = () => {
|
|
2848
|
+
controller.abort();
|
|
2849
|
+
reader?.cancel().catch(() => void 0);
|
|
2850
|
+
};
|
|
2851
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2852
|
+
let silent = false;
|
|
2853
|
+
let idle;
|
|
2854
|
+
const armIdle = () => {
|
|
2855
|
+
clearTimeout(idle);
|
|
2856
|
+
idle = setTimeout(() => {
|
|
2857
|
+
silent = true;
|
|
2858
|
+
cancel();
|
|
2859
|
+
}, idleTimeoutMs);
|
|
2860
|
+
};
|
|
2861
|
+
const silence = () => fail(
|
|
2862
|
+
`reviewflow's stream went silent for ${String(idleTimeoutMs / 1e3)}s`
|
|
2863
|
+
);
|
|
2864
|
+
try {
|
|
2865
|
+
armIdle();
|
|
2866
|
+
const query = new URLSearchParams({
|
|
2867
|
+
closedWithinDays: String(streamClosedWithinDays)
|
|
2868
|
+
});
|
|
2869
|
+
const response = await fetch(`${url}/api/stream/v1?${query.toString()}`, {
|
|
2870
|
+
headers: {
|
|
2871
|
+
authorization: `Bearer ${token}`,
|
|
2872
|
+
accept: "text/event-stream"
|
|
2873
|
+
},
|
|
2874
|
+
signal: controller.signal
|
|
2875
|
+
});
|
|
2876
|
+
if (!response.ok) throw await responseFailure(response);
|
|
2877
|
+
if (response.body === null) {
|
|
2878
|
+
throw fail("reviewflow answered the stream with no body");
|
|
2879
|
+
}
|
|
2880
|
+
reader = response.body.getReader();
|
|
2881
|
+
const decoder = new TextDecoder();
|
|
2882
|
+
let buffer = "";
|
|
2883
|
+
for (; ; ) {
|
|
2884
|
+
const { value, done } = await reader.read();
|
|
2885
|
+
if (done) break;
|
|
2886
|
+
armIdle();
|
|
2887
|
+
buffer = (buffer + decoder.decode(value, { stream: true })).replaceAll(
|
|
2888
|
+
"\r\n",
|
|
2889
|
+
"\n"
|
|
2890
|
+
);
|
|
2891
|
+
let end = buffer.indexOf("\n\n");
|
|
2892
|
+
while (end !== -1) {
|
|
2893
|
+
const event = toStreamEvent(parseFrame(buffer.slice(0, end)), logger);
|
|
2894
|
+
buffer = buffer.slice(end + 2);
|
|
2895
|
+
if (event !== null) {
|
|
2896
|
+
yield event;
|
|
2897
|
+
if (event.type === "end") return;
|
|
2898
|
+
}
|
|
2899
|
+
end = buffer.indexOf("\n\n");
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
if (silent) throw silence();
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
if (signal.aborted) return;
|
|
2905
|
+
if (silent) throw silence();
|
|
2906
|
+
if (isReviewflowFailure(error)) throw error;
|
|
2907
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
2908
|
+
throw fail(`reviewflow's stream could not be read at ${url}: ${cause}`);
|
|
2909
|
+
} finally {
|
|
2910
|
+
clearTimeout(idle);
|
|
2911
|
+
signal.removeEventListener("abort", cancel);
|
|
2912
|
+
cancel();
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
const toBoardPr = (pr) => ({
|
|
2917
|
+
id: pr.id,
|
|
2918
|
+
ownerRepo: pr.ownerRepo,
|
|
2919
|
+
number: pr.number,
|
|
2920
|
+
title: pr.title,
|
|
2921
|
+
url: pr.url,
|
|
2922
|
+
headRef: pr.headRef,
|
|
2923
|
+
state: pr.state,
|
|
2924
|
+
reviewState: pr.reviewState,
|
|
2925
|
+
author: pr.author,
|
|
2926
|
+
checksFailed: pr.checks.failed,
|
|
2927
|
+
checksConclusion: pr.checks.conclusion,
|
|
2928
|
+
checksRunning: pr.checks.running,
|
|
2929
|
+
failedChecks: pr.checks.failedNames,
|
|
2930
|
+
lintFailed: pr.lintFailed,
|
|
2931
|
+
approvedBy: pr.approvedBy,
|
|
2932
|
+
changesRequestedBy: pr.changesRequestedBy,
|
|
2933
|
+
requestedReviewers: pr.requestedReviewers,
|
|
2934
|
+
reRequestedReviewers: pr.reRequestedReviewers,
|
|
2935
|
+
requestedTeams: pr.requestedTeams,
|
|
2936
|
+
assignees: pr.assignees,
|
|
2937
|
+
statusLinks: pr.statusLinks,
|
|
2938
|
+
changes: pr.changes,
|
|
2939
|
+
openedAt: pr.openedAt,
|
|
2940
|
+
approvedAt: pr.approvedAt,
|
|
2941
|
+
unresolvedThreads: pr.unresolvedThreads,
|
|
2942
|
+
mergeable: pr.mergeable,
|
|
2943
|
+
updatedAt: pr.updatedAt
|
|
2944
|
+
});
|
|
2945
|
+
const accountOfOwnerRepo = (ownerRepo) => ownerRepo.slice(0, ownerRepo.indexOf("/")).toLowerCase();
|
|
2946
|
+
const isPullRequestOnBoard = (board, { ownerRepo, number }) => {
|
|
2947
|
+
const wanted = ownerRepo.toLowerCase();
|
|
2948
|
+
return board?.groups.some(
|
|
2949
|
+
(group) => group.buckets.some(
|
|
2950
|
+
(bucket) => bucket.prs.some(
|
|
2951
|
+
(pr) => pr.number === number && pr.ownerRepo.toLowerCase() === wanted
|
|
2952
|
+
)
|
|
2953
|
+
)
|
|
2954
|
+
) ?? false;
|
|
2955
|
+
};
|
|
2956
|
+
const withAccountBoard = (snapshot, account, board) => ({ ...snapshot, [account]: board });
|
|
2957
|
+
const readAccountBoards = async (client, accounts) => {
|
|
2958
|
+
const settled = await Promise.allSettled(
|
|
2959
|
+
accounts.map(async (account) => ({
|
|
2960
|
+
account,
|
|
2961
|
+
groups: await client.board(account)
|
|
2962
|
+
}))
|
|
2963
|
+
);
|
|
2964
|
+
const read = { boards: [], errors: [] };
|
|
2965
|
+
settled.forEach((result, index) => {
|
|
2966
|
+
if (result.status === "fulfilled") read.boards.push(result.value);
|
|
2967
|
+
else {
|
|
2968
|
+
const { reason } = result;
|
|
2969
|
+
read.errors.push({
|
|
2970
|
+
account: accounts[index],
|
|
2971
|
+
message: reason instanceof Error ? reason.message : String(reason)
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
});
|
|
2975
|
+
return read;
|
|
2976
|
+
};
|
|
2977
|
+
const assembleBoard = (snapshot, viewer, accounts = Object.keys(snapshot)) => {
|
|
2978
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2979
|
+
const fetchedAts = [];
|
|
2980
|
+
for (const account of accounts) {
|
|
2981
|
+
const read = snapshot[account];
|
|
2982
|
+
if (read === void 0) continue;
|
|
2983
|
+
fetchedAts.push(read.fetchedAt);
|
|
2984
|
+
let position = 0;
|
|
2985
|
+
for (const group of read.groups) {
|
|
2986
|
+
for (const bucket of group.buckets) {
|
|
2987
|
+
const existing = merged.get(bucket.key);
|
|
2988
|
+
const prs = bucket.prs.map((pr) => toBoardPr(pr));
|
|
2989
|
+
if (existing === void 0) {
|
|
2990
|
+
merged.set(bucket.key, {
|
|
2991
|
+
key: bucket.key,
|
|
2992
|
+
group: { key: group.key, title: group.title },
|
|
2993
|
+
title: bucket.title,
|
|
2994
|
+
total: bucket.total,
|
|
2995
|
+
position,
|
|
2996
|
+
prs,
|
|
2997
|
+
fetchedAt: read.fetchedAt
|
|
2998
|
+
});
|
|
2999
|
+
} else {
|
|
3000
|
+
existing.total += bucket.total;
|
|
3001
|
+
existing.position = Math.min(existing.position, position);
|
|
3002
|
+
const seen = new Set(existing.prs.map((pr) => pr.id));
|
|
3003
|
+
existing.prs.push(...prs.filter((pr) => !seen.has(pr.id)));
|
|
3004
|
+
}
|
|
3005
|
+
position++;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
if (merged.size === 0) return { board: null, viewer };
|
|
3010
|
+
const groups = [];
|
|
3011
|
+
for (const bucket of [...merged.values()].toSorted(
|
|
3012
|
+
(a, b) => a.position - b.position
|
|
3013
|
+
)) {
|
|
3014
|
+
const entry = {
|
|
3015
|
+
key: bucket.key,
|
|
3016
|
+
title: bucket.title,
|
|
3017
|
+
total: bucket.total,
|
|
3018
|
+
prs: bucket.prs
|
|
3019
|
+
};
|
|
3020
|
+
const last = groups.at(-1);
|
|
3021
|
+
if (last?.key === bucket.group.key) last.buckets.push(entry);
|
|
3022
|
+
else {
|
|
3023
|
+
groups.push({
|
|
3024
|
+
key: bucket.group.key,
|
|
3025
|
+
title: bucket.group.title,
|
|
3026
|
+
buckets: [entry]
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
const [oldest] = fetchedAts.toSorted((a, b) => a.localeCompare(b));
|
|
3031
|
+
return { board: { fetchedAt: oldest ?? "", groups }, viewer };
|
|
3032
|
+
};
|
|
3033
|
+
const applyStreamEvent = (snapshot, event) => {
|
|
3034
|
+
if (event.type !== "pr" && event.type !== "prRemoved") {
|
|
3035
|
+
return { snapshot, changed: false, readAccounts: [] };
|
|
3036
|
+
}
|
|
3037
|
+
const id = event.type === "pr" ? event.pr.id : event.id;
|
|
3038
|
+
let changed = false;
|
|
3039
|
+
const holding = /* @__PURE__ */ new Set();
|
|
3040
|
+
const next = {};
|
|
3041
|
+
for (const [account, read] of Object.entries(snapshot)) {
|
|
3042
|
+
const groups = read.groups.map((group) => ({
|
|
3043
|
+
...group,
|
|
3044
|
+
buckets: group.buckets.map((bucket) => {
|
|
3045
|
+
if (!bucket.prs.some((pr) => pr.id === id)) return bucket;
|
|
3046
|
+
holding.add(account);
|
|
3047
|
+
changed = true;
|
|
3048
|
+
if (event.type === "pr") {
|
|
3049
|
+
return {
|
|
3050
|
+
...bucket,
|
|
3051
|
+
prs: bucket.prs.map((pr) => pr.id === id ? event.pr : pr)
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
return {
|
|
3055
|
+
...bucket,
|
|
3056
|
+
total: Math.max(0, bucket.total - 1),
|
|
3057
|
+
prs: bucket.prs.filter((pr) => pr.id !== id)
|
|
3058
|
+
};
|
|
3059
|
+
})
|
|
3060
|
+
}));
|
|
3061
|
+
next[account] = { ...read, groups };
|
|
3062
|
+
}
|
|
3063
|
+
const readAccounts = new Set(holding);
|
|
3064
|
+
if (event.type === "pr") {
|
|
3065
|
+
const owner = accountOfOwnerRepo(event.pr.ownerRepo);
|
|
3066
|
+
for (const account of Object.keys(snapshot)) {
|
|
3067
|
+
if (account.toLowerCase() === owner) readAccounts.add(account);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
return {
|
|
3071
|
+
snapshot: changed ? next : snapshot,
|
|
3072
|
+
changed,
|
|
3073
|
+
readAccounts: [...readAccounts]
|
|
3074
|
+
};
|
|
3075
|
+
};
|
|
3076
|
+
|
|
3077
|
+
const lingerMs = 5 * 6e4;
|
|
3078
|
+
const boardReadDelayMs = 3e3;
|
|
3079
|
+
const reconnectDelayMs = 1e3;
|
|
3080
|
+
const minRetryMs = 2e3;
|
|
3081
|
+
const maxRetryMs = 5 * 6e4;
|
|
3082
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
3083
|
+
const retryDelayMs = (error, failures) => {
|
|
3084
|
+
if (isReviewflowFailure(error)) {
|
|
3085
|
+
if (error.retryAfterMs !== void 0) return error.retryAfterMs;
|
|
3086
|
+
if (error.status === 401) return maxRetryMs;
|
|
3087
|
+
}
|
|
3088
|
+
return Math.min(minRetryMs * 2 ** (failures - 1), maxRetryMs);
|
|
3089
|
+
};
|
|
3090
|
+
const wait = async (ms, signal) => new Promise((resolve) => {
|
|
3091
|
+
const done = () => {
|
|
3092
|
+
clearTimeout(timer);
|
|
3093
|
+
signal.removeEventListener("abort", done);
|
|
3094
|
+
resolve();
|
|
3095
|
+
};
|
|
3096
|
+
const timer = setTimeout(done, ms);
|
|
3097
|
+
signal.addEventListener("abort", done, { once: true });
|
|
3098
|
+
});
|
|
3099
|
+
const createReviewflowFollower = ({
|
|
3100
|
+
connect,
|
|
3101
|
+
accounts,
|
|
3102
|
+
onPr,
|
|
3103
|
+
onPrRemoved,
|
|
3104
|
+
onAccountBoard,
|
|
3105
|
+
onBoardError,
|
|
3106
|
+
onPullRequestChanged,
|
|
3107
|
+
onStateChanged,
|
|
3108
|
+
onLog
|
|
3109
|
+
}) => {
|
|
3110
|
+
let watched = false;
|
|
3111
|
+
let state = "off";
|
|
3112
|
+
let controller = null;
|
|
3113
|
+
let linger;
|
|
3114
|
+
const boardReads = /* @__PURE__ */ new Map();
|
|
3115
|
+
const accountOfPr = /* @__PURE__ */ new Map();
|
|
3116
|
+
const lastSeen = /* @__PURE__ */ new Map();
|
|
3117
|
+
const setState = (next, error) => {
|
|
3118
|
+
if (state === next && error === void 0) return;
|
|
3119
|
+
state = next;
|
|
3120
|
+
onStateChanged?.(next, error ?? null);
|
|
3121
|
+
};
|
|
3122
|
+
const comparePr = (pr) => {
|
|
3123
|
+
const previous = lastSeen.get(pr.id);
|
|
3124
|
+
lastSeen.set(pr.id, pr);
|
|
3125
|
+
if (previous === void 0 || onPullRequestChanged === void 0) return;
|
|
3126
|
+
const parts = changedPullRequestParts(previous, pr);
|
|
3127
|
+
if (parts.length === 0) return;
|
|
3128
|
+
onPullRequestChanged({ ownerRepo: pr.ownerRepo, number: pr.number, parts });
|
|
3129
|
+
};
|
|
3130
|
+
const linked = (account) => accounts().find((owner) => owner.toLowerCase() === account.toLowerCase());
|
|
3131
|
+
const readBoard = async (connection, account, signal) => {
|
|
3132
|
+
const read = boardReads.get(account);
|
|
3133
|
+
if (read === void 0 || signal.aborted) return false;
|
|
3134
|
+
read.timer = void 0;
|
|
3135
|
+
read.inFlight = true;
|
|
3136
|
+
read.again = false;
|
|
3137
|
+
try {
|
|
3138
|
+
const groups = await connection.client.board(account);
|
|
3139
|
+
if (signal.aborted) return false;
|
|
3140
|
+
onAccountBoard(account, groups);
|
|
3141
|
+
} catch (error) {
|
|
3142
|
+
if (signal.aborted) return false;
|
|
3143
|
+
onBoardError?.(account, messageOf(error));
|
|
3144
|
+
} finally {
|
|
3145
|
+
read.inFlight = false;
|
|
3146
|
+
}
|
|
3147
|
+
return read.again;
|
|
3148
|
+
};
|
|
3149
|
+
const scheduleBoard = (connection, account, signal) => {
|
|
3150
|
+
const owner = linked(account);
|
|
3151
|
+
if (owner === void 0) return;
|
|
3152
|
+
const read = boardReads.get(owner) ?? { inFlight: false, again: false };
|
|
3153
|
+
boardReads.set(owner, read);
|
|
3154
|
+
if (read.inFlight) {
|
|
3155
|
+
read.again = true;
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
read.timer ??= setTimeout(() => {
|
|
3159
|
+
readBoard(connection, owner, signal).then((again) => {
|
|
3160
|
+
if (again) scheduleBoard(connection, owner, signal);
|
|
3161
|
+
}).catch((error) => {
|
|
3162
|
+
onLog?.({
|
|
3163
|
+
level: "warn",
|
|
3164
|
+
message: "reviewflow board read failed",
|
|
3165
|
+
error
|
|
3166
|
+
});
|
|
3167
|
+
});
|
|
3168
|
+
}, boardReadDelayMs);
|
|
3169
|
+
};
|
|
3170
|
+
const scheduleEveryBoard = (connection, signal) => {
|
|
3171
|
+
for (const owner of accounts()) scheduleBoard(connection, owner, signal);
|
|
3172
|
+
};
|
|
3173
|
+
const follow = async (connection, signal, onReady) => {
|
|
3174
|
+
const scope = [];
|
|
3175
|
+
let ready = false;
|
|
3176
|
+
for await (const event of connection.events(signal)) {
|
|
3177
|
+
if (event.type === "end") {
|
|
3178
|
+
onLog?.({
|
|
3179
|
+
level: "info",
|
|
3180
|
+
message: "reviewflow ended the stream",
|
|
3181
|
+
fields: { reason: event.reason }
|
|
3182
|
+
});
|
|
3183
|
+
scheduleEveryBoard(connection, signal);
|
|
3184
|
+
return ready;
|
|
3185
|
+
}
|
|
3186
|
+
if (event.type === "ready") {
|
|
3187
|
+
ready = true;
|
|
3188
|
+
onReady();
|
|
3189
|
+
onLog?.({
|
|
3190
|
+
level: "info",
|
|
3191
|
+
message: "reviewflow stream ready",
|
|
3192
|
+
fields: { prs: scope.length }
|
|
3193
|
+
});
|
|
3194
|
+
for (const pr of scope.splice(0)) onPr(pr, "scope");
|
|
3195
|
+
setState("live");
|
|
3196
|
+
scheduleEveryBoard(connection, signal);
|
|
3197
|
+
} else if (event.type === "pr") {
|
|
3198
|
+
accountOfPr.set(event.pr.id, accountOfOwnerRepo(event.pr.ownerRepo));
|
|
3199
|
+
comparePr(event.pr);
|
|
3200
|
+
if (ready) {
|
|
3201
|
+
onPr(event.pr, "change");
|
|
3202
|
+
scheduleBoard(
|
|
3203
|
+
connection,
|
|
3204
|
+
accountOfOwnerRepo(event.pr.ownerRepo),
|
|
3205
|
+
signal
|
|
3206
|
+
);
|
|
3207
|
+
} else {
|
|
3208
|
+
scope.push(event.pr);
|
|
3209
|
+
}
|
|
3210
|
+
} else if (ready) {
|
|
3211
|
+
const account = accountOfPr.get(event.id);
|
|
3212
|
+
accountOfPr.delete(event.id);
|
|
3213
|
+
lastSeen.delete(event.id);
|
|
3214
|
+
onPrRemoved(event.id);
|
|
3215
|
+
if (account === void 0) scheduleEveryBoard(connection, signal);
|
|
3216
|
+
else scheduleBoard(connection, account, signal);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
return ready;
|
|
3220
|
+
};
|
|
3221
|
+
const run = async (signal) => {
|
|
3222
|
+
let failures = 0;
|
|
3223
|
+
const resetFailures = () => {
|
|
3224
|
+
failures = 0;
|
|
3225
|
+
};
|
|
3226
|
+
while (!signal.aborted) {
|
|
3227
|
+
const connection = connect();
|
|
3228
|
+
if (connection === null) {
|
|
3229
|
+
setState("off");
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
let delayMs = reconnectDelayMs;
|
|
3233
|
+
setState("connecting");
|
|
3234
|
+
try {
|
|
3235
|
+
const reachedReady = await follow(connection, signal, resetFailures);
|
|
3236
|
+
if (!reachedReady && !signal.aborted) {
|
|
3237
|
+
failures++;
|
|
3238
|
+
delayMs = retryDelayMs(void 0, failures);
|
|
3239
|
+
onLog?.({
|
|
3240
|
+
level: "warn",
|
|
3241
|
+
message: "reviewflow ended the stream before it was ready",
|
|
3242
|
+
fields: { retryInMs: delayMs }
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
} catch (error) {
|
|
3246
|
+
if (signal.aborted) return;
|
|
3247
|
+
failures++;
|
|
3248
|
+
delayMs = retryDelayMs(error, failures);
|
|
3249
|
+
onLog?.({
|
|
3250
|
+
level: "warn",
|
|
3251
|
+
message: "reviewflow stream failed",
|
|
3252
|
+
error,
|
|
3253
|
+
fields: { retryInMs: delayMs }
|
|
3254
|
+
});
|
|
3255
|
+
setState("error", messageOf(error));
|
|
3256
|
+
}
|
|
3257
|
+
if (state === "live") setState("connecting");
|
|
3258
|
+
await wait(delayMs, signal);
|
|
3259
|
+
}
|
|
3260
|
+
};
|
|
3261
|
+
const stop = () => {
|
|
3262
|
+
controller?.abort();
|
|
3263
|
+
controller = null;
|
|
3264
|
+
for (const read of boardReads.values()) clearTimeout(read.timer);
|
|
3265
|
+
boardReads.clear();
|
|
3266
|
+
accountOfPr.clear();
|
|
3267
|
+
setState("off");
|
|
3268
|
+
};
|
|
3269
|
+
const start = () => {
|
|
3270
|
+
if (controller !== null) return;
|
|
3271
|
+
const current = new AbortController();
|
|
3272
|
+
controller = current;
|
|
3273
|
+
run(current.signal).catch((error) => {
|
|
3274
|
+
onLog?.({
|
|
3275
|
+
level: "error",
|
|
3276
|
+
message: "reviewflow stream stopped",
|
|
3277
|
+
error
|
|
3278
|
+
});
|
|
3279
|
+
}).finally(() => {
|
|
3280
|
+
if (controller === current) controller = null;
|
|
3281
|
+
});
|
|
3282
|
+
};
|
|
3283
|
+
return {
|
|
3284
|
+
setWatched: (value) => {
|
|
3285
|
+
watched = value;
|
|
3286
|
+
clearTimeout(linger);
|
|
3287
|
+
linger = void 0;
|
|
3288
|
+
if (value) start();
|
|
3289
|
+
else if (controller !== null) linger = setTimeout(stop, lingerMs);
|
|
3290
|
+
},
|
|
3291
|
+
restart: () => {
|
|
3292
|
+
const wasOpen = controller !== null;
|
|
3293
|
+
stop();
|
|
3294
|
+
lastSeen.clear();
|
|
3295
|
+
if (watched || wasOpen) start();
|
|
3296
|
+
},
|
|
3297
|
+
state: () => state,
|
|
3298
|
+
close: () => {
|
|
3299
|
+
watched = false;
|
|
3300
|
+
clearTimeout(linger);
|
|
3301
|
+
stop();
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3307
|
+
const insertCheckoutEntry = (source, ownerRepo) => {
|
|
3308
|
+
let parsed;
|
|
3309
|
+
if (source.trim() === "") {
|
|
3310
|
+
parsed = {};
|
|
3311
|
+
} else {
|
|
3312
|
+
try {
|
|
3313
|
+
parsed = JSON.parse(source);
|
|
3314
|
+
} catch {
|
|
3315
|
+
return null;
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
if (!isRecord(parsed)) return null;
|
|
3319
|
+
const repos = parsed.repos ?? {};
|
|
3320
|
+
if (!isRecord(repos)) return null;
|
|
3321
|
+
const wanted = ownerRepo.toLowerCase();
|
|
3322
|
+
const existing = Object.keys(repos).find(
|
|
3323
|
+
(key2) => key2.toLowerCase() === wanted
|
|
3324
|
+
);
|
|
3325
|
+
const key = existing ?? ownerRepo;
|
|
3326
|
+
const next = existing === void 0 ? { ...repos, [key]: "" } : repos;
|
|
3327
|
+
const text = `${JSON.stringify({ ...parsed, repos: next }, null, 2)}
|
|
3328
|
+
`;
|
|
3329
|
+
const marker = `${JSON.stringify(key)}: "`;
|
|
3330
|
+
const reposAt = text.indexOf('\n "repos": ');
|
|
3331
|
+
const at = text.indexOf(marker, reposAt);
|
|
3332
|
+
if (at === -1) return null;
|
|
3333
|
+
const value = at + marker.length;
|
|
3334
|
+
return { source: text, cursor: value };
|
|
3335
|
+
};
|
|
3336
|
+
|
|
3337
|
+
export { accountOfOwnerRepo, applyPullRequestEdit, applyStreamEvent, applySuggestionsToText, assembleBoard, boardReadDelayMs, buildPrQuery, changedPullRequestParts, checkOutcomes, checksFromReviewflow, coAuthorTrailers, commitSuggestions, createGitHubTransport, createReviewflowClient, createReviewflowFollower, diffLines, diffPaths, diffThemeNames, diffThemeOptions, diffThemes, editPullRequest, fail, fetchCompareDiff, fetchPullRequestCandidates, fetchPullRequestHead, fetchPullRequestPart, fetchPullRequestRevisions, insertCheckoutEntry, interpretGraphql, isAttachablePath, isDiffTheme, isDiffWhitespace, isPullRequestOnBoard, isPullRequestPart, isPullRequestReactionContent, isReviewflowFailure, lingerMs, maxExtraPagesPerBucket, maxLookupTargets, maxRetryMs, parsePrQueryResponse, parsePullRequestCandidates, parsePullRequestChecks, parsePullRequestCommits, parsePullRequestDetail, parsePullRequestEdit, parsePullRequestFiles, parsePullRequestRevisions, parsePullRequestTimeline, parseSuggestions, partFingerprint, planSuggestions, prFields, pullRequestCandidatesQuery, pullRequestChecksQuery, pullRequestCommitsQuery, pullRequestDetailQuery, pullRequestEditMovesBoard, pullRequestFilesQuery, pullRequestHeadQuery, pullRequestParts, pullRequestPartsStaleAfter, pullRequestReactionContents, pullRequestRevisionsQuery, pullRequestTimelineQuery, readAccountBoards, readPrDetail, readReviewflowStream, reconnectDelayMs, responseFailure, restErrorMessage, reviewflowErrorCode, splitOwnerRepo, streamClosedWithinDays, streamIdleTimeoutMs, subjectsOf, suggestionCommitRefusal, suggestionOriginalLines, suggestionRangesOverlap, toBoardPr, toFetchedPr, withAccountBoard, withFileViewed };
|
|
3338
|
+
//# sourceMappingURL=index-node.mjs.map
|