@henryqw/pi-pr 0.3.7 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -23
- package/docs/pr-routing.svg +127 -0
- package/extensions/pr-command.ts +154 -0
- package/extensions/pr-github.ts +953 -0
- package/extensions/pr-merge.ts +288 -0
- package/extensions/pr-routing.ts +59 -0
- package/extensions/pr-ui.ts +98 -0
- package/extensions/pr.ts +82 -259
- package/package.json +4 -3
- package/skills/pi-pr-comment-sweep/SKILL.md +79 -0
- package/skills/pi-pr-comment-sweep/references/recovery.md +49 -0
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +8 -0
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1672 -0
- package/skills/pi-pr-create/SKILL.md +25 -1
- package/skills/pi-pr-fix-ci/SKILL.md +59 -0
- package/skills/pi-pr-update-branch/SKILL.md +116 -0
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PR_FIELDS = "number,url,title,state,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,mergeStateStatus,statusCheckRollup";
|
|
10
|
+
const PR_PATH = /^\/([^/]+)\/([^/]+)\/pull\/([1-9][0-9]*)$/;
|
|
11
|
+
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
12
|
+
const PR_LIST_LIMIT = 100;
|
|
13
|
+
const PR_SEARCH_CAP = 1_000;
|
|
14
|
+
const READ_ATTEMPTS = 3;
|
|
15
|
+
const SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
16
|
+
const CHECK_BUCKETS = new Map([
|
|
17
|
+
["ACTION_REQUIRED", "failing"],
|
|
18
|
+
["CANCELLED", "failing"],
|
|
19
|
+
["ERROR", "failing"],
|
|
20
|
+
["FAILURE", "failing"],
|
|
21
|
+
["STALE", "failing"],
|
|
22
|
+
["STARTUP_FAILURE", "failing"],
|
|
23
|
+
["TIMED_OUT", "failing"],
|
|
24
|
+
["NEUTRAL", "passing"],
|
|
25
|
+
["SKIPPED", "passing"],
|
|
26
|
+
["SUCCESS", "passing"],
|
|
27
|
+
["COMPLETED", "pending"],
|
|
28
|
+
["EXPECTED", "pending"],
|
|
29
|
+
["IN_PROGRESS", "pending"],
|
|
30
|
+
["PENDING", "pending"],
|
|
31
|
+
["QUEUED", "pending"],
|
|
32
|
+
["REQUESTED", "pending"],
|
|
33
|
+
["WAITING", "pending"],
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const FEEDBACK_QUERY = `
|
|
37
|
+
query Feedback($owner:String!,$repo:String!,$number:Int!,$commentsCursor:String,$reviewsCursor:String,$threadsCursor:String){
|
|
38
|
+
repository(owner:$owner,name:$repo){pullRequest(number:$number){
|
|
39
|
+
comments(first:100,after:$commentsCursor){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
|
|
40
|
+
reviews(first:100,after:$reviewsCursor){pageInfo{hasNextPage endCursor} nodes{id url state body submittedAt author{login}}}
|
|
41
|
+
reviewThreads(first:100,after:$threadsCursor){pageInfo{hasNextPage endCursor} nodes{
|
|
42
|
+
id isResolved isOutdated path line diffSide startLine startDiffSide originalLine originalStartLine
|
|
43
|
+
comments(first:100){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
|
|
44
|
+
}}
|
|
45
|
+
}}
|
|
46
|
+
}`;
|
|
47
|
+
|
|
48
|
+
const THREAD_REPLIES_QUERY = `
|
|
49
|
+
query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on PullRequestReviewThread{
|
|
50
|
+
comments(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
|
|
51
|
+
}}}`;
|
|
52
|
+
|
|
53
|
+
const THREAD_STATES_QUERY = `
|
|
54
|
+
query ThreadStates($owner:String!,$repo:String!,$number:Int!,$cursor:String){
|
|
55
|
+
repository(owner:$owner,name:$repo){pullRequest(number:$number){
|
|
56
|
+
reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{id isResolved}}
|
|
57
|
+
}}
|
|
58
|
+
}`;
|
|
59
|
+
|
|
60
|
+
const RESOLVE_THREAD_MUTATION = `
|
|
61
|
+
mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
|
|
62
|
+
|
|
63
|
+
class FeedbackError extends Error {}
|
|
64
|
+
class UsageError extends Error {}
|
|
65
|
+
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function text(value, label) {
|
|
71
|
+
if (typeof value !== "string") throw new FeedbackError(`${label} must be a string`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requiredText(value, label) {
|
|
76
|
+
const result = text(value, label);
|
|
77
|
+
if (!result) throw new FeedbackError(`${label} is missing`);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bool(value, label) {
|
|
82
|
+
if (typeof value !== "boolean") throw new FeedbackError(`${label} must be a boolean`);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function oid(value, label) {
|
|
87
|
+
const result = requiredText(value, label);
|
|
88
|
+
if (!OID.test(result)) throw new FeedbackError(`${label} must be a full OID`);
|
|
89
|
+
return result.toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function list(value, label) {
|
|
93
|
+
if (!Array.isArray(value)) throw new FeedbackError(`${label} must be an array`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function run(program, args, input, spawn = spawnSync) {
|
|
98
|
+
const result = spawn(program, args, {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
input,
|
|
101
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
102
|
+
timeout: SUBPROCESS_TIMEOUT_MS,
|
|
103
|
+
});
|
|
104
|
+
if (result.error?.code === "ETIMEDOUT") {
|
|
105
|
+
throw new FeedbackError(`${program} timed out after ${SUBPROCESS_TIMEOUT_MS}ms`);
|
|
106
|
+
}
|
|
107
|
+
if (result.error) throw new FeedbackError(`${program} failed: ${result.error.message}`);
|
|
108
|
+
if (result.status !== 0) {
|
|
109
|
+
const detail = (result.stderr || result.stdout || "").trim();
|
|
110
|
+
throw new FeedbackError(`${program} ${args.slice(0, 3).join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
return result.stdout;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function jsonValueCommand(program, args, input) {
|
|
116
|
+
const output = run(program, args, input);
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(output);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new FeedbackError(`invalid JSON from ${program}: ${error.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function jsonCommand(program, args, input) {
|
|
125
|
+
const value = jsonValueCommand(program, args, input);
|
|
126
|
+
if (!isRecord(value)) throw new FeedbackError(`invalid JSON from ${program}: root is not an object`);
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function retryableReadFailure(error) {
|
|
131
|
+
return /\b5(?:\d\d|xx)\b|tls|ssl|x509|certificate|handshake/i.test(error instanceof Error ? error.message : String(error));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sleep(milliseconds) {
|
|
135
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function retryRead(read, report = console.error) {
|
|
139
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
140
|
+
try {
|
|
141
|
+
return read();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (attempt === READ_ATTEMPTS - 1 || !retryableReadFailure(error)) throw error;
|
|
144
|
+
report(`retrying read-only GitHub request (${attempt + 1}/${READ_ATTEMPTS - 1})`);
|
|
145
|
+
sleep(250 * (attempt + 1));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readJsonCommand(program, args, input) {
|
|
151
|
+
return retryRead(() => jsonCommand(program, args, input));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readJsonValueCommand(program, args, input) {
|
|
155
|
+
return retryRead(() => jsonValueCommand(program, args, input));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function git(...args) {
|
|
159
|
+
return run("git", args).trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cleanHead() {
|
|
163
|
+
git("rev-parse", "--show-toplevel");
|
|
164
|
+
const states = [
|
|
165
|
+
["merge", "MERGE_HEAD"],
|
|
166
|
+
["rebase", "rebase-merge"],
|
|
167
|
+
["rebase", "rebase-apply"],
|
|
168
|
+
["cherry-pick", "CHERRY_PICK_HEAD"],
|
|
169
|
+
["revert", "REVERT_HEAD"],
|
|
170
|
+
["sequenced cherry-pick or revert", "sequencer"],
|
|
171
|
+
];
|
|
172
|
+
for (const [operation, state] of states) {
|
|
173
|
+
const path = requiredText(git("rev-parse", "--git-path", state), `${operation} state path`);
|
|
174
|
+
if (lstatSync(path, { throwIfNoEntry: false })) throw new FeedbackError(`${operation} is in progress`);
|
|
175
|
+
}
|
|
176
|
+
if (git("status", "--porcelain", "--untracked-files=all")) throw new FeedbackError("index and worktree must be clean");
|
|
177
|
+
return oid(git("rev-parse", "--verify", "HEAD^{commit}"), "local HEAD");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parsePrUrl(value, label = "PR URL") {
|
|
181
|
+
const url = requiredText(value, label);
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = new URL(url);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new FeedbackError(`unsupported ${label}: ${url}`);
|
|
187
|
+
}
|
|
188
|
+
const match = PR_PATH.exec(parsed.pathname);
|
|
189
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash || !match || !parsed.hostname) {
|
|
190
|
+
throw new FeedbackError(`unsupported ${label}: ${url}`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
url: parsed.toString(),
|
|
194
|
+
hostname: parsed.hostname.toLowerCase(),
|
|
195
|
+
owner: match[1],
|
|
196
|
+
repo: match[2],
|
|
197
|
+
number: Number(match[3]),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validatePrRef(pr) {
|
|
202
|
+
if (pr === undefined || /^[1-9][0-9]*$/.test(pr)) return;
|
|
203
|
+
try {
|
|
204
|
+
parsePrUrl(pr, "--pr");
|
|
205
|
+
} catch {
|
|
206
|
+
throw new UsageError("--pr must be a positive PR number or https://HOST/OWNER/REPO/pull/NUMBER");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function prTarget(metadata, label) {
|
|
211
|
+
if (!isRecord(metadata)) throw new FeedbackError(`${label} is invalid`);
|
|
212
|
+
const parsed = parsePrUrl(metadata.url, `${label} URL`);
|
|
213
|
+
if (!Number.isInteger(metadata.number) || metadata.number !== parsed.number) {
|
|
214
|
+
throw new FeedbackError(`${label} number does not match URL`);
|
|
215
|
+
}
|
|
216
|
+
const hostname = requiredText(metadata.hostname, `${label} hostname`).toLowerCase();
|
|
217
|
+
if (hostname !== parsed.hostname) throw new FeedbackError(`${label} hostname does not match URL`);
|
|
218
|
+
if (!isRecord(metadata.headRepository)) throw new FeedbackError(`${label} head repository identity is unavailable`);
|
|
219
|
+
const headRepository = requiredText(metadata.headRepository.nameWithOwner, `${label} head repository`);
|
|
220
|
+
const headRepositoryParts = headRepository.split("/");
|
|
221
|
+
if (headRepositoryParts.length !== 2 || headRepositoryParts.some((part) => !part)) throw new FeedbackError(`${label} head repository is invalid`);
|
|
222
|
+
return {
|
|
223
|
+
number: metadata.number,
|
|
224
|
+
url: parsed.url,
|
|
225
|
+
hostname,
|
|
226
|
+
baseRefName: requiredText(metadata.baseRefName, `${label} base ref`),
|
|
227
|
+
baseRefOid: oid(metadata.baseRefOid, `${label} base OID`),
|
|
228
|
+
headRepository,
|
|
229
|
+
headRefName: requiredText(metadata.headRefName, `${label} head ref`),
|
|
230
|
+
headRefOid: oid(metadata.headRefOid, `${label} head OID`),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function requireSamePr(expected, current, includeHeadOid = true) {
|
|
235
|
+
const fields = [
|
|
236
|
+
["number", "number"],
|
|
237
|
+
["hostname", "hostname"],
|
|
238
|
+
["url", "URL"],
|
|
239
|
+
["baseRefName", "base ref"],
|
|
240
|
+
["baseRefOid", "base OID"],
|
|
241
|
+
["headRepository", "head repository"],
|
|
242
|
+
["headRefName", "head ref"],
|
|
243
|
+
];
|
|
244
|
+
if (includeHeadOid) fields.push(["headRefOid", "head OID"]);
|
|
245
|
+
for (const [field, name] of fields) {
|
|
246
|
+
if (expected[field] !== current[field]) {
|
|
247
|
+
throw new FeedbackError(`PR ${name} changed since feedback fetch: ${expected[field]} -> ${current[field]}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readOpenPr(pr) {
|
|
253
|
+
if (pr === undefined) throw new FeedbackError("explicit PR identity is required");
|
|
254
|
+
const requested = /^[1-9][0-9]*$/.test(pr) ? { number: Number(pr) } : parsePrUrl(pr, "requested PR");
|
|
255
|
+
const value = readJsonCommand("gh", ["pr", "view", pr, "--json", PR_FIELDS]);
|
|
256
|
+
if (value.state !== "OPEN") throw new FeedbackError(`PR must be OPEN, got ${JSON.stringify(value.state)}`);
|
|
257
|
+
|
|
258
|
+
const parsed = parsePrUrl(value.url);
|
|
259
|
+
const metadata = {
|
|
260
|
+
...value,
|
|
261
|
+
url: parsed.url,
|
|
262
|
+
hostname: parsed.hostname,
|
|
263
|
+
baseRepository: `${parsed.owner}/${parsed.repo}`,
|
|
264
|
+
};
|
|
265
|
+
const target = prTarget(metadata, "PR");
|
|
266
|
+
if (("url" in requested && target.url !== requested.url) || ("number" in requested && target.number !== requested.number)) {
|
|
267
|
+
throw new FeedbackError("PR metadata identity does not match requested PR");
|
|
268
|
+
}
|
|
269
|
+
metadata.baseRefOid = target.baseRefOid;
|
|
270
|
+
metadata.headRefOid = target.headRefOid;
|
|
271
|
+
requiredText(metadata.mergeStateStatus, "PR merge state");
|
|
272
|
+
list(metadata.statusCheckRollup, "PR status checks");
|
|
273
|
+
return metadata;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function requireMatchingHead(metadata, localHead) {
|
|
277
|
+
if (metadata.headRefOid !== localHead) {
|
|
278
|
+
throw new FeedbackError(`local HEAD ${localHead} does not match PR head ${metadata.headRefOid}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pushRepositoryLocator(value) {
|
|
283
|
+
const pushUrl = requiredText(value, "push URL");
|
|
284
|
+
if (pushUrl.trim() !== pushUrl || /[\u0000-\u001f\u007f]/.test(pushUrl)) throw new FeedbackError("push URL is invalid");
|
|
285
|
+
const scp = /^(?:[^@/:]+@)?([^@/:]+):([^/]+)\/([^/]+)$/i.exec(pushUrl);
|
|
286
|
+
let hostname;
|
|
287
|
+
let parts;
|
|
288
|
+
if (scp) {
|
|
289
|
+
hostname = scp[1].toLowerCase();
|
|
290
|
+
parts = scp.slice(2);
|
|
291
|
+
} else {
|
|
292
|
+
let url;
|
|
293
|
+
try {
|
|
294
|
+
url = new URL(pushUrl);
|
|
295
|
+
} catch {
|
|
296
|
+
throw new FeedbackError("push URL is invalid");
|
|
297
|
+
}
|
|
298
|
+
if (!["ssh:", "https:"].includes(url.protocol) || !url.hostname || url.port || url.search || url.hash) {
|
|
299
|
+
throw new FeedbackError("push URL is invalid");
|
|
300
|
+
}
|
|
301
|
+
hostname = url.hostname.toLowerCase();
|
|
302
|
+
parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
|
303
|
+
}
|
|
304
|
+
if (parts.length === 2) parts[1] = parts[1].replace(/\.git$/i, "");
|
|
305
|
+
if (!/^[a-z0-9.-]+$/i.test(hostname) || parts.length !== 2 || parts.some((part) => !/^[a-z0-9_.-]+$/i.test(part))) {
|
|
306
|
+
throw new FeedbackError("push URL is invalid");
|
|
307
|
+
}
|
|
308
|
+
const repository = parts.join("/");
|
|
309
|
+
return { hostname, repository, locator: `${hostname}/${repository}` };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readPushRepository(pushUrl, read = readJsonCommand) {
|
|
313
|
+
const push = pushRepositoryLocator(pushUrl);
|
|
314
|
+
const value = read("gh", ["repo", "view", push.locator, "--json", "nameWithOwner,url"]);
|
|
315
|
+
const repository = requiredText(value.nameWithOwner, "push repository");
|
|
316
|
+
let url;
|
|
317
|
+
try {
|
|
318
|
+
url = new URL(requiredText(value.url, "push repository URL"));
|
|
319
|
+
} catch {
|
|
320
|
+
throw new FeedbackError("push repository URL is invalid");
|
|
321
|
+
}
|
|
322
|
+
const hostname = url.hostname.toLowerCase();
|
|
323
|
+
const normalized = repository.toLowerCase();
|
|
324
|
+
if (
|
|
325
|
+
url.protocol !== "https:" || !hostname || url.username || url.password || url.port || url.search || url.hash ||
|
|
326
|
+
githubRemote(url.href, hostname) !== normalized || push.hostname !== hostname || push.repository.toLowerCase() !== normalized
|
|
327
|
+
) throw new FeedbackError("push URL does not match GitHub repository");
|
|
328
|
+
return { hostname, repository };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function configuredPushTarget() {
|
|
332
|
+
const branch = git("branch", "--show-current");
|
|
333
|
+
if (!branch) throw new FeedbackError("current HEAD is detached");
|
|
334
|
+
const push = git("for-each-ref", "--format=%(push:short)", `refs/heads/${branch}`);
|
|
335
|
+
if (!push) throw new FeedbackError(`local branch ${JSON.stringify(branch)} has no configured push target`);
|
|
336
|
+
const remotes = git("remote").split("\n").filter(Boolean);
|
|
337
|
+
if (new Set(remotes).size !== remotes.length) throw new FeedbackError("configured remote names are invalid");
|
|
338
|
+
const matches = remotes.filter((remote) => push.startsWith(`${remote}/`)).sort((left, right) => right.length - left.length);
|
|
339
|
+
if (!matches.length || (matches[1] && matches[1].length === matches[0].length)) {
|
|
340
|
+
throw new FeedbackError("configured push target does not name one configured remote");
|
|
341
|
+
}
|
|
342
|
+
const remote = matches[0];
|
|
343
|
+
const ref = push.slice(remote.length + 1);
|
|
344
|
+
const checkedRef = git("check-ref-format", "--branch", ref);
|
|
345
|
+
if (checkedRef !== ref) throw new FeedbackError("configured push ref is invalid");
|
|
346
|
+
const output = run("git", ["remote", "get-url", "--push", "--all", remote]).replace(/\r\n/g, "\n");
|
|
347
|
+
const urls = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
|
|
348
|
+
if (urls.length !== 1 || !urls[0]) throw new FeedbackError(`expected exactly one push URL on ${remote}, found ${urls.filter(Boolean).length}`);
|
|
349
|
+
const pushUrl = requiredText(urls[0], `push URL for ${remote}`);
|
|
350
|
+
return { branch, remote, ref, pushUrl, ...readPushRepository(pushUrl) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function candidatePrUrls(pages, hostname) {
|
|
354
|
+
if (!Array.isArray(pages) || !pages.length) throw new FeedbackError("invalid pull request search response");
|
|
355
|
+
let totalCount = null;
|
|
356
|
+
let incomplete = false;
|
|
357
|
+
const pageItems = [];
|
|
358
|
+
for (const page of pages) {
|
|
359
|
+
if (
|
|
360
|
+
!isRecord(page) || !Number.isSafeInteger(page.total_count) || page.total_count < 0 ||
|
|
361
|
+
typeof page.incomplete_results !== "boolean" || !Array.isArray(page.items)
|
|
362
|
+
) throw new FeedbackError("invalid pull request search response");
|
|
363
|
+
if (totalCount !== null && page.total_count !== totalCount) throw new FeedbackError("pull request search pages are inconsistent");
|
|
364
|
+
totalCount = page.total_count;
|
|
365
|
+
incomplete ||= page.incomplete_results;
|
|
366
|
+
pageItems.push(page.items);
|
|
367
|
+
}
|
|
368
|
+
if (incomplete) throw new FeedbackError("pull request search results are incomplete");
|
|
369
|
+
if (totalCount > PR_SEARCH_CAP) throw new FeedbackError("GitHub pull request search cap reached");
|
|
370
|
+
const expectedPages = Math.max(1, Math.ceil(totalCount / PR_LIST_LIMIT));
|
|
371
|
+
if (pageItems.length !== expectedPages || pageItems.some((items, index) =>
|
|
372
|
+
items.length !== Math.min(PR_LIST_LIMIT, Math.max(0, totalCount - index * PR_LIST_LIMIT))
|
|
373
|
+
)) throw new FeedbackError("pull request search result count is incomplete");
|
|
374
|
+
const urls = pageItems.flat().map((item) => {
|
|
375
|
+
if (!isRecord(item)) throw new FeedbackError("pull request search item is invalid");
|
|
376
|
+
const parsed = parsePrUrl(item.html_url, "candidate PR URL");
|
|
377
|
+
if (parsed.hostname !== hostname) throw new FeedbackError("candidate PR URL hostname does not match push target");
|
|
378
|
+
return parsed.url;
|
|
379
|
+
});
|
|
380
|
+
if (new Set(urls.map((url) => url.toLowerCase())).size !== urls.length) throw new FeedbackError("pull request search returned duplicate candidates");
|
|
381
|
+
return urls;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function discoverOpenPr(localHead) {
|
|
385
|
+
const push = configuredPushTarget();
|
|
386
|
+
const [owner] = push.repository.split("/", 2);
|
|
387
|
+
const response = readJsonValueCommand("gh", [
|
|
388
|
+
"api", "search/issues", "--hostname", push.hostname, "--paginate", "--slurp", "-X", "GET",
|
|
389
|
+
"-f", `q=is:pr is:open head:${owner}:${push.ref}`,
|
|
390
|
+
"-f", `per_page=${PR_LIST_LIMIT}`,
|
|
391
|
+
]);
|
|
392
|
+
const candidates = candidatePrUrls(response, push.hostname).map((url) => {
|
|
393
|
+
const candidate = readOpenPr(url);
|
|
394
|
+
if (candidate.url !== url) throw new FeedbackError("candidate PR metadata URL does not match search result");
|
|
395
|
+
return candidate;
|
|
396
|
+
});
|
|
397
|
+
const matching = candidates.filter((candidate) =>
|
|
398
|
+
candidate.hostname === push.hostname &&
|
|
399
|
+
candidate.headRepository.nameWithOwner.toLowerCase() === push.repository.toLowerCase() &&
|
|
400
|
+
candidate.headRefName === push.ref && candidate.headRefOid === localHead
|
|
401
|
+
);
|
|
402
|
+
if (matching.length !== 1) {
|
|
403
|
+
throw new FeedbackError(`expected exactly one open PR matching configured push target and local HEAD, found ${matching.length}`);
|
|
404
|
+
}
|
|
405
|
+
return { metadata: matching[0], push };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function graphqlArgs(hostname, variables) {
|
|
409
|
+
const args = ["api", "graphql", "--hostname", requiredText(hostname, "GitHub hostname"), "-F", "query=@-"];
|
|
410
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
411
|
+
if (value !== null && value !== undefined) args.push("-F", `${name}=${value}`);
|
|
412
|
+
}
|
|
413
|
+
return args;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function graphqlResponse(query, variables, hostname) {
|
|
417
|
+
const response = jsonCommand("gh", graphqlArgs(hostname, variables), query);
|
|
418
|
+
if (response.errors && (!Array.isArray(response.errors) || response.errors.length)) {
|
|
419
|
+
throw new FeedbackError(`GitHub GraphQL errors: ${JSON.stringify(response.errors)}`);
|
|
420
|
+
}
|
|
421
|
+
if (!isRecord(response.data)) throw new FeedbackError("GitHub GraphQL response has no data");
|
|
422
|
+
return response.data;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function readGraphql(query, variables, hostname) {
|
|
426
|
+
return retryRead(() => graphqlResponse(query, variables, hostname));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function writeGraphql(query, variables, hostname) {
|
|
430
|
+
return graphqlResponse(query, variables, hostname);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function pullRequest(data) {
|
|
434
|
+
if (!isRecord(data.repository) || !isRecord(data.repository.pullRequest)) {
|
|
435
|
+
throw new FeedbackError("pull request disappeared");
|
|
436
|
+
}
|
|
437
|
+
return data.repository.pullRequest;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function connection(value, label) {
|
|
441
|
+
if (!isRecord(value) || !isRecord(value.pageInfo)) throw new FeedbackError(`invalid ${label} connection`);
|
|
442
|
+
const nodes = list(value.nodes, `${label} nodes`);
|
|
443
|
+
const hasNextPage = bool(value.pageInfo.hasNextPage, `${label} hasNextPage`);
|
|
444
|
+
const endCursor = value.pageInfo.endCursor;
|
|
445
|
+
if (hasNextPage && (typeof endCursor !== "string" || !endCursor)) {
|
|
446
|
+
throw new FeedbackError(`missing pagination cursor for ${label}`);
|
|
447
|
+
}
|
|
448
|
+
return { nodes, hasNextPage, endCursor };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function collectFeedback(metadata, request = readGraphql) {
|
|
452
|
+
const [owner, repo] = metadata.baseRepository.split("/", 2);
|
|
453
|
+
const targets = [
|
|
454
|
+
{ key: "conversationComments", field: "comments", cursor: "commentsCursor", label: "conversation comments" },
|
|
455
|
+
{ key: "reviews", field: "reviews", cursor: "reviewsCursor", label: "reviews" },
|
|
456
|
+
{ key: "reviewThreads", field: "reviewThreads", cursor: "threadsCursor", label: "review threads" },
|
|
457
|
+
];
|
|
458
|
+
const results = Object.fromEntries(targets.map(({ key }) => [key, []]));
|
|
459
|
+
const cursors = Object.fromEntries(targets.map(({ key }) => [key, null]));
|
|
460
|
+
const pending = new Set(targets.map(({ key }) => key));
|
|
461
|
+
|
|
462
|
+
while (pending.size) {
|
|
463
|
+
const data = request(FEEDBACK_QUERY, {
|
|
464
|
+
owner,
|
|
465
|
+
repo,
|
|
466
|
+
number: metadata.number,
|
|
467
|
+
commentsCursor: cursors.conversationComments,
|
|
468
|
+
reviewsCursor: cursors.reviews,
|
|
469
|
+
threadsCursor: cursors.reviewThreads,
|
|
470
|
+
}, metadata.hostname);
|
|
471
|
+
const pr = pullRequest(data);
|
|
472
|
+
for (const target of targets) {
|
|
473
|
+
if (!pending.has(target.key)) continue;
|
|
474
|
+
const page = connection(pr[target.field], target.label);
|
|
475
|
+
results[target.key].push(...page.nodes);
|
|
476
|
+
if (page.hasNextPage) {
|
|
477
|
+
cursors[target.key] = page.endCursor;
|
|
478
|
+
} else {
|
|
479
|
+
pending.delete(target.key);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
results.reviewThreads = results.reviewThreads.map((thread) => collectReplies(thread, request, metadata.hostname));
|
|
485
|
+
return {
|
|
486
|
+
pullRequest: metadata,
|
|
487
|
+
conversationComments: results.conversationComments,
|
|
488
|
+
reviews: results.reviews,
|
|
489
|
+
reviewThreads: results.reviewThreads,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function collectReplies(thread, request, hostname) {
|
|
494
|
+
if (!isRecord(thread)) throw new FeedbackError("invalid review thread");
|
|
495
|
+
const threadId = requiredText(thread.id, "review thread ID");
|
|
496
|
+
let page = connection(thread.comments, `replies for ${threadId}`);
|
|
497
|
+
const comments = [...page.nodes];
|
|
498
|
+
while (page.hasNextPage) {
|
|
499
|
+
const data = request(THREAD_REPLIES_QUERY, { threadId, cursor: page.endCursor }, hostname);
|
|
500
|
+
if (!isRecord(data.node)) throw new FeedbackError(`review thread disappeared: ${threadId}`);
|
|
501
|
+
page = connection(data.node.comments, `replies for ${threadId}`);
|
|
502
|
+
comments.push(...page.nodes);
|
|
503
|
+
}
|
|
504
|
+
return { ...thread, comments };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function collectThreadStates(metadata, request = readGraphql) {
|
|
508
|
+
const [owner, repo] = metadata.baseRepository.split("/", 2);
|
|
509
|
+
const states = new Map();
|
|
510
|
+
let cursor = null;
|
|
511
|
+
for (;;) {
|
|
512
|
+
const data = request(THREAD_STATES_QUERY, { owner, repo, number: metadata.number, cursor }, metadata.hostname);
|
|
513
|
+
const page = connection(pullRequest(data).reviewThreads, "review threads");
|
|
514
|
+
for (const thread of page.nodes) {
|
|
515
|
+
if (!isRecord(thread)) throw new FeedbackError("invalid review thread state");
|
|
516
|
+
states.set(requiredText(thread.id, "review thread ID"), bool(thread.isResolved, "review thread isResolved"));
|
|
517
|
+
}
|
|
518
|
+
if (!page.hasNextPage) return states;
|
|
519
|
+
cursor = page.endCursor;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function resolveThread(threadId, hostname, mutate = writeGraphql) {
|
|
524
|
+
const data = mutate(RESOLVE_THREAD_MUTATION, { threadId }, hostname);
|
|
525
|
+
const result = data.resolveReviewThread;
|
|
526
|
+
if (!isRecord(result) || !isRecord(result.thread)) throw new FeedbackError(`failed to resolve ${threadId}`);
|
|
527
|
+
if (result.thread.id !== threadId || result.thread.isResolved !== true) {
|
|
528
|
+
throw new FeedbackError(`GitHub did not resolve ${threadId}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function parsedSnapshot(raw, path) {
|
|
533
|
+
let value;
|
|
534
|
+
try {
|
|
535
|
+
value = JSON.parse(raw);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
throw new FeedbackError(`invalid feedback JSON ${path}: ${error.message}`);
|
|
538
|
+
}
|
|
539
|
+
if (!isRecord(value) || !isRecord(value.pullRequest) || !Array.isArray(value.conversationComments) || !Array.isArray(value.reviews) || !Array.isArray(value.reviewThreads)) {
|
|
540
|
+
throw new FeedbackError(`invalid feedback JSON ${path}`);
|
|
541
|
+
}
|
|
542
|
+
parsePrUrl(value.pullRequest.url, "snapshot PR URL");
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function snapshot(path) {
|
|
547
|
+
try {
|
|
548
|
+
return parsedSnapshot(readFileSync(path, "utf8"), path);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (error instanceof FeedbackError) throw error;
|
|
551
|
+
throw new FeedbackError(`invalid feedback JSON ${path}: ${error.message}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function previousSnapshot(path) {
|
|
556
|
+
try {
|
|
557
|
+
const raw = readFileSync(path, "utf8");
|
|
558
|
+
if (!raw.trim()) return null;
|
|
559
|
+
return parsedSnapshot(raw, path);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error?.code === "ENOENT") return null;
|
|
562
|
+
if (error instanceof FeedbackError) throw error;
|
|
563
|
+
throw new FeedbackError(`invalid feedback JSON ${path}: ${error.message}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function nodeIds(nodes, label) {
|
|
568
|
+
return nodes.map((node, index) => {
|
|
569
|
+
if (!isRecord(node)) throw new FeedbackError(`invalid ${label} ${index + 1}`);
|
|
570
|
+
return requiredText(node.id, `${label} ID`);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function threadBuckets(threads) {
|
|
575
|
+
const resolved = [];
|
|
576
|
+
const outdated = [];
|
|
577
|
+
const openCurrent = [];
|
|
578
|
+
for (const thread of threads) {
|
|
579
|
+
if (!isRecord(thread)) throw new FeedbackError("invalid review thread");
|
|
580
|
+
if (bool(thread.isResolved, "review thread isResolved")) resolved.push(thread);
|
|
581
|
+
else if (bool(thread.isOutdated, "review thread isOutdated")) outdated.push(thread);
|
|
582
|
+
else openCurrent.push(thread);
|
|
583
|
+
}
|
|
584
|
+
return { resolved, outdated, openCurrent };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function login(author) {
|
|
588
|
+
if (author === null) return "-";
|
|
589
|
+
if (!isRecord(author)) throw new FeedbackError("comment author is invalid");
|
|
590
|
+
return requiredText(author.login, "comment author login");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function location(thread) {
|
|
594
|
+
const path = thread.path === null ? "-" : JSON.stringify(text(thread.path, "thread path")).slice(1, -1)
|
|
595
|
+
.replace(/[\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
596
|
+
const line = Number.isInteger(thread.line)
|
|
597
|
+
? thread.line
|
|
598
|
+
: Number.isInteger(thread.originalLine)
|
|
599
|
+
? thread.originalLine
|
|
600
|
+
: "-";
|
|
601
|
+
return `${path}:${line}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function checkBucket(check) {
|
|
605
|
+
if (!isRecord(check)) throw new FeedbackError("invalid PR status check");
|
|
606
|
+
const states = ["conclusion", "state", "status"].flatMap((field) => {
|
|
607
|
+
const value = check[field];
|
|
608
|
+
if (value === null || value === undefined || value === "") return [];
|
|
609
|
+
if (typeof value !== "string" || !CHECK_BUCKETS.has(value)) throw new FeedbackError("invalid PR status check");
|
|
610
|
+
return [value];
|
|
611
|
+
});
|
|
612
|
+
if (!states.length) throw new FeedbackError("invalid PR status check");
|
|
613
|
+
|
|
614
|
+
// COMPLETED is a lifecycle state; the conclusion supplies its outcome.
|
|
615
|
+
const outcomes = states.filter((state) => state !== "COMPLETED").map((state) => CHECK_BUCKETS.get(state));
|
|
616
|
+
if (new Set(outcomes).size > 1 || (states.includes("COMPLETED") && outcomes.includes("pending"))) {
|
|
617
|
+
throw new FeedbackError("invalid PR status check");
|
|
618
|
+
}
|
|
619
|
+
return outcomes[0] ?? "pending";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function checkSummary(metadata) {
|
|
623
|
+
const summary = { passing: 0, pending: 0, failing: 0 };
|
|
624
|
+
for (const check of list(metadata.statusCheckRollup, "PR status checks")) summary[checkBucket(check)] += 1;
|
|
625
|
+
return summary;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function printPrStatus(metadata, log = console.log) {
|
|
629
|
+
const checks = checkSummary(metadata);
|
|
630
|
+
log(`merge_state=${requiredText(metadata.mergeStateStatus, "PR merge state")} checks_total=${checks.passing + checks.pending + checks.failing} passing=${checks.passing} pending=${checks.pending} failing=${checks.failing}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function printComments(label, comments, log) {
|
|
634
|
+
log(`${label}=comment\tauthor\tbody`);
|
|
635
|
+
for (const comment of comments) {
|
|
636
|
+
if (!isRecord(comment)) throw new FeedbackError("invalid conversation comment");
|
|
637
|
+
log([requiredText(comment.id, "conversation comment ID"), login(comment.author), JSON.stringify(text(comment.body, "conversation comment body"))].join("\t"));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function printReviews(label, reviews, log) {
|
|
642
|
+
log(`${label}=review\tstate\tauthor\tbody`);
|
|
643
|
+
for (const review of reviews) {
|
|
644
|
+
if (!isRecord(review)) throw new FeedbackError("invalid review");
|
|
645
|
+
log([requiredText(review.id, "review ID"), requiredText(review.state, "review state"), login(review.author), JSON.stringify(text(review.body, "review body"))].join("\t"));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function printThreads(label, threads, log) {
|
|
650
|
+
log(`${label}=thread\tstate\tlocation\tcomment\tauthor\tbody`);
|
|
651
|
+
for (const thread of threads) {
|
|
652
|
+
const threadId = requiredText(thread.id, "review thread ID");
|
|
653
|
+
const prefix = [threadId, threadState(thread), location(thread)];
|
|
654
|
+
const comments = list(thread.comments, `replies for ${threadId}`);
|
|
655
|
+
if (!comments.length) log([...prefix, "-", "-", "-"].join("\t"));
|
|
656
|
+
for (const comment of comments) {
|
|
657
|
+
if (!isRecord(comment)) throw new FeedbackError(`invalid comment in ${threadId}`);
|
|
658
|
+
log([...prefix, requiredText(comment.id, "comment ID"), login(comment.author), JSON.stringify(text(comment.body, "comment body"))].join("\t"));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function threadState(thread) {
|
|
664
|
+
if (bool(thread.isResolved, "review thread isResolved")) return "resolved";
|
|
665
|
+
return bool(thread.isOutdated, "review thread isOutdated") ? "outdated" : "current";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function printFeedbackDelta(data, previous, log) {
|
|
669
|
+
const oldComments = new Map(previous.conversationComments.map((comment) => [requiredText(comment.id, "previous conversation comment ID"), comment]));
|
|
670
|
+
const oldReviews = new Map(previous.reviews.map((review) => [requiredText(review.id, "previous review ID"), review]));
|
|
671
|
+
const oldThreads = new Map(previous.reviewThreads.map((thread) => [requiredText(thread.id, "previous review thread ID"), thread]));
|
|
672
|
+
const changed = (node, old, fields) => old && fields.some((field) => node[field] !== old[field]);
|
|
673
|
+
const newComments = data.conversationComments.filter((comment) => !oldComments.has(comment.id));
|
|
674
|
+
const updatedComments = data.conversationComments.filter((comment) => changed(comment, oldComments.get(comment.id), ["body"]));
|
|
675
|
+
const newReviews = data.reviews.filter((review) => !oldReviews.has(review.id));
|
|
676
|
+
const updatedReviews = data.reviews.filter((review) => changed(review, oldReviews.get(review.id), ["state", "body"]));
|
|
677
|
+
const newThreads = data.reviewThreads.filter((thread) => !oldThreads.has(thread.id));
|
|
678
|
+
const newReplies = [];
|
|
679
|
+
const updatedReplies = [];
|
|
680
|
+
const stateChanges = [];
|
|
681
|
+
|
|
682
|
+
for (const thread of data.reviewThreads) {
|
|
683
|
+
const oldThread = oldThreads.get(thread.id);
|
|
684
|
+
if (!oldThread) continue;
|
|
685
|
+
const oldReplies = new Map(list(oldThread.comments, `previous replies for ${thread.id}`).map((reply) => [requiredText(reply.id, "previous reply ID"), reply]));
|
|
686
|
+
const replies = list(thread.comments, `replies for ${thread.id}`);
|
|
687
|
+
const added = replies.filter((reply) => !oldReplies.has(reply.id));
|
|
688
|
+
const updated = replies.filter((reply) => changed(reply, oldReplies.get(reply.id), ["body"]));
|
|
689
|
+
if (added.length) newReplies.push({ ...thread, comments: added });
|
|
690
|
+
if (updated.length) updatedReplies.push({ ...thread, comments: updated });
|
|
691
|
+
const before = threadState(oldThread);
|
|
692
|
+
const after = threadState(thread);
|
|
693
|
+
if (before !== after) stateChanges.push([thread.id, before, after]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (newComments.length) printComments("new_conversation_comments", newComments, log);
|
|
697
|
+
if (updatedComments.length) printComments("updated_conversation_comments", updatedComments, log);
|
|
698
|
+
if (newReviews.length) printReviews("new_reviews", newReviews, log);
|
|
699
|
+
if (updatedReviews.length) printReviews("updated_reviews", updatedReviews, log);
|
|
700
|
+
if (newThreads.length) printThreads("new_threads", newThreads, log);
|
|
701
|
+
if (newReplies.length) printThreads("new_replies", newReplies, log);
|
|
702
|
+
if (updatedReplies.length) printThreads("updated_replies", updatedReplies, log);
|
|
703
|
+
if (stateChanges.length) {
|
|
704
|
+
log("thread_state_changes=thread\tbefore\tafter");
|
|
705
|
+
for (const change of stateChanges) log(change.join("\t"));
|
|
706
|
+
}
|
|
707
|
+
if (![newComments, updatedComments, newReviews, updatedReviews, newThreads, newReplies, updatedReplies, stateChanges].some((items) => items.length)) log("feedback_delta=none");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function printFetchSummary(data, previous, out, log = console.log) {
|
|
711
|
+
const { resolved, outdated, openCurrent } = threadBuckets(data.reviewThreads);
|
|
712
|
+
log(`snapshot=${out}`);
|
|
713
|
+
log(`counts comments=${data.conversationComments.length} reviews=${data.reviews.length} threads=${data.reviewThreads.length} open_current=${openCurrent.length} outdated=${outdated.length}`);
|
|
714
|
+
printPrStatus(data.pullRequest, log);
|
|
715
|
+
if (previous) {
|
|
716
|
+
printFeedbackDelta(data, previous, log);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
printComments("conversation_comments", data.conversationComments, log);
|
|
720
|
+
printReviews("reviews", data.reviews, log);
|
|
721
|
+
printThreads("open_current_threads", openCurrent, log);
|
|
722
|
+
printThreads("outdated_threads", outdated, log);
|
|
723
|
+
log(`resolved_thread_ids=${nodeIds(resolved, "resolved review thread").join(",") || "-"}`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function writeSnapshot(path, data) {
|
|
727
|
+
const destination = resolve(path);
|
|
728
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
729
|
+
writeFileSync(destination, `${JSON.stringify(data)}\n`);
|
|
730
|
+
return destination;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function fetchFeedback(options) {
|
|
734
|
+
const out = options.json ? null : resolve(options.out);
|
|
735
|
+
const previous = out ? previousSnapshot(out) : null;
|
|
736
|
+
const localHead = cleanHead();
|
|
737
|
+
const initial = options.pr === undefined ? discoverOpenPr(localHead).metadata : readOpenPr(options.pr);
|
|
738
|
+
if (previous && parsePrUrl(previous.pullRequest.url, "snapshot PR URL").url !== initial.url) {
|
|
739
|
+
throw new FeedbackError(`existing feedback JSON belongs to ${previous.pullRequest.url}, not ${initial.url}`);
|
|
740
|
+
}
|
|
741
|
+
requireMatchingHead(initial, localHead);
|
|
742
|
+
const data = collectFeedback(initial);
|
|
743
|
+
const current = readOpenPr(initial.url);
|
|
744
|
+
if (current.headRefOid !== initial.headRefOid) {
|
|
745
|
+
throw new FeedbackError(`PR head changed during fetch: ${initial.headRefOid} -> ${current.headRefOid}`);
|
|
746
|
+
}
|
|
747
|
+
requireMatchingHead(current, localHead);
|
|
748
|
+
const complete = {
|
|
749
|
+
...data,
|
|
750
|
+
pullRequest: current,
|
|
751
|
+
openCurrentThreads: threadBuckets(data.reviewThreads).openCurrent,
|
|
752
|
+
};
|
|
753
|
+
if (options.json) {
|
|
754
|
+
process.stdout.write(`${JSON.stringify(complete)}\n`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const saved = writeSnapshot(out, complete);
|
|
758
|
+
printFetchSummary(complete, previous, saved);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function currentExpectedHead(pr, expectedHead) {
|
|
762
|
+
const localHead = cleanHead();
|
|
763
|
+
if (localHead !== expectedHead) {
|
|
764
|
+
throw new FeedbackError(`local HEAD ${localHead} does not match expected head ${expectedHead}`);
|
|
765
|
+
}
|
|
766
|
+
const metadata = pr === undefined ? discoverOpenPr(localHead).metadata : readOpenPr(pr);
|
|
767
|
+
if (metadata.headRefOid !== expectedHead) {
|
|
768
|
+
throw new FeedbackError(`PR head ${metadata.headRefOid} does not match expected head ${expectedHead}`);
|
|
769
|
+
}
|
|
770
|
+
return metadata;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function resolveCurrentThread(threadId, expected, expectedHead, operations = {}) {
|
|
774
|
+
const localHead = (operations.readHead ?? cleanHead)();
|
|
775
|
+
if (localHead !== expectedHead) {
|
|
776
|
+
throw new FeedbackError(`local HEAD ${localHead} does not match expected head ${expectedHead}`);
|
|
777
|
+
}
|
|
778
|
+
const discovered = (operations.discover ?? discoverOpenPr)(localHead);
|
|
779
|
+
const current = prTarget(discovered.metadata, "current PR");
|
|
780
|
+
requireSamePr(expected, current);
|
|
781
|
+
requirePushTargetMatchesPr(discovered.push, current);
|
|
782
|
+
resolveThread(threadId, current.hostname, operations.mutate ?? writeGraphql);
|
|
783
|
+
return discovered.metadata;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function resolveFeedback(options) {
|
|
787
|
+
let metadata = currentExpectedHead(options.pr, options.expectedHead);
|
|
788
|
+
const expected = prTarget(metadata, "initial PR");
|
|
789
|
+
const states = collectThreadStates(metadata);
|
|
790
|
+
const requested = options.threads;
|
|
791
|
+
const missing = requested.filter((threadId) => !states.has(threadId));
|
|
792
|
+
if (missing.length) throw new FeedbackError(`review thread IDs not found: ${missing.join(",")}`);
|
|
793
|
+
|
|
794
|
+
for (const threadId of requested) {
|
|
795
|
+
if (!states.get(threadId)) {
|
|
796
|
+
metadata = resolveCurrentThread(threadId, expected, options.expectedHead);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
metadata = currentExpectedHead(metadata.url, options.expectedHead);
|
|
801
|
+
const verified = collectThreadStates(metadata);
|
|
802
|
+
const unresolved = requested.filter((threadId) => verified.get(threadId) !== true);
|
|
803
|
+
if (unresolved.length) throw new FeedbackError(`resolution verification failed: ${unresolved.join(",")}`);
|
|
804
|
+
console.log(`resolved_thread_ids=${requested.join(",")}`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function githubRemote(url, hostname) {
|
|
808
|
+
const expected = requiredText(hostname, "PR hostname").toLowerCase();
|
|
809
|
+
const scp = /^(?:[^@/:]+@)?([^/:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url);
|
|
810
|
+
if (scp) return scp[1].toLowerCase() === expected ? `${scp[2]}/${scp[3]}`.toLowerCase() : null;
|
|
811
|
+
try {
|
|
812
|
+
const parsed = new URL(url);
|
|
813
|
+
if (!["ssh:", "https:"].includes(parsed.protocol) || parsed.hostname.toLowerCase() !== expected || parsed.search || parsed.hash) return null;
|
|
814
|
+
const parts = parsed.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
|
815
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
816
|
+
return `${parts[0]}/${parts[1].replace(/\.git$/i, "")}`.toLowerCase();
|
|
817
|
+
} catch {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function requirePushTargetMatchesPr(push, target) {
|
|
823
|
+
if (
|
|
824
|
+
push.hostname !== target.hostname || push.repository.toLowerCase() !== target.headRepository.toLowerCase() ||
|
|
825
|
+
push.ref !== target.headRefName
|
|
826
|
+
) throw new FeedbackError("configured push target does not match PR head");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function requireSamePushTarget(expected, current) {
|
|
830
|
+
for (const field of ["branch", "remote", "ref", "pushUrl", "hostname", "repository"]) {
|
|
831
|
+
if (expected[field] !== current[field]) throw new FeedbackError("configured push target changed before push");
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function pushArgs(remote, refspec) {
|
|
836
|
+
return ["push", "--recurse-submodules=no", remote, refspec];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function verifyTarget(options) {
|
|
840
|
+
const localHead = cleanHead();
|
|
841
|
+
const discovered = options.pr === undefined ? discoverOpenPr(localHead) : null;
|
|
842
|
+
const metadata = discovered?.metadata ?? readOpenPr(options.pr);
|
|
843
|
+
requireMatchingHead(metadata, localHead);
|
|
844
|
+
const target = prTarget(metadata, "PR");
|
|
845
|
+
const push = discovered?.push ?? configuredPushTarget();
|
|
846
|
+
requirePushTargetMatchesPr(push, target);
|
|
847
|
+
console.log(`pr=${metadata.url}`);
|
|
848
|
+
console.log(`remote=${push.remote} repository=${target.headRepository}`);
|
|
849
|
+
console.log(`push_target=git ${pushArgs(push.remote, `${localHead}:refs/heads/${push.ref}`).join(" ")}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function checkPr(options) {
|
|
853
|
+
const metadata = currentExpectedHead(options.pr, options.expectedHead);
|
|
854
|
+
console.log(`pr=${metadata.url}`);
|
|
855
|
+
printPrStatus(metadata);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function waitForHead(expected, expectedHead, readPr = readOpenPr, pause = sleep) {
|
|
859
|
+
let metadata;
|
|
860
|
+
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
|
|
861
|
+
metadata = readPr(expected.url);
|
|
862
|
+
requireSamePr(expected, prTarget(metadata, "current PR"), false);
|
|
863
|
+
if (metadata.headRefOid === expectedHead) return metadata;
|
|
864
|
+
if (attempt < READ_ATTEMPTS - 1) pause(250 * (attempt + 1));
|
|
865
|
+
}
|
|
866
|
+
return metadata;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function publishHead(initial, localHead, operations) {
|
|
870
|
+
const { readPr, readTarget, readHead, push, pause = sleep } = operations;
|
|
871
|
+
const expected = prTarget(initial, "snapshot PR");
|
|
872
|
+
const initialHead = expected.headRefOid;
|
|
873
|
+
const current = readPr(expected.url);
|
|
874
|
+
const observed = prTarget(current, "current PR");
|
|
875
|
+
requireSamePr(expected, observed, false);
|
|
876
|
+
if (observed.headRefOid === localHead) return { metadata: current, status: "already-current" };
|
|
877
|
+
if (observed.headRefOid !== initialHead) {
|
|
878
|
+
throw new FeedbackError(`PR head changed before push: ${initialHead} -> ${observed.headRefOid}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const savedTarget = readTarget();
|
|
882
|
+
requirePushTargetMatchesPr(savedTarget, observed);
|
|
883
|
+
const guardedTarget = readTarget();
|
|
884
|
+
requireSamePushTarget(savedTarget, guardedTarget);
|
|
885
|
+
const guardedPr = prTarget(readPr(expected.url), "current PR");
|
|
886
|
+
requireSamePr(expected, guardedPr);
|
|
887
|
+
requirePushTargetMatchesPr(guardedTarget, guardedPr);
|
|
888
|
+
if (readHead() !== localHead) throw new FeedbackError("local HEAD changed before push");
|
|
889
|
+
push(guardedTarget.remote, `${localHead}:refs/heads/${guardedTarget.ref}`);
|
|
890
|
+
|
|
891
|
+
const pushed = waitForHead(expected, localHead, readPr, pause);
|
|
892
|
+
if (pushed.headRefOid !== localHead) {
|
|
893
|
+
throw new FeedbackError(`push did not update PR head to local HEAD: ${pushed.headRefOid} != ${localHead}`);
|
|
894
|
+
}
|
|
895
|
+
return { metadata: pushed, remote: guardedTarget.remote, ref: guardedTarget.ref, status: "pushed" };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function printSnapshotPrUrl(options) {
|
|
899
|
+
console.log(prTarget(snapshot(resolve(options.snapshot)).pullRequest, "snapshot PR").url);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function pushHead(options) {
|
|
903
|
+
const initial = snapshot(resolve(options.snapshot)).pullRequest;
|
|
904
|
+
const localHead = cleanHead();
|
|
905
|
+
const result = publishHead(initial, localHead, {
|
|
906
|
+
readPr: readOpenPr,
|
|
907
|
+
readTarget: configuredPushTarget,
|
|
908
|
+
readHead: cleanHead,
|
|
909
|
+
push: (remote, refspec) => run("git", pushArgs(remote, refspec)),
|
|
910
|
+
});
|
|
911
|
+
const destination = result.remote ? ` remote=${result.remote} branch=${result.ref}` : "";
|
|
912
|
+
console.log(`pushed_head=${localHead}${destination} status=${result.status}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function page(nodes, hasNextPage = false, endCursor = null) {
|
|
916
|
+
return { nodes, pageInfo: { hasNextPage, endCursor } };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function selfTest() {
|
|
920
|
+
const enterpriseHost = "github.example.test";
|
|
921
|
+
const enterpriseUrl = `https://${enterpriseHost}/Owner/Repo/pull/1`;
|
|
922
|
+
assert.deepEqual(parsePrUrl(enterpriseUrl), {
|
|
923
|
+
url: enterpriseUrl,
|
|
924
|
+
hostname: enterpriseHost,
|
|
925
|
+
owner: "Owner",
|
|
926
|
+
repo: "Repo",
|
|
927
|
+
number: 1,
|
|
928
|
+
});
|
|
929
|
+
assert.doesNotThrow(() => validatePrRef(enterpriseUrl));
|
|
930
|
+
assert.throws(() => validatePrRef(`http://${enterpriseHost}/Owner/Repo/pull/1`), UsageError);
|
|
931
|
+
assert.deepEqual(graphqlArgs(enterpriseHost, { owner: "owner", skipped: null }), [
|
|
932
|
+
"api", "graphql", "--hostname", enterpriseHost, "-F", "query=@-", "-F", "owner=owner",
|
|
933
|
+
]);
|
|
934
|
+
const credentialedPushUrl = `https://account:secret@${enterpriseHost}/Owner/Repo.git`;
|
|
935
|
+
const repositoryReads = [];
|
|
936
|
+
assert.deepEqual(readPushRepository(credentialedPushUrl, (program, args) => {
|
|
937
|
+
repositoryReads.push([program, ...args]);
|
|
938
|
+
return { nameWithOwner: "Owner/Repo", url: `https://${enterpriseHost}/Owner/Repo` };
|
|
939
|
+
}), { hostname: enterpriseHost, repository: "Owner/Repo" });
|
|
940
|
+
assert.deepEqual(repositoryReads, [[
|
|
941
|
+
"gh", "repo", "view", `${enterpriseHost}/Owner/Repo`, "--json", "nameWithOwner,url",
|
|
942
|
+
]]);
|
|
943
|
+
let invalidPushUrlRead = false;
|
|
944
|
+
assert.throws(() => readPushRepository(`https://account:secret@${enterpriseHost}/Owner`, () => {
|
|
945
|
+
invalidPushUrlRead = true;
|
|
946
|
+
}), /push URL is invalid/);
|
|
947
|
+
assert.equal(invalidPushUrlRead, false);
|
|
948
|
+
assert.deepEqual(candidatePrUrls([{
|
|
949
|
+
total_count: 1,
|
|
950
|
+
incomplete_results: false,
|
|
951
|
+
items: [{ html_url: enterpriseUrl }],
|
|
952
|
+
}], enterpriseHost), [enterpriseUrl]);
|
|
953
|
+
const paginatedItems = Array.from({ length: 101 }, (_, index) => ({ html_url: `https://${enterpriseHost}/Owner/Repo/pull/${index + 1}` }));
|
|
954
|
+
assert.equal(candidatePrUrls([
|
|
955
|
+
{ total_count: 101, incomplete_results: false, items: paginatedItems.slice(0, 100) },
|
|
956
|
+
{ total_count: 101, incomplete_results: false, items: paginatedItems.slice(100) },
|
|
957
|
+
], enterpriseHost).length, 101);
|
|
958
|
+
assert.throws(() => candidatePrUrls([{ total_count: 1, incomplete_results: true, items: [{ html_url: enterpriseUrl }] }], enterpriseHost), /incomplete/);
|
|
959
|
+
assert.throws(() => candidatePrUrls([{ total_count: 1001, incomplete_results: false, items: [] }], enterpriseHost), /cap/);
|
|
960
|
+
assert.throws(() => candidatePrUrls([{ total_count: 1, incomplete_results: false, items: [] }], enterpriseHost), /count/);
|
|
961
|
+
assert.throws(() => candidatePrUrls([
|
|
962
|
+
{ total_count: 101, incomplete_results: false, items: paginatedItems.slice(0, 100) },
|
|
963
|
+
{ total_count: 102, incomplete_results: false, items: paginatedItems.slice(100) },
|
|
964
|
+
], enterpriseHost), /inconsistent/);
|
|
965
|
+
assert.throws(() => candidatePrUrls([{ total_count: 1, incomplete_results: false, items: [{}] }], enterpriseHost), /must be a string/);
|
|
966
|
+
assert.throws(() => candidatePrUrls([{ total_count: 1, incomplete_results: false, items: [{ html_url: "https://github.com/Owner/Repo/pull/1" }] }], enterpriseHost), /hostname/);
|
|
967
|
+
assert.throws(() => candidatePrUrls([{ total_count: 2, incomplete_results: false, items: [{ html_url: enterpriseUrl }, { html_url: enterpriseUrl.toLowerCase() }] }], enterpriseHost), /duplicate/);
|
|
968
|
+
|
|
969
|
+
for (const remoteUrl of [
|
|
970
|
+
`git@${enterpriseHost}:Owner/Repo.git`,
|
|
971
|
+
`ssh://git@${enterpriseHost}/Owner/Repo.git`,
|
|
972
|
+
`https://${enterpriseHost}/Owner/Repo.git`,
|
|
973
|
+
]) assert.equal(githubRemote(remoteUrl, enterpriseHost), "owner/repo");
|
|
974
|
+
assert.equal(githubRemote("https://github.com/Owner/Repo.git", enterpriseHost), null);
|
|
975
|
+
assert.equal(githubRemote(`http://${enterpriseHost}/Owner/Repo.git`, enterpriseHost), null);
|
|
976
|
+
assert.equal(retryableReadFailure(new FeedbackError("HTTP 503")), true);
|
|
977
|
+
assert.equal(retryableReadFailure(new FeedbackError("HTTP 400")), false);
|
|
978
|
+
let readAttempts = 0;
|
|
979
|
+
assert.equal(retryRead(() => {
|
|
980
|
+
readAttempts += 1;
|
|
981
|
+
if (readAttempts === 1) throw new FeedbackError("TLS handshake failed");
|
|
982
|
+
return "ok";
|
|
983
|
+
}, () => {}), "ok");
|
|
984
|
+
assert.equal(readAttempts, 2);
|
|
985
|
+
let timeoutAttempts = 0;
|
|
986
|
+
assert.throws(
|
|
987
|
+
() => retryRead(() => run("gh", ["api", "graphql"], undefined, (program, args, options) => {
|
|
988
|
+
timeoutAttempts += 1;
|
|
989
|
+
assert.equal(program, "gh");
|
|
990
|
+
assert.deepEqual(args, ["api", "graphql"]);
|
|
991
|
+
assert.equal(options.timeout, SUBPROCESS_TIMEOUT_MS);
|
|
992
|
+
assert.equal(options.maxBuffer, 64 * 1024 * 1024);
|
|
993
|
+
return {
|
|
994
|
+
error: Object.assign(new Error("credential=secret"), { code: "ETIMEDOUT" }),
|
|
995
|
+
status: null,
|
|
996
|
+
signal: "SIGTERM",
|
|
997
|
+
stdout: "",
|
|
998
|
+
stderr: "",
|
|
999
|
+
};
|
|
1000
|
+
}), () => {}),
|
|
1001
|
+
(error) => error instanceof FeedbackError &&
|
|
1002
|
+
error.message === `gh timed out after ${SUBPROCESS_TIMEOUT_MS}ms` &&
|
|
1003
|
+
!error.message.includes("secret"),
|
|
1004
|
+
);
|
|
1005
|
+
assert.equal(timeoutAttempts, 1);
|
|
1006
|
+
|
|
1007
|
+
const temporary = mkdtempSync(join(tmpdir(), "pr-feedback-"));
|
|
1008
|
+
const emptySnapshot = join(temporary, "snapshot.json");
|
|
1009
|
+
writeFileSync(emptySnapshot, "");
|
|
1010
|
+
assert.equal(previousSnapshot(emptySnapshot), null);
|
|
1011
|
+
|
|
1012
|
+
const bin = join(temporary, "bin");
|
|
1013
|
+
mkdirSync(bin);
|
|
1014
|
+
writeFileSync(join(bin, "gh"), `#!/bin/sh
|
|
1015
|
+
if [ "$1" = auth ]; then
|
|
1016
|
+
echo 'stale other-host credentials' >&2
|
|
1017
|
+
exit 1
|
|
1018
|
+
fi
|
|
1019
|
+
if [ "$1" = pr ] && [ "$2" = view ]; then
|
|
1020
|
+
if [ "$3" = 1 ]; then
|
|
1021
|
+
echo '{"number":1,"url":"https://${enterpriseHost}/Owner/Repo/pull/1","state":"OPEN","baseRefName":"main","baseRefOid":"${"b".repeat(40)}","headRefName":"feature","headRefOid":"${"a".repeat(40)}","headRepository":{"nameWithOwner":"Owner/Repo"},"mergeStateStatus":"CLEAN","statusCheckRollup":[]}'
|
|
1022
|
+
exit 0
|
|
1023
|
+
fi
|
|
1024
|
+
echo 'target request failed' >&2
|
|
1025
|
+
exit 1
|
|
1026
|
+
fi
|
|
1027
|
+
if [ "$1" = api ] && [ "$2" = graphql ] && [ "$3" = --hostname ] && [ "$4" = ${enterpriseHost} ]; then
|
|
1028
|
+
echo 'host GraphQL failed' >&2
|
|
1029
|
+
exit 1
|
|
1030
|
+
fi
|
|
1031
|
+
echo "unexpected gh command: $*" >&2
|
|
1032
|
+
exit 1
|
|
1033
|
+
`, { mode: 0o755 });
|
|
1034
|
+
const originalPath = process.env.PATH;
|
|
1035
|
+
const originalLog = console.log;
|
|
1036
|
+
try {
|
|
1037
|
+
process.env.PATH = `${bin}:${originalPath ?? ""}`;
|
|
1038
|
+
console.log = () => {};
|
|
1039
|
+
assert.equal(readOpenPr("1").hostname, enterpriseHost);
|
|
1040
|
+
assert.throws(() => readOpenPr("404"), /target request failed/);
|
|
1041
|
+
assert.throws(() => graphqlResponse("query { viewer { login } }", {}, enterpriseHost), /host GraphQL failed/);
|
|
1042
|
+
} finally {
|
|
1043
|
+
console.log = originalLog;
|
|
1044
|
+
if (originalPath === undefined) delete process.env.PATH;
|
|
1045
|
+
else process.env.PATH = originalPath;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const originalWorkingDirectory = process.cwd();
|
|
1049
|
+
const repository = join(temporary, "repository");
|
|
1050
|
+
try {
|
|
1051
|
+
run("git", ["init", "--initial-branch=main", repository]);
|
|
1052
|
+
process.chdir(repository);
|
|
1053
|
+
git("config", "user.name", "Pi PR self-test");
|
|
1054
|
+
git("config", "user.email", "pi-pr@example.test");
|
|
1055
|
+
writeFileSync("tracked.txt", "base\n");
|
|
1056
|
+
git("add", "tracked.txt");
|
|
1057
|
+
git("commit", "-m", "base");
|
|
1058
|
+
const base = cleanHead();
|
|
1059
|
+
git("config", "status.showUntrackedFiles", "no");
|
|
1060
|
+
writeFileSync("untracked.txt", "untracked\n");
|
|
1061
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1062
|
+
assert.throws(() => cleanHead(), /index and worktree must be clean/);
|
|
1063
|
+
rmSync("untracked.txt");
|
|
1064
|
+
git("config", "--unset", "status.showUntrackedFiles");
|
|
1065
|
+
|
|
1066
|
+
git("switch", "-c", "operation-source");
|
|
1067
|
+
writeFileSync("tracked.txt", "picked\n");
|
|
1068
|
+
git("commit", "-am", "pick one");
|
|
1069
|
+
const firstPick = git("rev-parse", "HEAD");
|
|
1070
|
+
writeFileSync("tracked.txt", "picked again\n");
|
|
1071
|
+
git("commit", "-am", "pick two");
|
|
1072
|
+
const secondPick = git("rev-parse", "HEAD");
|
|
1073
|
+
|
|
1074
|
+
git("switch", "main");
|
|
1075
|
+
writeFileSync("tracked.txt", "target\n");
|
|
1076
|
+
git("commit", "-am", "target");
|
|
1077
|
+
git("branch", "merge-source", base);
|
|
1078
|
+
git("switch", "merge-source");
|
|
1079
|
+
git("commit", "--allow-empty", "-m", "merge source");
|
|
1080
|
+
const mergeSource = git("rev-parse", "HEAD");
|
|
1081
|
+
git("switch", "main");
|
|
1082
|
+
|
|
1083
|
+
writeFileSync(join(temporary, "MERGE_HEAD"), "stale outside Git state\n");
|
|
1084
|
+
assert.equal(cleanHead(), git("rev-parse", "HEAD"));
|
|
1085
|
+
|
|
1086
|
+
git("merge", "--no-ff", "--no-commit", mergeSource);
|
|
1087
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1088
|
+
assert.throws(() => cleanHead(), /merge is in progress/);
|
|
1089
|
+
git("merge", "--abort");
|
|
1090
|
+
|
|
1091
|
+
assert.throws(() => run("git", ["rebase", "--force-rebase", "--exec", "false", base]), FeedbackError);
|
|
1092
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1093
|
+
assert.throws(() => cleanHead(), /rebase is in progress/);
|
|
1094
|
+
git("rebase", "--abort");
|
|
1095
|
+
|
|
1096
|
+
assert.throws(() => run("git", ["cherry-pick", firstPick, secondPick]), FeedbackError);
|
|
1097
|
+
git("checkout", "--ours", "tracked.txt");
|
|
1098
|
+
git("add", "tracked.txt");
|
|
1099
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1100
|
+
assert.throws(() => cleanHead(), /cherry-pick is in progress/);
|
|
1101
|
+
const cherryPickHead = git("rev-parse", "--git-path", "CHERRY_PICK_HEAD");
|
|
1102
|
+
const cherryPickState = readFileSync(cherryPickHead);
|
|
1103
|
+
rmSync(cherryPickHead);
|
|
1104
|
+
assert.throws(() => cleanHead(), /sequenced cherry-pick or revert is in progress/);
|
|
1105
|
+
writeFileSync(cherryPickHead, cherryPickState);
|
|
1106
|
+
git("cherry-pick", "--abort");
|
|
1107
|
+
|
|
1108
|
+
assert.throws(() => run("git", ["revert", "--no-edit", secondPick, firstPick]), FeedbackError);
|
|
1109
|
+
git("checkout", "--ours", "tracked.txt");
|
|
1110
|
+
git("add", "tracked.txt");
|
|
1111
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1112
|
+
assert.throws(() => cleanHead(), /revert is in progress/);
|
|
1113
|
+
const revertHead = git("rev-parse", "--git-path", "REVERT_HEAD");
|
|
1114
|
+
const revertState = readFileSync(revertHead);
|
|
1115
|
+
rmSync(revertHead);
|
|
1116
|
+
assert.throws(() => cleanHead(), /sequenced cherry-pick or revert is in progress/);
|
|
1117
|
+
writeFileSync(revertHead, revertState);
|
|
1118
|
+
git("revert", "--abort");
|
|
1119
|
+
|
|
1120
|
+
const linked = join(temporary, "linked");
|
|
1121
|
+
git("worktree", "add", "-b", "linked", linked);
|
|
1122
|
+
process.chdir(linked);
|
|
1123
|
+
assert.equal(cleanHead(), git("rev-parse", "HEAD"));
|
|
1124
|
+
git("merge", "--no-ff", "--no-commit", mergeSource);
|
|
1125
|
+
assert.equal(git("status", "--porcelain"), "");
|
|
1126
|
+
assert.throws(() => cleanHead(), /merge is in progress/);
|
|
1127
|
+
git("merge", "--abort");
|
|
1128
|
+
assert.equal(cleanHead(), git("rev-parse", "HEAD"));
|
|
1129
|
+
|
|
1130
|
+
process.chdir(repository);
|
|
1131
|
+
git("branch", "-m", "feature-local");
|
|
1132
|
+
git("remote", "add", "fork", `https://account:secret@${enterpriseHost}/Owner/Repo.git`);
|
|
1133
|
+
git("remote", "add", "upstream", `https://${enterpriseHost}/Owner/Other.git`);
|
|
1134
|
+
git("config", "push.default", "upstream");
|
|
1135
|
+
const discoveryMetadata = {
|
|
1136
|
+
hostname: enterpriseHost,
|
|
1137
|
+
headRefName: "feature/pr",
|
|
1138
|
+
headRefOid: cleanHead(),
|
|
1139
|
+
headRepository: { nameWithOwner: "Owner/Repo" },
|
|
1140
|
+
};
|
|
1141
|
+
requireMatchingHead(discoveryMetadata, discoveryMetadata.headRefOid);
|
|
1142
|
+
assert.throws(() => requireMatchingHead(discoveryMetadata, base), /does not match PR head/);
|
|
1143
|
+
|
|
1144
|
+
assert.throws(() => configuredPushTarget(), /has no configured push target/);
|
|
1145
|
+
git("config", "branch.feature-local.remote", "fork");
|
|
1146
|
+
git("config", "branch.feature-local.merge", "refs/heads/feature/pr");
|
|
1147
|
+
const discoveryCalls = join(temporary, "discovery-calls");
|
|
1148
|
+
writeFileSync(join(bin, "gh"), `#!/bin/sh
|
|
1149
|
+
echo "$*" >> "${discoveryCalls}"
|
|
1150
|
+
if [ "$*" = "repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url" ]; then
|
|
1151
|
+
echo '{"nameWithOwner":"Owner/Repo","url":"https://${enterpriseHost}/Owner/Repo"}'
|
|
1152
|
+
exit 0
|
|
1153
|
+
fi
|
|
1154
|
+
if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100" ]; then
|
|
1155
|
+
echo '[{"total_count":1,"incomplete_results":false,"items":[{"html_url":"https://${enterpriseHost}/Upstream/Project/pull/1"}]}]'
|
|
1156
|
+
exit 0
|
|
1157
|
+
fi
|
|
1158
|
+
if [ "$1" = pr ] && [ "$2" = view ] && [ "$3" = "https://${enterpriseHost}/Upstream/Project/pull/1" ]; then
|
|
1159
|
+
printf '%s\\n' '{"number":1,"url":"https://${enterpriseHost}/Upstream/Project/pull/1","state":"OPEN","baseRefName":"main","baseRefOid":"${base}","headRefName":"feature/pr","headRefOid":"${discoveryMetadata.headRefOid}","headRepository":{"nameWithOwner":"Owner/Repo"},"mergeStateStatus":"CLEAN","statusCheckRollup":[]}'
|
|
1160
|
+
exit 0
|
|
1161
|
+
fi
|
|
1162
|
+
echo "unexpected gh command: $*" >&2
|
|
1163
|
+
exit 1
|
|
1164
|
+
`, { mode: 0o755 });
|
|
1165
|
+
const discoveryScript = readFileSync(join(bin, "gh"), "utf8");
|
|
1166
|
+
const pathBeforeDiscovery = process.env.PATH;
|
|
1167
|
+
const logBeforeDiscovery = console.log;
|
|
1168
|
+
try {
|
|
1169
|
+
process.env.PATH = `${bin}:${pathBeforeDiscovery ?? ""}`;
|
|
1170
|
+
console.log = () => {};
|
|
1171
|
+
const discovered = discoverOpenPr(discoveryMetadata.headRefOid);
|
|
1172
|
+
assert.equal(discovered.push.remote, "fork");
|
|
1173
|
+
assert.equal(discovered.metadata.headRefName, "feature/pr");
|
|
1174
|
+
assert.equal(main(["checks", "--expected-head", discoveryMetadata.headRefOid]), 0);
|
|
1175
|
+
assert.throws(
|
|
1176
|
+
() => main(["checks", "--pr", `https://${enterpriseHost}/Upstream/Project/pull/1`, "--expected-head", base]),
|
|
1177
|
+
/local HEAD .* does not match expected head/,
|
|
1178
|
+
);
|
|
1179
|
+
writeFileSync(join(bin, "gh"), discoveryScript.replace(
|
|
1180
|
+
`"url":"https://${enterpriseHost}/Upstream/Project/pull/1"`,
|
|
1181
|
+
`"url":"https://${enterpriseHost}/Owner/Repo/pull/1"`,
|
|
1182
|
+
), { mode: 0o755 });
|
|
1183
|
+
assert.throws(() => main(["checks", "--expected-head", discoveryMetadata.headRefOid]), /metadata identity does not match requested PR/);
|
|
1184
|
+
writeFileSync(join(bin, "gh"), discoveryScript.replace(
|
|
1185
|
+
`"headRefOid":"${discoveryMetadata.headRefOid}"`,
|
|
1186
|
+
`"headRefOid":"${base}"`,
|
|
1187
|
+
), { mode: 0o755 });
|
|
1188
|
+
assert.throws(() => main(["checks", "--expected-head", discoveryMetadata.headRefOid]), /matching configured push target and local HEAD, found 0/);
|
|
1189
|
+
assert.throws(
|
|
1190
|
+
() => main(["checks", "--pr", `https://${enterpriseHost}/Upstream/Project/pull/1`, "--expected-head", discoveryMetadata.headRefOid]),
|
|
1191
|
+
/PR head .* does not match expected head/,
|
|
1192
|
+
);
|
|
1193
|
+
} finally {
|
|
1194
|
+
console.log = logBeforeDiscovery;
|
|
1195
|
+
if (pathBeforeDiscovery === undefined) delete process.env.PATH;
|
|
1196
|
+
else process.env.PATH = pathBeforeDiscovery;
|
|
1197
|
+
}
|
|
1198
|
+
const discoveryCommands = readFileSync(discoveryCalls, "utf8").trim().split("\n");
|
|
1199
|
+
assert.equal(discoveryCommands[0], `repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url`);
|
|
1200
|
+
assert(!discoveryCommands.some((command) => command.includes("account") || command.includes("secret")));
|
|
1201
|
+
assert.equal(discoveryCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100`);
|
|
1202
|
+
assert.match(discoveryCommands[2], new RegExp(`^pr view https://${enterpriseHost}/Upstream/Project/pull/1 `));
|
|
1203
|
+
assert(!discoveryCommands.some((command) => command.startsWith("pr view --json")));
|
|
1204
|
+
|
|
1205
|
+
git("config", "branch.feature-local.merge", "refs/heads/bad..ref");
|
|
1206
|
+
assert.throws(() => configuredPushTarget(), /check-ref-format/);
|
|
1207
|
+
git("config", "branch.feature-local.merge", "refs/heads/feature/pr");
|
|
1208
|
+
|
|
1209
|
+
const views = join(temporary, "views");
|
|
1210
|
+
const resolved = join(temporary, "resolved");
|
|
1211
|
+
const resolutionCalls = join(temporary, "resolution-calls");
|
|
1212
|
+
writeFileSync(join(bin, "gh"), `#!/bin/sh
|
|
1213
|
+
echo "$*" >> "${resolutionCalls}"
|
|
1214
|
+
if [ "$*" = "repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url" ]; then
|
|
1215
|
+
echo '{"nameWithOwner":"Owner/Repo","url":"https://${enterpriseHost}/Owner/Repo"}'
|
|
1216
|
+
exit 0
|
|
1217
|
+
fi
|
|
1218
|
+
if [ "$*" = "api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100" ]; then
|
|
1219
|
+
echo '[{"total_count":1,"incomplete_results":false,"items":[{"html_url":"https://${enterpriseHost}/Owner/Repo/pull/1"}]}]'
|
|
1220
|
+
exit 0
|
|
1221
|
+
fi
|
|
1222
|
+
if [ "$1" = pr ] && [ "$2" = view ]; then
|
|
1223
|
+
if [ "$3" != 1 ] && [ "$3" != "https://${enterpriseHost}/Owner/Repo/pull/1" ]; then
|
|
1224
|
+
echo "unexpected gh command: $*" >&2
|
|
1225
|
+
exit 1
|
|
1226
|
+
fi
|
|
1227
|
+
views=0
|
|
1228
|
+
[ -f "${views}" ] && views=$(cat "${views}")
|
|
1229
|
+
views=$((views + 1))
|
|
1230
|
+
echo "$views" > "${views}"
|
|
1231
|
+
head=${discoveryMetadata.headRefOid}
|
|
1232
|
+
[ "$views" -ge 3 ] && head=${"d".repeat(40)}
|
|
1233
|
+
printf '%s\\n' "{\\"number\\":1,\\"url\\":\\"https://${enterpriseHost}/Owner/Repo/pull/1\\",\\"state\\":\\"OPEN\\",\\"baseRefName\\":\\"main\\",\\"baseRefOid\\":\\"${base}\\",\\"headRefName\\":\\"feature/pr\\",\\"headRefOid\\":\\"$head\\",\\"headRepository\\":{\\"nameWithOwner\\":\\"Owner/Repo\\"},\\"mergeStateStatus\\":\\"CLEAN\\",\\"statusCheckRollup\\":[]}"
|
|
1234
|
+
exit 0
|
|
1235
|
+
fi
|
|
1236
|
+
if [ "$1" = api ] && [ "$2" = graphql ] && [ "$3" = --hostname ] && [ "$4" = ${enterpriseHost} ]; then
|
|
1237
|
+
query=$(cat)
|
|
1238
|
+
case "$query" in
|
|
1239
|
+
*resolveReviewThread*)
|
|
1240
|
+
thread=
|
|
1241
|
+
for arg in "$@"; do
|
|
1242
|
+
case "$arg" in threadId=*) thread=\${arg#threadId=} ;; esac
|
|
1243
|
+
done
|
|
1244
|
+
echo "$thread" >> "${resolved}"
|
|
1245
|
+
printf '%s\\n' "{\\"data\\":{\\"resolveReviewThread\\":{\\"thread\\":{\\"id\\":\\"$thread\\",\\"isResolved\\":true}}}}"
|
|
1246
|
+
;;
|
|
1247
|
+
*)
|
|
1248
|
+
echo '{"data":{"repository":{"pullRequest":{"reviewThreads":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[{"id":"resolved","isResolved":true},{"id":"first","isResolved":false},{"id":"second","isResolved":false}]}}}}}'
|
|
1249
|
+
;;
|
|
1250
|
+
esac
|
|
1251
|
+
exit 0
|
|
1252
|
+
fi
|
|
1253
|
+
echo "unexpected gh command: $*" >&2
|
|
1254
|
+
exit 1
|
|
1255
|
+
`, { mode: 0o755 });
|
|
1256
|
+
const pathBeforeResolution = process.env.PATH;
|
|
1257
|
+
try {
|
|
1258
|
+
process.env.PATH = `${bin}:${pathBeforeResolution ?? ""}`;
|
|
1259
|
+
assert.throws(
|
|
1260
|
+
() => main(["resolve", "--pr", "1", "--expected-head", discoveryMetadata.headRefOid, "--thread", "resolved", "--thread", "first", "--thread", "second"]),
|
|
1261
|
+
/matching configured push target and local HEAD, found 0/,
|
|
1262
|
+
);
|
|
1263
|
+
assert.equal(readFileSync(resolved, "utf8"), "first\n");
|
|
1264
|
+
assert.match(readFileSync(resolutionCalls, "utf8"), /^pr view 1 /);
|
|
1265
|
+
|
|
1266
|
+
rmSync(views);
|
|
1267
|
+
rmSync(resolved);
|
|
1268
|
+
writeFileSync(resolutionCalls, "");
|
|
1269
|
+
assert.throws(
|
|
1270
|
+
() => main(["resolve", "--expected-head", discoveryMetadata.headRefOid, "--thread", "resolved", "--thread", "first", "--thread", "second"]),
|
|
1271
|
+
/matching configured push target and local HEAD, found 0/,
|
|
1272
|
+
);
|
|
1273
|
+
} finally {
|
|
1274
|
+
if (pathBeforeResolution === undefined) delete process.env.PATH;
|
|
1275
|
+
else process.env.PATH = pathBeforeResolution;
|
|
1276
|
+
}
|
|
1277
|
+
assert.equal(readFileSync(resolved, "utf8"), "first\n");
|
|
1278
|
+
const resolutionCommands = readFileSync(resolutionCalls, "utf8").trim().split("\n");
|
|
1279
|
+
assert.equal(resolutionCommands[0], `repo view ${enterpriseHost}/Owner/Repo --json nameWithOwner,url`);
|
|
1280
|
+
assert.equal(resolutionCommands[1], `api search/issues --hostname ${enterpriseHost} --paginate --slurp -X GET -f q=is:pr is:open head:Owner:feature/pr -f per_page=100`);
|
|
1281
|
+
assert.match(resolutionCommands[2], new RegExp(`^pr view https://${enterpriseHost}/Owner/Repo/pull/1 `));
|
|
1282
|
+
assert(!resolutionCommands.some((command) => command.startsWith("pr view --json")));
|
|
1283
|
+
|
|
1284
|
+
git("switch", "--detach");
|
|
1285
|
+
assert.throws(() => configuredPushTarget(), /current HEAD is detached/);
|
|
1286
|
+
} finally {
|
|
1287
|
+
process.chdir(originalWorkingDirectory);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
let feedbackPages = 0;
|
|
1291
|
+
const graphqlHosts = [];
|
|
1292
|
+
const request = (query, variables, hostname) => {
|
|
1293
|
+
assert.equal(typeof variables, "object");
|
|
1294
|
+
assert.equal(hostname, enterpriseHost);
|
|
1295
|
+
graphqlHosts.push(hostname);
|
|
1296
|
+
if (query === THREAD_REPLIES_QUERY) return { node: { comments: page([{ id: "reply-2" }]) } };
|
|
1297
|
+
assert.equal(query, FEEDBACK_QUERY);
|
|
1298
|
+
feedbackPages += 1;
|
|
1299
|
+
const first = feedbackPages === 1;
|
|
1300
|
+
return {
|
|
1301
|
+
repository: {
|
|
1302
|
+
pullRequest: {
|
|
1303
|
+
comments: page([{ id: first ? "comment-1" : "comment-2" }], first, first ? "comments-2" : null),
|
|
1304
|
+
reviews: page([{ id: "review-1" }]),
|
|
1305
|
+
reviewThreads: page([{
|
|
1306
|
+
id: first ? "thread-1" : "thread-2",
|
|
1307
|
+
isResolved: false,
|
|
1308
|
+
isOutdated: false,
|
|
1309
|
+
comments: page([{ id: "reply-1" }], first, first ? "replies-2" : null),
|
|
1310
|
+
}], first, first ? "threads-2" : null),
|
|
1311
|
+
},
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
};
|
|
1315
|
+
const metadata = { baseRepository: "owner/repo", number: 1, hostname: enterpriseHost };
|
|
1316
|
+
const buckets = threadBuckets([
|
|
1317
|
+
{ id: "resolved", isResolved: true, isOutdated: false },
|
|
1318
|
+
{ id: "outdated", isResolved: false, isOutdated: true },
|
|
1319
|
+
{ id: "open", isResolved: false, isOutdated: false },
|
|
1320
|
+
]);
|
|
1321
|
+
assert.deepEqual(nodeIds(buckets.openCurrent, "open thread"), ["open"]);
|
|
1322
|
+
const fetched = collectFeedback(metadata, request);
|
|
1323
|
+
assert.deepEqual(nodeIds(fetched.conversationComments, "comment"), ["comment-1", "comment-2"]);
|
|
1324
|
+
assert.deepEqual(nodeIds(fetched.reviews, "review"), ["review-1"]);
|
|
1325
|
+
assert.deepEqual(nodeIds(fetched.reviewThreads, "thread"), ["thread-1", "thread-2"]);
|
|
1326
|
+
assert.deepEqual(nodeIds(fetched.reviewThreads[0].comments, "reply"), ["reply-1", "reply-2"]);
|
|
1327
|
+
assert.deepEqual(new Set(graphqlHosts), new Set([enterpriseHost]));
|
|
1328
|
+
|
|
1329
|
+
let statePages = 0;
|
|
1330
|
+
const stateRequest = (query, variables, hostname) => {
|
|
1331
|
+
assert.equal(typeof variables, "object");
|
|
1332
|
+
assert.equal(query, THREAD_STATES_QUERY);
|
|
1333
|
+
assert.equal(hostname, enterpriseHost);
|
|
1334
|
+
statePages += 1;
|
|
1335
|
+
return { repository: { pullRequest: { reviewThreads: page(
|
|
1336
|
+
[{ id: `thread-${statePages}`, isResolved: true }],
|
|
1337
|
+
statePages === 1,
|
|
1338
|
+
statePages === 1 ? "states-2" : null,
|
|
1339
|
+
) } } };
|
|
1340
|
+
};
|
|
1341
|
+
assert.deepEqual([...collectThreadStates(metadata, stateRequest)], [["thread-1", true], ["thread-2", true]]);
|
|
1342
|
+
|
|
1343
|
+
const mutations = [];
|
|
1344
|
+
resolveThread("thread-1", enterpriseHost, (query, variables, hostname) => {
|
|
1345
|
+
mutations.push([query, variables, hostname]);
|
|
1346
|
+
return { resolveReviewThread: { thread: { id: "thread-1", isResolved: true } } };
|
|
1347
|
+
});
|
|
1348
|
+
assert.deepEqual(mutations, [[RESOLVE_THREAD_MUTATION, { threadId: "thread-1" }, enterpriseHost]]);
|
|
1349
|
+
let failedMutations = 0;
|
|
1350
|
+
assert.throws(
|
|
1351
|
+
() => resolveThread("thread-2", enterpriseHost, () => {
|
|
1352
|
+
failedMutations += 1;
|
|
1353
|
+
throw new FeedbackError("mutation failed");
|
|
1354
|
+
}),
|
|
1355
|
+
/mutation failed/,
|
|
1356
|
+
);
|
|
1357
|
+
assert.equal(failedMutations, 1);
|
|
1358
|
+
|
|
1359
|
+
const previousFeedback = {
|
|
1360
|
+
conversationComments: [{ id: "conversation-1", author: { login: "octocat" }, body: "old conversation" }],
|
|
1361
|
+
reviews: [{ id: "review-1", state: "CHANGES_REQUESTED", author: { login: "reviewer" }, body: "old review" }],
|
|
1362
|
+
reviewThreads: [
|
|
1363
|
+
{ id: "current", isResolved: false, isOutdated: false, path: "src/a.js", line: 3, comments: [{ id: "current-comment", author: { login: "reviewer" }, body: "old reply" }] },
|
|
1364
|
+
{ id: "outdated", isResolved: false, isOutdated: false, path: "src/b.js", originalLine: 4, comments: [] },
|
|
1365
|
+
{ id: "resolved", isResolved: false, isOutdated: false, path: "src/c.js", line: 5, comments: [] },
|
|
1366
|
+
],
|
|
1367
|
+
};
|
|
1368
|
+
const feedback = {
|
|
1369
|
+
pullRequest: {
|
|
1370
|
+
mergeStateStatus: "BLOCKED",
|
|
1371
|
+
statusCheckRollup: [
|
|
1372
|
+
{ status: "COMPLETED", conclusion: "SUCCESS" },
|
|
1373
|
+
{ status: "IN_PROGRESS", conclusion: null },
|
|
1374
|
+
{ state: "FAILURE" },
|
|
1375
|
+
{ status: "COMPLETED", conclusion: "STALE" },
|
|
1376
|
+
],
|
|
1377
|
+
},
|
|
1378
|
+
conversationComments: [{ ...previousFeedback.conversationComments[0], body: "edited conversation" }, { id: "conversation-2", author: { login: "octocat" }, body: "new conversation" }],
|
|
1379
|
+
reviews: [{ ...previousFeedback.reviews[0], state: "DISMISSED", body: "edited review" }, { id: "review-2", state: "COMMENTED", author: { login: "reviewer" }, body: "new review" }],
|
|
1380
|
+
reviewThreads: [
|
|
1381
|
+
{ ...previousFeedback.reviewThreads[0], comments: [{ ...previousFeedback.reviewThreads[0].comments[0], body: "edited reply" }, { id: "new-reply", author: { login: "reviewer" }, body: "new reply" }] },
|
|
1382
|
+
{ ...previousFeedback.reviewThreads[1], isOutdated: true },
|
|
1383
|
+
{ ...previousFeedback.reviewThreads[2], isResolved: true },
|
|
1384
|
+
{ id: "new-thread", isResolved: false, isOutdated: false, path: "src/d.js", line: 6, comments: [{ id: "new-thread-comment", author: { login: "reviewer" }, body: "new thread" }] },
|
|
1385
|
+
],
|
|
1386
|
+
};
|
|
1387
|
+
assert.equal(location({ path: "src/normal.js", line: 7 }), "src/normal.js:7");
|
|
1388
|
+
const maliciousPathLines = [];
|
|
1389
|
+
printThreads("threads", [{
|
|
1390
|
+
id: "thread",
|
|
1391
|
+
isResolved: false,
|
|
1392
|
+
isOutdated: false,
|
|
1393
|
+
path: "src/forged\nrow\t\u001b[31m\u009b31m.js",
|
|
1394
|
+
line: 7,
|
|
1395
|
+
comments: [],
|
|
1396
|
+
}], (line) => maliciousPathLines.push(line));
|
|
1397
|
+
assert.deepEqual(maliciousPathLines, [
|
|
1398
|
+
"threads=thread\tstate\tlocation\tcomment\tauthor\tbody",
|
|
1399
|
+
"thread\tcurrent\tsrc/forged\\nrow\\t\\u001b[31m\\u009b31m.js:7\t-\t-\t-",
|
|
1400
|
+
]);
|
|
1401
|
+
|
|
1402
|
+
assert.equal(checkBucket({ conclusion: "STALE" }), "failing");
|
|
1403
|
+
assert.equal(checkBucket({ status: "", state: "ERROR", conclusion: "" }), "failing");
|
|
1404
|
+
assert.equal(checkBucket({ status: "QUEUED", state: "", conclusion: "" }), "pending");
|
|
1405
|
+
assert.throws(() => checkBucket({ status: "IN_PROGRESS", conclusion: "SUCCESS" }), /invalid PR status check/);
|
|
1406
|
+
assert.throws(() => checkBucket({ status: "", state: "", conclusion: "" }), /invalid PR status check/);
|
|
1407
|
+
assert.deepEqual(checkSummary(feedback.pullRequest), { passing: 1, pending: 1, failing: 2 });
|
|
1408
|
+
const initialLines = [];
|
|
1409
|
+
printFetchSummary(feedback, null, "/tmp/snapshot", (line) => initialLines.push(line));
|
|
1410
|
+
for (const body of ["edited conversation", "edited review", "edited reply", "new conversation", "new review", "new reply", "new thread"]) {
|
|
1411
|
+
assert(initialLines.some((line) => line.includes(body)));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const savedFeedback = { ...feedback, pullRequest: { ...feedback.pullRequest, url: "https://github.com/owner/repo/pull/1" } };
|
|
1415
|
+
const savedPrevious = { ...previousFeedback, pullRequest: savedFeedback.pullRequest };
|
|
1416
|
+
const deltaSnapshot = join(temporary, "delta.json");
|
|
1417
|
+
writeSnapshot(deltaSnapshot, savedPrevious);
|
|
1418
|
+
const deltaLines = [];
|
|
1419
|
+
printFetchSummary(feedback, previousSnapshot(deltaSnapshot), deltaSnapshot, (line) => deltaLines.push(line));
|
|
1420
|
+
writeSnapshot(deltaSnapshot, savedFeedback);
|
|
1421
|
+
assert.equal(snapshot(deltaSnapshot).reviewThreads.length, feedback.reviewThreads.length);
|
|
1422
|
+
const delta = deltaLines.join("\n");
|
|
1423
|
+
assert(delta.includes("merge_state=BLOCKED checks_total=4 passing=1 pending=1 failing=2"));
|
|
1424
|
+
for (const body of ["edited conversation", "edited review", "edited reply", "new conversation", "new review", "new reply", "new thread"]) assert(delta.includes(body));
|
|
1425
|
+
for (const old of ["old conversation", "old review", "old reply"]) assert(!delta.includes(old));
|
|
1426
|
+
assert(delta.includes("new-thread\tcurrent"));
|
|
1427
|
+
assert(delta.includes("outdated\tcurrent\toutdated"));
|
|
1428
|
+
assert(delta.includes("resolved\tcurrent\tresolved"));
|
|
1429
|
+
const unchangedLines = [];
|
|
1430
|
+
printFetchSummary(feedback, savedFeedback, deltaSnapshot, (line) => unchangedLines.push(line));
|
|
1431
|
+
assert(unchangedLines.includes("feedback_delta=none"));
|
|
1432
|
+
assert(!unchangedLines.some((line) => line.includes("edited conversation")));
|
|
1433
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
1434
|
+
|
|
1435
|
+
const oldHead = {
|
|
1436
|
+
number: 1,
|
|
1437
|
+
url: `https://${enterpriseHost}/owner/repo/pull/1`,
|
|
1438
|
+
hostname: enterpriseHost,
|
|
1439
|
+
baseRefName: "main",
|
|
1440
|
+
baseRefOid: "b".repeat(40),
|
|
1441
|
+
headRefOid: "a".repeat(40),
|
|
1442
|
+
headRefName: "feature",
|
|
1443
|
+
headRepository: { nameWithOwner: "owner/repo" },
|
|
1444
|
+
};
|
|
1445
|
+
const capturedOid = "c".repeat(40);
|
|
1446
|
+
const localHead = { ...oldHead, headRefOid: capturedOid };
|
|
1447
|
+
const pushTarget = {
|
|
1448
|
+
branch: "feature-local",
|
|
1449
|
+
remote: "origin",
|
|
1450
|
+
ref: "feature",
|
|
1451
|
+
pushUrl: `ssh://git@${enterpriseHost}/owner/repo.git`,
|
|
1452
|
+
hostname: enterpriseHost,
|
|
1453
|
+
repository: "owner/repo",
|
|
1454
|
+
};
|
|
1455
|
+
const sequence = (...values) => {
|
|
1456
|
+
let index = 0;
|
|
1457
|
+
return () => values[Math.min(index++, values.length - 1)];
|
|
1458
|
+
};
|
|
1459
|
+
const operations = (readPr, push = () => {}, readTarget = sequence(pushTarget, pushTarget), readHead = () => capturedOid) => ({
|
|
1460
|
+
readPr,
|
|
1461
|
+
readTarget,
|
|
1462
|
+
readHead,
|
|
1463
|
+
push,
|
|
1464
|
+
pause: () => {},
|
|
1465
|
+
});
|
|
1466
|
+
let resolutionMutations = 0;
|
|
1467
|
+
assert.throws(
|
|
1468
|
+
() => resolveCurrentThread("thread-1", prTarget(oldHead, "initial PR"), oldHead.headRefOid, {
|
|
1469
|
+
readHead: () => oldHead.headRefOid,
|
|
1470
|
+
discover: () => ({ metadata: oldHead, push: { ...pushTarget, ref: "other-feature" } }),
|
|
1471
|
+
mutate: () => {
|
|
1472
|
+
resolutionMutations += 1;
|
|
1473
|
+
return { resolveReviewThread: { thread: { id: "thread-1", isResolved: true } } };
|
|
1474
|
+
},
|
|
1475
|
+
}),
|
|
1476
|
+
/configured push target does not match PR head/,
|
|
1477
|
+
);
|
|
1478
|
+
assert.equal(resolutionMutations, 0);
|
|
1479
|
+
assert.deepEqual(pushArgs("origin", `${capturedOid}:refs/heads/feature`), [
|
|
1480
|
+
"push", "--recurse-submodules=no", "origin", `${capturedOid}:refs/heads/feature`,
|
|
1481
|
+
]);
|
|
1482
|
+
|
|
1483
|
+
let alreadyCurrentTargetReads = 0;
|
|
1484
|
+
let alreadyCurrentPushes = 0;
|
|
1485
|
+
assert.equal(publishHead(oldHead, capturedOid, operations(
|
|
1486
|
+
sequence(localHead),
|
|
1487
|
+
() => { alreadyCurrentPushes += 1; },
|
|
1488
|
+
() => { alreadyCurrentTargetReads += 1; return pushTarget; },
|
|
1489
|
+
)).status, "already-current");
|
|
1490
|
+
assert.equal(alreadyCurrentTargetReads, 0);
|
|
1491
|
+
assert.equal(alreadyCurrentPushes, 0);
|
|
1492
|
+
|
|
1493
|
+
const pushCalls = [];
|
|
1494
|
+
assert.equal(publishHead(oldHead, capturedOid, operations(
|
|
1495
|
+
sequence(oldHead, oldHead, localHead),
|
|
1496
|
+
(remote, refspec) => pushCalls.push([remote, refspec]),
|
|
1497
|
+
)).status, "pushed");
|
|
1498
|
+
assert.deepEqual(pushCalls, [["origin", `${capturedOid}:refs/heads/feature`]]);
|
|
1499
|
+
|
|
1500
|
+
let failedPushes = 0;
|
|
1501
|
+
assert.throws(
|
|
1502
|
+
() => publishHead(oldHead, capturedOid, operations(sequence(oldHead, oldHead), () => {
|
|
1503
|
+
failedPushes += 1;
|
|
1504
|
+
throw new FeedbackError("HTTP 503");
|
|
1505
|
+
})),
|
|
1506
|
+
/HTTP 503/,
|
|
1507
|
+
);
|
|
1508
|
+
assert.equal(failedPushes, 1);
|
|
1509
|
+
|
|
1510
|
+
const prDrifts = [
|
|
1511
|
+
["number", { ...oldHead, number: 2, url: `https://${enterpriseHost}/owner/repo/pull/2` }],
|
|
1512
|
+
["URL", { ...oldHead, url: `https://${enterpriseHost}/owner/other/pull/1` }],
|
|
1513
|
+
["hostname", { ...oldHead, hostname: "other.example.test", url: "https://other.example.test/owner/repo/pull/1" }],
|
|
1514
|
+
["base ref", { ...oldHead, baseRefName: "release" }],
|
|
1515
|
+
["base OID", { ...oldHead, baseRefOid: "d".repeat(40) }],
|
|
1516
|
+
["head repository", { ...oldHead, headRepository: { nameWithOwner: "owner/fork" } }],
|
|
1517
|
+
["head ref", { ...oldHead, headRefName: "other-feature" }],
|
|
1518
|
+
];
|
|
1519
|
+
for (const [field, changed] of prDrifts) {
|
|
1520
|
+
let pushes = 0;
|
|
1521
|
+
assert.throws(
|
|
1522
|
+
() => publishHead(oldHead, capturedOid, operations(sequence({ ...changed, headRefOid: capturedOid }), () => { pushes += 1; })),
|
|
1523
|
+
new RegExp(`PR ${field} changed since feedback fetch`),
|
|
1524
|
+
);
|
|
1525
|
+
assert.equal(pushes, 0);
|
|
1526
|
+
}
|
|
1527
|
+
let driftPushes = 0;
|
|
1528
|
+
assert.throws(
|
|
1529
|
+
() => publishHead(oldHead, capturedOid, operations(sequence({ ...oldHead, headRefOid: "d".repeat(40) }), () => { driftPushes += 1; })),
|
|
1530
|
+
/PR head changed before push/,
|
|
1531
|
+
);
|
|
1532
|
+
assert.equal(driftPushes, 0);
|
|
1533
|
+
|
|
1534
|
+
for (const [field, value] of [
|
|
1535
|
+
["branch", "other-local"],
|
|
1536
|
+
["remote", "fork"],
|
|
1537
|
+
["ref", "other-feature"],
|
|
1538
|
+
["pushUrl", `ssh://git@${enterpriseHost}/owner/other.git`],
|
|
1539
|
+
["hostname", "other.example.test"],
|
|
1540
|
+
["repository", "owner/other"],
|
|
1541
|
+
]) {
|
|
1542
|
+
let pushes = 0;
|
|
1543
|
+
assert.throws(
|
|
1544
|
+
() => publishHead(oldHead, capturedOid, operations(
|
|
1545
|
+
sequence(oldHead),
|
|
1546
|
+
() => { pushes += 1; },
|
|
1547
|
+
sequence(pushTarget, { ...pushTarget, [field]: value }),
|
|
1548
|
+
)),
|
|
1549
|
+
/configured push target changed before push/,
|
|
1550
|
+
field,
|
|
1551
|
+
);
|
|
1552
|
+
assert.equal(pushes, 0, field);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
assert.throws(
|
|
1556
|
+
() => publishHead(oldHead, capturedOid, operations(
|
|
1557
|
+
sequence(oldHead),
|
|
1558
|
+
() => {},
|
|
1559
|
+
sequence({ ...pushTarget, repository: "owner/other" }),
|
|
1560
|
+
)),
|
|
1561
|
+
/configured push target does not match PR head/,
|
|
1562
|
+
);
|
|
1563
|
+
assert.throws(
|
|
1564
|
+
() => publishHead(oldHead, capturedOid, operations(sequence(oldHead, { ...oldHead, baseRefOid: "d".repeat(40) }))),
|
|
1565
|
+
/PR base OID changed since feedback fetch/,
|
|
1566
|
+
);
|
|
1567
|
+
assert.throws(
|
|
1568
|
+
() => publishHead(oldHead, capturedOid, operations(sequence(oldHead, { ...oldHead, headRefOid: "d".repeat(40) }))),
|
|
1569
|
+
/PR head OID changed since feedback fetch/,
|
|
1570
|
+
);
|
|
1571
|
+
assert.throws(
|
|
1572
|
+
() => publishHead(oldHead, capturedOid, operations(sequence(oldHead, oldHead), () => {}, undefined, () => "d".repeat(40))),
|
|
1573
|
+
/local HEAD changed before push/,
|
|
1574
|
+
);
|
|
1575
|
+
assert.throws(
|
|
1576
|
+
() => collectFeedback(metadata, () => ({ repository: { pullRequest: {
|
|
1577
|
+
comments: page([], true), reviews: page([]), reviewThreads: page([]),
|
|
1578
|
+
} } })),
|
|
1579
|
+
/missing pagination cursor for conversation comments/,
|
|
1580
|
+
);
|
|
1581
|
+
console.log("pr-feedback self-test ok");
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function usage() {
|
|
1585
|
+
const command = `node ${process.argv[1]}`;
|
|
1586
|
+
return `usage:
|
|
1587
|
+
${command} fetch [--pr PR] (--out FILE | --json)
|
|
1588
|
+
${command} target [--pr PR]
|
|
1589
|
+
${command} checks [--pr PR] --expected-head SHA
|
|
1590
|
+
${command} snapshot-url --snapshot FILE
|
|
1591
|
+
${command} push --snapshot FILE
|
|
1592
|
+
${command} resolve [--pr PR] --expected-head SHA --thread ID [--thread ID ...]
|
|
1593
|
+
${command} self-test`;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function parse(command, args) {
|
|
1597
|
+
const allowed = {
|
|
1598
|
+
fetch: new Set(["--pr", "--out", "--json"]),
|
|
1599
|
+
target: new Set(["--pr"]),
|
|
1600
|
+
checks: new Set(["--pr", "--expected-head"]),
|
|
1601
|
+
"snapshot-url": new Set(["--snapshot"]),
|
|
1602
|
+
push: new Set(["--snapshot"]),
|
|
1603
|
+
resolve: new Set(["--pr", "--expected-head", "--thread"]),
|
|
1604
|
+
}[command];
|
|
1605
|
+
if (!allowed) throw new UsageError(`unknown command: ${command}`);
|
|
1606
|
+
const values = { threads: [] };
|
|
1607
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1608
|
+
const flag = args[index];
|
|
1609
|
+
if (!allowed.has(flag)) throw new UsageError(`unsupported ${flag} for ${command}`);
|
|
1610
|
+
if (flag === "--json") {
|
|
1611
|
+
if (values.json) throw new UsageError("--json was supplied twice");
|
|
1612
|
+
values.json = true;
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
const value = args[index + 1];
|
|
1616
|
+
if (value === undefined || value.startsWith("--")) throw new UsageError(`${flag} needs a value`);
|
|
1617
|
+
index += 1;
|
|
1618
|
+
if (flag === "--thread") {
|
|
1619
|
+
values.threads.push(value);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1623
|
+
if (values[key] !== undefined) throw new UsageError(`${flag} was supplied twice`);
|
|
1624
|
+
values[key] = value;
|
|
1625
|
+
}
|
|
1626
|
+
validatePrRef(values.pr);
|
|
1627
|
+
if (command === "fetch" && values.out === undefined && !values.json) throw new UsageError("fetch needs --out or --json");
|
|
1628
|
+
if (command === "fetch" && values.out !== undefined && values.json) throw new UsageError("fetch accepts --out or --json, not both");
|
|
1629
|
+
if ((command === "push" || command === "snapshot-url") && values.snapshot === undefined) {
|
|
1630
|
+
throw new UsageError(`${command} needs --snapshot`);
|
|
1631
|
+
}
|
|
1632
|
+
if (command === "checks" && values.expectedHead === undefined) throw new UsageError("checks needs --expected-head");
|
|
1633
|
+
if ((command === "checks" || command === "resolve") && values.expectedHead !== undefined) {
|
|
1634
|
+
if (!OID.test(values.expectedHead)) throw new UsageError("--expected-head must be a full OID");
|
|
1635
|
+
values.expectedHead = values.expectedHead.toLowerCase();
|
|
1636
|
+
}
|
|
1637
|
+
if (command === "resolve") {
|
|
1638
|
+
if (values.expectedHead === undefined) throw new UsageError("resolve needs --expected-head");
|
|
1639
|
+
if (!values.threads.length) throw new UsageError("resolve needs at least one --thread");
|
|
1640
|
+
if (new Set(values.threads).size !== values.threads.length) throw new UsageError("duplicate --thread");
|
|
1641
|
+
}
|
|
1642
|
+
return values;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function main(argv) {
|
|
1646
|
+
if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
|
|
1647
|
+
console.log(usage());
|
|
1648
|
+
return 0;
|
|
1649
|
+
}
|
|
1650
|
+
const [command, ...args] = argv;
|
|
1651
|
+
if (command === "self-test") {
|
|
1652
|
+
if (args.length) throw new UsageError("self-test takes no arguments");
|
|
1653
|
+
selfTest();
|
|
1654
|
+
return 0;
|
|
1655
|
+
}
|
|
1656
|
+
const options = parse(command, args);
|
|
1657
|
+
if (command === "fetch") fetchFeedback(options);
|
|
1658
|
+
if (command === "target") verifyTarget(options);
|
|
1659
|
+
if (command === "checks") checkPr(options);
|
|
1660
|
+
if (command === "snapshot-url") printSnapshotPrUrl(options);
|
|
1661
|
+
if (command === "push") pushHead(options);
|
|
1662
|
+
if (command === "resolve") resolveFeedback(options);
|
|
1663
|
+
return 0;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
try {
|
|
1667
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
console.error(`error: ${error.message}`);
|
|
1670
|
+
if (error instanceof UsageError) console.error(usage());
|
|
1671
|
+
process.exitCode = error instanceof UsageError ? 2 : 1;
|
|
1672
|
+
}
|