opencode-magi 0.0.0-dev-20260519011027
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/commands.js +18 -0
- package/dist/config/load.js +62 -0
- package/dist/config/output.js +16 -0
- package/dist/config/resolve.js +113 -0
- package/dist/config/validate.js +580 -0
- package/dist/config/worktree.js +13 -0
- package/dist/github/commands.js +398 -0
- package/dist/github/retry.js +44 -0
- package/dist/index.js +540 -0
- package/dist/orchestrator/abort.js +9 -0
- package/dist/orchestrator/ci.js +568 -0
- package/dist/orchestrator/findings.js +66 -0
- package/dist/orchestrator/majority.js +48 -0
- package/dist/orchestrator/merge.js +836 -0
- package/dist/orchestrator/model.js +202 -0
- package/dist/orchestrator/pool.js +15 -0
- package/dist/orchestrator/report.js +168 -0
- package/dist/orchestrator/review.js +791 -0
- package/dist/orchestrator/run-manager.js +1670 -0
- package/dist/orchestrator/safety.js +44 -0
- package/dist/permissions/common.json +24 -0
- package/dist/permissions/editor.json +7 -0
- package/dist/prompts/compose.js +298 -0
- package/dist/prompts/contracts.js +189 -0
- package/dist/prompts/output.js +260 -0
- package/dist/prompts/templates/ci-classification-after-edit.md +16 -0
- package/dist/prompts/templates/ci-classification.md +9 -0
- package/dist/prompts/templates/close-reconsideration.md +6 -0
- package/dist/prompts/templates/edit.md +9 -0
- package/dist/prompts/templates/finding-validation.md +7 -0
- package/dist/prompts/templates/rereview-close-reconsideration.md +6 -0
- package/dist/prompts/templates/rereview.md +16 -0
- package/dist/prompts/templates/review.md +7 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
- package/schema.json +206 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const WORKTREE_CHECKOUT_RETRY_ATTEMPTS = 5;
|
|
5
|
+
const WORKTREE_CHECKOUT_RETRY_DELAY_MS = 100;
|
|
6
|
+
const worktreeCreateLocks = new Map();
|
|
7
|
+
function errorText(error) {
|
|
8
|
+
if (!error || typeof error !== "object")
|
|
9
|
+
return String(error);
|
|
10
|
+
const value = error;
|
|
11
|
+
return [value.message, value.stderr, value.stdout]
|
|
12
|
+
.filter((item) => typeof item === "string")
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
function isCheckoutConfigLockError(error) {
|
|
16
|
+
const text = errorText(error);
|
|
17
|
+
return (/could not lock config file/i.test(text) ||
|
|
18
|
+
/Unable to write upstream branch configuration/i.test(text));
|
|
19
|
+
}
|
|
20
|
+
async function delay(ms) {
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
async function withWorktreeCreateLock(key, run) {
|
|
24
|
+
const previous = worktreeCreateLocks.get(key) ?? Promise.resolve();
|
|
25
|
+
let release;
|
|
26
|
+
const current = new Promise((resolve) => {
|
|
27
|
+
release = resolve;
|
|
28
|
+
});
|
|
29
|
+
const tail = previous.catch(() => undefined).then(() => current);
|
|
30
|
+
worktreeCreateLocks.set(key, tail);
|
|
31
|
+
await previous.catch(() => undefined);
|
|
32
|
+
try {
|
|
33
|
+
return await run();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
release();
|
|
37
|
+
if (worktreeCreateLocks.get(key) === tail)
|
|
38
|
+
worktreeCreateLocks.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function checkoutPullRequestWithRetry(exec, repository, pr, worktreePath) {
|
|
42
|
+
for (let attempt = 0;; attempt += 1) {
|
|
43
|
+
try {
|
|
44
|
+
await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, {
|
|
45
|
+
cwd: worktreePath,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (attempt >= WORKTREE_CHECKOUT_RETRY_ATTEMPTS - 1 ||
|
|
51
|
+
!isCheckoutConfigLockError(error)) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
await delay(WORKTREE_CHECKOUT_RETRY_DELAY_MS * 2 ** attempt);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function shellQuote(value) {
|
|
59
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
60
|
+
}
|
|
61
|
+
export function repoSlug(repository) {
|
|
62
|
+
return `${repository.github.owner}/${repository.github.repo}`;
|
|
63
|
+
}
|
|
64
|
+
function githubHost(repository) {
|
|
65
|
+
return repository.github.host || "github.com";
|
|
66
|
+
}
|
|
67
|
+
export function repoSpecifier(repository) {
|
|
68
|
+
const host = githubHost(repository);
|
|
69
|
+
return host === "github.com"
|
|
70
|
+
? repoSlug(repository)
|
|
71
|
+
: `${host}/${repoSlug(repository)}`;
|
|
72
|
+
}
|
|
73
|
+
export function ghHostOption(repository) {
|
|
74
|
+
const host = githubHost(repository);
|
|
75
|
+
return host === "github.com" ? "" : ` --hostname ${shellQuote(host)}`;
|
|
76
|
+
}
|
|
77
|
+
export async function ghToken(exec, repository, account) {
|
|
78
|
+
return (await exec(`gh auth token${ghHostOption(repository)} --user ${shellQuote(account)}`)).trim();
|
|
79
|
+
}
|
|
80
|
+
export async function fetchPullRequest(exec, repository, pr) {
|
|
81
|
+
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName`);
|
|
82
|
+
return JSON.parse(json);
|
|
83
|
+
}
|
|
84
|
+
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
85
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100) { nodes { author { login } submittedAt state body commit { oid } } } } } }`;
|
|
86
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}`);
|
|
87
|
+
const data = JSON.parse(raw);
|
|
88
|
+
return data.data.repository.pullRequest.reviews.nodes;
|
|
89
|
+
}
|
|
90
|
+
export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
91
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100) { nodes { commit { oid committedDate parents { totalCount } } } } } } }`;
|
|
92
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}`);
|
|
93
|
+
const data = JSON.parse(raw);
|
|
94
|
+
return data.data.repository.pullRequest.commits.nodes.map(({ commit }) => ({
|
|
95
|
+
committedDate: commit.committedDate,
|
|
96
|
+
oid: commit.oid,
|
|
97
|
+
parentCount: commit.parents.totalCount,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
|
|
101
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { author { login } changedFiles labels(first: 100) { nodes { name } } files(first: 100, after: $filesCursor) { nodes { path } pageInfo { hasNextPage endCursor } } } } } }`;
|
|
102
|
+
const files = [];
|
|
103
|
+
let author = "";
|
|
104
|
+
let changedFiles = 0;
|
|
105
|
+
let labels = [];
|
|
106
|
+
let cursor;
|
|
107
|
+
for (;;) {
|
|
108
|
+
const cursorFlag = cursor ? ` -F filesCursor=${shellQuote(cursor)}` : "";
|
|
109
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
|
|
110
|
+
const data = JSON.parse(raw);
|
|
111
|
+
const pullRequest = data.data.repository.pullRequest;
|
|
112
|
+
author = pullRequest.author?.login ?? author;
|
|
113
|
+
changedFiles = pullRequest.changedFiles;
|
|
114
|
+
labels = pullRequest.labels.nodes.map((label) => label.name);
|
|
115
|
+
files.push(...pullRequest.files.nodes.map((file) => file.path));
|
|
116
|
+
if (!pullRequest.files.pageInfo.hasNextPage)
|
|
117
|
+
break;
|
|
118
|
+
cursor = pullRequest.files.pageInfo.endCursor;
|
|
119
|
+
if (!cursor)
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
return { author, changedFiles, files, labels };
|
|
123
|
+
}
|
|
124
|
+
export async function waitForChecks(exec, repository, pr, enabled = repository.checks.waitBeforeReview) {
|
|
125
|
+
if (!enabled)
|
|
126
|
+
return undefined;
|
|
127
|
+
const report = {
|
|
128
|
+
attempts: 0,
|
|
129
|
+
excluded: [],
|
|
130
|
+
failed: [],
|
|
131
|
+
rerun: [],
|
|
132
|
+
scopeInside: [],
|
|
133
|
+
scopeOutsideRecovered: [],
|
|
134
|
+
scopeOutsideUnresolved: [],
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
await watchChecks(exec, repository, pr);
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
report.failed = applyCheckExclusions({
|
|
142
|
+
checks: await fetchFailedChecks(exec, repository, pr),
|
|
143
|
+
excluded: report.excluded,
|
|
144
|
+
patterns: repository.checks.exclude,
|
|
145
|
+
});
|
|
146
|
+
return report;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function watchChecks(exec, repository, pr) {
|
|
150
|
+
await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
|
|
151
|
+
}
|
|
152
|
+
export async function fetchFailedChecks(exec, repository, pr) {
|
|
153
|
+
const checks = await fetchPullRequestChecks(exec, repository, pr);
|
|
154
|
+
return checks.filter((check) => isFailedCheck(check) || isCancelledCheck(check));
|
|
155
|
+
}
|
|
156
|
+
export function isCancelledCheck(check) {
|
|
157
|
+
return check.bucket === "cancel" || check.state === "CANCELLED";
|
|
158
|
+
}
|
|
159
|
+
export function isFailedCheck(check) {
|
|
160
|
+
return check.bucket === "fail" || check.state === "FAILURE";
|
|
161
|
+
}
|
|
162
|
+
export async function fetchPullRequestChecks(exec, repository, pr) {
|
|
163
|
+
const raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow`);
|
|
164
|
+
return JSON.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
export async function fetchWorkflowRunMeta(exec, repository, runId) {
|
|
167
|
+
const endpoint = `repos/${repository.github.owner}/${repository.github.repo}/actions/runs/${runId}`;
|
|
168
|
+
const raw = await exec(`gh api${ghHostOption(repository)} ${shellQuote(endpoint)}`);
|
|
169
|
+
const data = JSON.parse(raw);
|
|
170
|
+
return {
|
|
171
|
+
conclusion: data.conclusion,
|
|
172
|
+
headSha: data.head_sha ?? "",
|
|
173
|
+
status: data.status ?? "",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function excludedCheckMatcher(pattern) {
|
|
177
|
+
if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 1) {
|
|
178
|
+
const regex = new RegExp(pattern.slice(1, -1));
|
|
179
|
+
return (check) => regex.test(check.name);
|
|
180
|
+
}
|
|
181
|
+
return (check) => check.name === pattern;
|
|
182
|
+
}
|
|
183
|
+
export function applyCheckExclusions(input) {
|
|
184
|
+
if (!input.patterns.length)
|
|
185
|
+
return input.checks;
|
|
186
|
+
const matchers = input.patterns.map(excludedCheckMatcher);
|
|
187
|
+
const kept = [];
|
|
188
|
+
for (const check of input.checks) {
|
|
189
|
+
if (matchers.some((matcher) => matcher(check))) {
|
|
190
|
+
input.excluded.push(check);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
kept.push(check);
|
|
194
|
+
}
|
|
195
|
+
return kept;
|
|
196
|
+
}
|
|
197
|
+
export function checkJobId(check) {
|
|
198
|
+
return check.link.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1];
|
|
199
|
+
}
|
|
200
|
+
export function checkRunId(check) {
|
|
201
|
+
return check.link.match(/\/actions\/runs\/(\d+)\/job\/\d+/)?.[1];
|
|
202
|
+
}
|
|
203
|
+
export async function rerunCheckJob(exec, repository, jobId) {
|
|
204
|
+
await exec(`gh run rerun --repo ${shellQuote(repoSpecifier(repository))} --job ${shellQuote(jobId)}`);
|
|
205
|
+
}
|
|
206
|
+
export async function watchRun(exec, repository, runId) {
|
|
207
|
+
await exec(`gh run watch ${shellQuote(runId)} --repo ${shellQuote(repoSpecifier(repository))} --exit-status`);
|
|
208
|
+
}
|
|
209
|
+
export async function fetchCheckFailureLog(exec, repository, jobId) {
|
|
210
|
+
return exec(`gh run view --repo ${shellQuote(repoSpecifier(repository))} --job ${shellQuote(jobId)} --log-failed`);
|
|
211
|
+
}
|
|
212
|
+
export async function fetchMergeQueueRequirement(exec, repository, branch) {
|
|
213
|
+
const raw = await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/rules/branches/${shellQuote(branch)} -H ${shellQuote("Accept: application/vnd.github+json")}`);
|
|
214
|
+
const rules = JSON.parse(raw);
|
|
215
|
+
return rules.some((rule) => rule.type === "merge_queue");
|
|
216
|
+
}
|
|
217
|
+
export async function createWorktree(exec, repository, pr, root) {
|
|
218
|
+
const worktreePath = join(root, `pr-${pr}`);
|
|
219
|
+
const lockKey = `${repoSpecifier(repository)}:${root}`;
|
|
220
|
+
return withWorktreeCreateLock(lockKey, async () => {
|
|
221
|
+
let worktreeAdded = false;
|
|
222
|
+
try {
|
|
223
|
+
await mkdir(dirname(worktreePath), { recursive: true });
|
|
224
|
+
await exec(`git worktree add --detach ${shellQuote(worktreePath)}`);
|
|
225
|
+
worktreeAdded = true;
|
|
226
|
+
await checkoutPullRequestWithRetry(exec, repository, pr, worktreePath);
|
|
227
|
+
const branch = (await exec("git branch --show-current", { cwd: worktreePath })).trim();
|
|
228
|
+
return { branch: branch || undefined, path: worktreePath };
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (worktreeAdded) {
|
|
232
|
+
await removeWorktree(exec, worktreePath).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
export async function removeWorktree(exec, worktreePath) {
|
|
239
|
+
await exec(`git worktree remove --force ${shellQuote(worktreePath)}`);
|
|
240
|
+
await exec("git worktree prune");
|
|
241
|
+
}
|
|
242
|
+
export async function removeBranch(exec, branch) {
|
|
243
|
+
await exec(`git branch -D ${shellQuote(branch)}`);
|
|
244
|
+
}
|
|
245
|
+
export async function postApproval(exec, repository, pr, account) {
|
|
246
|
+
const token = await ghToken(exec, repository, account);
|
|
247
|
+
return exec(`GH_TOKEN=${shellQuote(token)} gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`);
|
|
248
|
+
}
|
|
249
|
+
export async function postCloseComment(exec, repository, pr, account, body) {
|
|
250
|
+
const token = await ghToken(exec, repository, account);
|
|
251
|
+
const payloadPath = join(tmpdir(), `magi-close-${process.pid}-${Date.now()}.json`);
|
|
252
|
+
await writeFile(payloadPath, JSON.stringify({ body, event: "COMMENT" }));
|
|
253
|
+
try {
|
|
254
|
+
return await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await rm(payloadPath, { force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function findingComment(finding) {
|
|
261
|
+
const comment = {
|
|
262
|
+
body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
|
|
263
|
+
line: finding.line,
|
|
264
|
+
path: finding.path,
|
|
265
|
+
side: "RIGHT",
|
|
266
|
+
};
|
|
267
|
+
if (finding.startLine != null) {
|
|
268
|
+
comment.start_line = finding.startLine;
|
|
269
|
+
comment.start_side = "RIGHT";
|
|
270
|
+
}
|
|
271
|
+
return comment;
|
|
272
|
+
}
|
|
273
|
+
export async function postChangesRequested(exec, repository, pr, account, findings) {
|
|
274
|
+
const token = await ghToken(exec, repository, account);
|
|
275
|
+
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
276
|
+
const body = findings
|
|
277
|
+
.map((finding) => `- ${finding.issue.split("\n")[0]}`)
|
|
278
|
+
.join("\n");
|
|
279
|
+
await writeFile(payloadPath, JSON.stringify({
|
|
280
|
+
body,
|
|
281
|
+
comments: findings.map(findingComment),
|
|
282
|
+
event: "REQUEST_CHANGES",
|
|
283
|
+
}));
|
|
284
|
+
try {
|
|
285
|
+
return await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await rm(payloadPath, { force: true });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export async function mergePullRequest(exec, repository, pr, account) {
|
|
292
|
+
const token = await ghToken(exec, repository, account);
|
|
293
|
+
const methodFlag = repository.merge.method === "merge"
|
|
294
|
+
? "--merge"
|
|
295
|
+
: repository.merge.method === "rebase"
|
|
296
|
+
? "--rebase"
|
|
297
|
+
: "--squash";
|
|
298
|
+
const autoFlag = repository.merge.auto ? " --auto" : "";
|
|
299
|
+
const deleteFlag = repository.merge.deleteBranch ? " --delete-branch" : "";
|
|
300
|
+
return exec(`GH_TOKEN=${shellQuote(token)} gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`);
|
|
301
|
+
}
|
|
302
|
+
export async function fetchPullRequestMergeStatus(exec, repository, pr) {
|
|
303
|
+
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json state,mergeStateStatus,autoMergeRequest`);
|
|
304
|
+
return JSON.parse(json);
|
|
305
|
+
}
|
|
306
|
+
export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_000) {
|
|
307
|
+
for (;;) {
|
|
308
|
+
const status = await fetchPullRequestMergeStatus(exec, repository, pr);
|
|
309
|
+
if (status.state === "MERGED")
|
|
310
|
+
return "merged";
|
|
311
|
+
if (status.state === "OPEN" && status.autoMergeRequest == null) {
|
|
312
|
+
return "dequeued";
|
|
313
|
+
}
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export async function closePullRequest(exec, repository, pr, account) {
|
|
318
|
+
const token = await ghToken(exec, repository, account);
|
|
319
|
+
return exec(`GH_TOKEN=${shellQuote(token)} gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`);
|
|
320
|
+
}
|
|
321
|
+
export async function pushHead(exec, repository, worktreePath, account) {
|
|
322
|
+
const token = await ghToken(exec, repository, account);
|
|
323
|
+
await exec(`git -c credential.helper= -c credential.helper=${shellQuote(`!f() { echo username=x-access-token; echo password=${token}; }; f`)} push origin HEAD`, { cwd: worktreePath });
|
|
324
|
+
}
|
|
325
|
+
export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
326
|
+
if (identity.name) {
|
|
327
|
+
await exec(`git config --worktree user.name ${shellQuote(identity.name)}`, {
|
|
328
|
+
cwd: worktreePath,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (identity.email) {
|
|
332
|
+
await exec(`git config --worktree user.email ${shellQuote(identity.email)}`, {
|
|
333
|
+
cwd: worktreePath,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
338
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { id isResolved comments(first: 50) { nodes { databaseId author { login } path line body createdAt } } } } } } }`;
|
|
339
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}`);
|
|
340
|
+
const data = JSON.parse(raw);
|
|
341
|
+
const threads = data.data.repository.pullRequest.reviewThreads
|
|
342
|
+
.nodes;
|
|
343
|
+
return threads.flatMap((thread) => {
|
|
344
|
+
if (thread.isResolved || !thread.comments.nodes.length)
|
|
345
|
+
return [];
|
|
346
|
+
const comments = [...thread.comments.nodes]
|
|
347
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
348
|
+
.map((comment) => ({
|
|
349
|
+
author: comment.author.login,
|
|
350
|
+
body: comment.body,
|
|
351
|
+
commentId: comment.databaseId,
|
|
352
|
+
createdAt: comment.createdAt,
|
|
353
|
+
}));
|
|
354
|
+
const first = thread.comments.nodes[0];
|
|
355
|
+
if (!author)
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
body: first.body,
|
|
359
|
+
commentId: first.databaseId,
|
|
360
|
+
comments,
|
|
361
|
+
line: first.line,
|
|
362
|
+
path: first.path,
|
|
363
|
+
threadId: thread.id,
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
if (first.author.login !== author)
|
|
367
|
+
return [];
|
|
368
|
+
const authored = thread.comments.nodes.filter((comment) => comment.author.login === author);
|
|
369
|
+
const latest = authored.sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1) ??
|
|
370
|
+
first;
|
|
371
|
+
return [
|
|
372
|
+
{
|
|
373
|
+
commentId: first.databaseId,
|
|
374
|
+
comments,
|
|
375
|
+
latestBody: latest.body,
|
|
376
|
+
line: first.line,
|
|
377
|
+
path: first.path,
|
|
378
|
+
threadId: thread.id,
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
export async function postReply(exec, repository, pr, account, commentId, body) {
|
|
384
|
+
const token = await ghToken(exec, repository, account);
|
|
385
|
+
const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
|
|
386
|
+
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
387
|
+
try {
|
|
388
|
+
return await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/comments/${commentId}/replies --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await rm(payloadPath, { force: true });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
export async function resolveThread(exec, repository, account, threadId) {
|
|
395
|
+
const token = await ghToken(exec, repository, account);
|
|
396
|
+
const query = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id } } }`;
|
|
397
|
+
await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`);
|
|
398
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function errorText(error) {
|
|
2
|
+
if (!error || typeof error !== "object")
|
|
3
|
+
return String(error);
|
|
4
|
+
const value = error;
|
|
5
|
+
return [value.message, value.stderr, value.stdout]
|
|
6
|
+
.filter((item) => typeof item === "string")
|
|
7
|
+
.join("\n");
|
|
8
|
+
}
|
|
9
|
+
function isGitHubCommand(command) {
|
|
10
|
+
return /(^|\s)gh\s+(api|auth|pr|run)\b/.test(command);
|
|
11
|
+
}
|
|
12
|
+
function isRateLimitError(error) {
|
|
13
|
+
return /rate limit/i.test(errorText(error));
|
|
14
|
+
}
|
|
15
|
+
async function delay(ms, signal) {
|
|
16
|
+
if (ms <= 0)
|
|
17
|
+
return;
|
|
18
|
+
if (signal?.aborted)
|
|
19
|
+
throw new DOMException("Aborted", "AbortError");
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const timeout = setTimeout(resolve, ms);
|
|
22
|
+
signal?.addEventListener("abort", () => {
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
25
|
+
}, { once: true });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function withGitHubApiRetry(exec, retryAttempts, delayMs = 1_000) {
|
|
29
|
+
return async (command, options) => {
|
|
30
|
+
for (let attempt = 0;; attempt += 1) {
|
|
31
|
+
try {
|
|
32
|
+
return await exec(command, options);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (attempt >= retryAttempts ||
|
|
36
|
+
!isGitHubCommand(command) ||
|
|
37
|
+
!isRateLimitError(error)) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
await delay(delayMs * 2 ** attempt, options?.signal);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|