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