opencode-magi 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +254 -0
- package/README.md +31 -26
- package/dist/commands.js +11 -9
- package/dist/config/index.js +2 -0
- package/dist/config/resolve.js +120 -246
- package/dist/config/validate.js +103 -1110
- package/dist/constant.js +135 -0
- package/dist/graphql/index.generated.js +125 -0
- package/dist/graphql/index.js +49 -0
- package/dist/index.js +12 -800
- package/dist/magi.js +357 -0
- package/dist/permissions/common.json +22 -22
- package/dist/permissions/editor.json +12 -12
- package/dist/prompts/index.js +98 -0
- package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
- package/dist/prompts/merge/ci-classification/task.md +10 -0
- package/dist/prompts/merge/ci-classification/validate.json +20 -0
- package/dist/prompts/merge/conflict/output-contract.md +12 -0
- package/dist/prompts/merge/conflict/task.md +9 -0
- package/dist/prompts/merge/conflict/validate.json +4 -0
- package/dist/prompts/merge/edit/output-contract.md +26 -0
- package/dist/prompts/merge/edit/task.md +10 -0
- package/dist/prompts/merge/edit/validate.json +60 -0
- package/dist/prompts/review/ci-classification/output-contract.md +21 -0
- package/dist/prompts/review/ci-classification/task.md +7 -0
- package/dist/prompts/review/ci-classification/validate.json +20 -0
- package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
- package/dist/prompts/review/close-reconsideration/task.md +6 -0
- package/dist/prompts/review/close-reconsideration/validate.json +44 -0
- package/dist/prompts/review/comment/output-contract.md +11 -0
- package/dist/prompts/review/comment/task.md +3 -0
- package/dist/prompts/review/comment/validate.json +8 -0
- package/dist/prompts/review/finding-validation/output-contract.md +25 -0
- package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
- package/dist/prompts/review/finding-validation/validate.json +21 -0
- package/dist/prompts/review/rereview/output-contract.md +30 -0
- package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
- package/dist/prompts/review/rereview/validate.json +87 -0
- package/dist/prompts/review/review/output-contract.md +25 -0
- package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
- package/dist/prompts/review/review/validate.json +54 -0
- package/dist/prompts/triage/acceptance/output-contract.md +16 -0
- package/dist/prompts/triage/acceptance/validate.json +23 -0
- package/dist/prompts/triage/category/output-contract.md +16 -0
- package/dist/prompts/triage/category/validate.json +23 -0
- package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
- package/dist/prompts/triage/comment-classification/validate.json +28 -0
- package/dist/prompts/triage/create/output-contract.md +28 -0
- package/dist/prompts/triage/create/validate.json +73 -0
- package/dist/prompts/triage/deplicate/output-contract.md +16 -0
- package/dist/prompts/triage/deplicate/validate.json +20 -0
- package/dist/prompts/triage/existing/output-contract.md +16 -0
- package/dist/prompts/triage/existing/validate.json +13 -0
- package/dist/prompts/triage/reconsider/output-contract.md +16 -0
- package/dist/prompts/triage/reconsider/validate.json +23 -0
- package/dist/prompts/triage/signal/output-contract.md +19 -0
- package/dist/prompts/triage/signal/validate.json +18 -0
- package/dist/tools/clear/index.js +21 -0
- package/dist/tools/index.js +12 -0
- package/dist/tools/merge/action.js +47 -0
- package/dist/tools/merge/context.js +118 -0
- package/dist/tools/merge/editor.js +264 -0
- package/dist/tools/merge/index.js +151 -0
- package/dist/tools/merge/index.type.js +1 -0
- package/dist/tools/merge/merge.js +42 -0
- package/dist/tools/merge/report.js +73 -0
- package/dist/tools/review/action.js +429 -0
- package/dist/tools/review/check.js +295 -0
- package/dist/tools/review/context.js +258 -0
- package/dist/tools/review/index.js +104 -0
- package/dist/tools/review/index.type.js +1 -0
- package/dist/tools/review/report.js +143 -0
- package/dist/tools/review/review.js +145 -0
- package/dist/tools/review/reviewer.js +409 -0
- package/dist/tools/triage/index.js +16 -0
- package/dist/tools/validate/index.js +28 -0
- package/dist/utils/array.js +6 -0
- package/dist/utils/assertion.js +38 -0
- package/dist/utils/exec.js +14 -0
- package/dist/utils/fs.js +24 -0
- package/dist/utils/function.js +37 -0
- package/dist/utils/github.js +63 -0
- package/dist/utils/index.js +10 -0
- package/dist/utils/index.type.js +1 -0
- package/dist/utils/object.js +21 -0
- package/dist/utils/opencode.js +25 -0
- package/dist/utils/string.js +38 -0
- package/dist/utils/worker.js +30 -0
- package/package.json +20 -8
- package/schema.json +6 -4
- package/dist/config/load.js +0 -62
- package/dist/config/output.js +0 -25
- package/dist/config/worktree.js +0 -25
- package/dist/github/commands.js +0 -905
- package/dist/github/retry.js +0 -44
- package/dist/orchestrator/abort.js +0 -9
- package/dist/orchestrator/ci.js +0 -579
- package/dist/orchestrator/findings.js +0 -88
- package/dist/orchestrator/inline-comments.js +0 -73
- package/dist/orchestrator/majority.js +0 -62
- package/dist/orchestrator/merge.js +0 -1260
- package/dist/orchestrator/model.js +0 -216
- package/dist/orchestrator/pool.js +0 -15
- package/dist/orchestrator/report.js +0 -175
- package/dist/orchestrator/review-context.js +0 -342
- package/dist/orchestrator/review.js +0 -1480
- package/dist/orchestrator/run-manager.js +0 -2132
- package/dist/orchestrator/safety.js +0 -44
- package/dist/orchestrator/triage.js +0 -1370
- package/dist/prompts/compose.js +0 -473
- package/dist/prompts/contracts.js +0 -300
- package/dist/prompts/output.js +0 -401
- package/dist/prompts/templates/merge/ci-classification.md +0 -16
- package/dist/prompts/templates/merge/conflict.md +0 -10
- package/dist/prompts/templates/merge/edit.md +0 -15
- package/dist/prompts/templates/review/ci-classification.md +0 -9
- package/dist/prompts/templates/review/close-reconsideration.md +0 -6
- /package/dist/{types.js → config/index.type.js} +0 -0
- /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
- /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
- /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
- /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
- /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
- /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
- /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
- /package/dist/prompts/{templates/triage/signal.md → triage/signal/task.md} +0 -0
package/dist/github/commands.js
DELETED
|
@@ -1,905 +0,0 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
function normalizeRelatedPullRequestState(state) {
|
|
5
|
-
const normalized = state?.toUpperCase();
|
|
6
|
-
if (normalized === "MERGED")
|
|
7
|
-
return "MERGED";
|
|
8
|
-
if (normalized === "CLOSED")
|
|
9
|
-
return "CLOSED";
|
|
10
|
-
return "OPEN";
|
|
11
|
-
}
|
|
12
|
-
const WORKTREE_CHECKOUT_RETRY_ATTEMPTS = 5;
|
|
13
|
-
const WORKTREE_CHECKOUT_RETRY_DELAY_MS = 100;
|
|
14
|
-
const worktreeCreateLocks = new Map();
|
|
15
|
-
function errorText(error) {
|
|
16
|
-
if (!error || typeof error !== "object")
|
|
17
|
-
return String(error);
|
|
18
|
-
const value = error;
|
|
19
|
-
return [value.message, value.stderr, value.stdout]
|
|
20
|
-
.filter((item) => typeof item === "string")
|
|
21
|
-
.join("\n");
|
|
22
|
-
}
|
|
23
|
-
function isIssueTypeUnavailableText(text) {
|
|
24
|
-
return (/cannot query field ["']?issueType["']? on type ["']?Issue["']?/i.test(text) ||
|
|
25
|
-
/field ["']?issueType["']?.*(does not exist|doesn't exist|is not defined|not found).*type ["']?Issue["']?/i.test(text) ||
|
|
26
|
-
/undefinedField.*issueType/i.test(text) ||
|
|
27
|
-
/issueType.*unsupported field|unsupported field.*issueType/i.test(text));
|
|
28
|
-
}
|
|
29
|
-
function isIssueTypeUnavailableError(error) {
|
|
30
|
-
return isIssueTypeUnavailableText(errorText(error));
|
|
31
|
-
}
|
|
32
|
-
function isIssueTypeUnavailableGraphqlResponse(data) {
|
|
33
|
-
return (data.errors?.some((error) => isIssueTypeUnavailableText([error.message, error.type]
|
|
34
|
-
.filter((item) => typeof item === "string")
|
|
35
|
-
.join("\n"))) ?? false);
|
|
36
|
-
}
|
|
37
|
-
async function localCommitExists(exec, worktreePath, sha) {
|
|
38
|
-
try {
|
|
39
|
-
await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
|
|
40
|
-
cwd: worktreePath,
|
|
41
|
-
});
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function pullRequestCommitSource(input) {
|
|
49
|
-
if (input.source === "base") {
|
|
50
|
-
return {
|
|
51
|
-
owner: input.repository.github.owner,
|
|
52
|
-
refName: input.meta.baseRefName,
|
|
53
|
-
repo: input.repository.github.repo,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
owner: input.meta.headRepositoryOwner?.login ?? input.repository.github.owner,
|
|
58
|
-
refName: input.meta.headRefName,
|
|
59
|
-
repo: input.meta.headRepository?.name ?? input.repository.github.repo,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
async function fetchPullRequestCommitSource(input) {
|
|
63
|
-
const commitSource = pullRequestCommitSource(input);
|
|
64
|
-
try {
|
|
65
|
-
await input.exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(input.repository, commitSource.owner, commitSource.repo))} ${shellQuote(`refs/heads/${commitSource.refName}`)}`, { cwd: input.worktreePath });
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
throw new Error(`Could not fetch ${input.source} ref ${commitSource.refName} for #${input.meta.number}: ${errorText(error)}`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
export async function ensurePullRequestCommits(input) {
|
|
72
|
-
const missing = [];
|
|
73
|
-
for (const commit of input.commits) {
|
|
74
|
-
if (!(await localCommitExists(input.exec, input.worktreePath, commit.sha))) {
|
|
75
|
-
missing.push(commit);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
for (const source of new Set(missing.map((commit) => commit.source))) {
|
|
79
|
-
await fetchPullRequestCommitSource({
|
|
80
|
-
exec: input.exec,
|
|
81
|
-
meta: input.meta,
|
|
82
|
-
repository: input.repository,
|
|
83
|
-
source,
|
|
84
|
-
worktreePath: input.worktreePath,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
for (const commit of missing) {
|
|
88
|
-
if (await localCommitExists(input.exec, input.worktreePath, commit.sha)) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const source = pullRequestCommitSource({
|
|
92
|
-
meta: input.meta,
|
|
93
|
-
repository: input.repository,
|
|
94
|
-
source: commit.source,
|
|
95
|
-
});
|
|
96
|
-
throw new Error(`${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${source.refName}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function isCheckoutConfigLockError(error) {
|
|
100
|
-
const text = errorText(error);
|
|
101
|
-
return (/could not lock config file/i.test(text) ||
|
|
102
|
-
/Unable to write upstream branch configuration/i.test(text));
|
|
103
|
-
}
|
|
104
|
-
async function delay(ms) {
|
|
105
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
|
-
}
|
|
107
|
-
async function withWorktreeCreateLock(key, run) {
|
|
108
|
-
const previous = worktreeCreateLocks.get(key) ?? Promise.resolve();
|
|
109
|
-
let release;
|
|
110
|
-
const current = new Promise((resolve) => {
|
|
111
|
-
release = resolve;
|
|
112
|
-
});
|
|
113
|
-
const tail = previous.catch(() => undefined).then(() => current);
|
|
114
|
-
worktreeCreateLocks.set(key, tail);
|
|
115
|
-
await previous.catch(() => undefined);
|
|
116
|
-
try {
|
|
117
|
-
return await run();
|
|
118
|
-
}
|
|
119
|
-
finally {
|
|
120
|
-
release();
|
|
121
|
-
if (worktreeCreateLocks.get(key) === tail)
|
|
122
|
-
worktreeCreateLocks.delete(key);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
async function checkoutPullRequestWithRetry(exec, repository, pr, worktreePath) {
|
|
126
|
-
for (let attempt = 0;; attempt += 1) {
|
|
127
|
-
try {
|
|
128
|
-
await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))} --detach`, {
|
|
129
|
-
cwd: worktreePath,
|
|
130
|
-
});
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
if (attempt >= WORKTREE_CHECKOUT_RETRY_ATTEMPTS - 1 ||
|
|
135
|
-
!isCheckoutConfigLockError(error)) {
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
await delay(WORKTREE_CHECKOUT_RETRY_DELAY_MS * 2 ** attempt);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
export function shellQuote(value) {
|
|
143
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
144
|
-
}
|
|
145
|
-
export function repoSlug(repository) {
|
|
146
|
-
return `${repository.github.owner}/${repository.github.repo}`;
|
|
147
|
-
}
|
|
148
|
-
function githubHost(repository) {
|
|
149
|
-
return repository.github.host || "github.com";
|
|
150
|
-
}
|
|
151
|
-
export function repoSpecifier(repository) {
|
|
152
|
-
const host = githubHost(repository);
|
|
153
|
-
return host === "github.com"
|
|
154
|
-
? repoSlug(repository)
|
|
155
|
-
: `${host}/${repoSlug(repository)}`;
|
|
156
|
-
}
|
|
157
|
-
function repositoryGitUrl(repository, owner, repo) {
|
|
158
|
-
return `https://${githubHost(repository)}/${owner}/${repo}.git`;
|
|
159
|
-
}
|
|
160
|
-
export function ghHostOption(repository) {
|
|
161
|
-
const host = githubHost(repository);
|
|
162
|
-
return host === "github.com" ? "" : ` --hostname ${shellQuote(host)}`;
|
|
163
|
-
}
|
|
164
|
-
export async function ghToken(exec, repository, account) {
|
|
165
|
-
return (await exec(`gh auth token${ghHostOption(repository)} --user ${shellQuote(account)}`)).trim();
|
|
166
|
-
}
|
|
167
|
-
function ghTokenEnv(token) {
|
|
168
|
-
return { env: { GH_TOKEN: token } };
|
|
169
|
-
}
|
|
170
|
-
async function fetchPullRequestQueueInput(exec, repository, pr, token) {
|
|
171
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { id headRefOid } } }`;
|
|
172
|
-
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}`, ghTokenEnv(token));
|
|
173
|
-
const data = JSON.parse(raw);
|
|
174
|
-
const pullRequest = data.data?.repository?.pullRequest;
|
|
175
|
-
if (!pullRequest?.id || !pullRequest.headRefOid) {
|
|
176
|
-
throw new Error(`Could not fetch pull request queue metadata for #${pr}`);
|
|
177
|
-
}
|
|
178
|
-
return { headRefOid: pullRequest.headRefOid, id: pullRequest.id };
|
|
179
|
-
}
|
|
180
|
-
export async function fetchPullRequest(exec, repository, pr) {
|
|
181
|
-
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles`);
|
|
182
|
-
return JSON.parse(json);
|
|
183
|
-
}
|
|
184
|
-
export async function fetchPullRequestClosingIssues(exec, repository, pr) {
|
|
185
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { closingIssuesReferences(first: 20) { nodes { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } } } }`;
|
|
186
|
-
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}`);
|
|
187
|
-
const data = JSON.parse(raw);
|
|
188
|
-
return (data.data?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue) => ({
|
|
189
|
-
author: issue.author?.login ?? "",
|
|
190
|
-
body: issue.body ?? "",
|
|
191
|
-
labels: issue.labels?.nodes?.map((label) => label.name) ?? [],
|
|
192
|
-
number: issue.number,
|
|
193
|
-
state: issue.state,
|
|
194
|
-
title: issue.title,
|
|
195
|
-
type: issue.issueType?.name,
|
|
196
|
-
url: issue.url,
|
|
197
|
-
})) ?? []);
|
|
198
|
-
}
|
|
199
|
-
export async function fetchIssue(exec, repository, issue) {
|
|
200
|
-
const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } }`;
|
|
201
|
-
let raw;
|
|
202
|
-
try {
|
|
203
|
-
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 issue=${issue}`);
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
if (isIssueTypeUnavailableError(error)) {
|
|
207
|
-
return fetchIssueWithCli(exec, repository, issue);
|
|
208
|
-
}
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
const data = JSON.parse(raw);
|
|
212
|
-
const graphqlIssue = data.data?.repository?.issue;
|
|
213
|
-
if (!graphqlIssue) {
|
|
214
|
-
if (isIssueTypeUnavailableGraphqlResponse(data)) {
|
|
215
|
-
return fetchIssueWithCli(exec, repository, issue);
|
|
216
|
-
}
|
|
217
|
-
throw new Error(`Could not fetch issue #${issue}`);
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
author: graphqlIssue.author?.login ?? "",
|
|
221
|
-
body: graphqlIssue.body ?? "",
|
|
222
|
-
labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
|
|
223
|
-
number: graphqlIssue.number,
|
|
224
|
-
state: graphqlIssue.state,
|
|
225
|
-
title: graphqlIssue.title,
|
|
226
|
-
type: graphqlIssue.issueType?.name,
|
|
227
|
-
url: graphqlIssue.url,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
async function fetchIssueWithCli(exec, repository, issue) {
|
|
231
|
-
const raw = await exec(`gh issue view ${issue} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,labels`);
|
|
232
|
-
const data = JSON.parse(raw);
|
|
233
|
-
return {
|
|
234
|
-
author: data.author?.login ?? "",
|
|
235
|
-
body: data.body ?? "",
|
|
236
|
-
labels: data.labels?.map((label) => label.name) ?? [],
|
|
237
|
-
number: data.number,
|
|
238
|
-
state: data.state,
|
|
239
|
-
title: data.title,
|
|
240
|
-
url: data.url,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
export async function fetchIssueComments(exec, repository, issue, limit = 50) {
|
|
244
|
-
return (await fetchIssueCommentPage(exec, repository, issue, limit)).comments;
|
|
245
|
-
}
|
|
246
|
-
export async function fetchIssueCommentPage(exec, repository, issue, limit = 50) {
|
|
247
|
-
const query = `query($owner: String!, $repo: String!, $issue: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { comments(last: $limit) { totalCount nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
|
|
248
|
-
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 issue=${issue} -F limit=${limit}`);
|
|
249
|
-
const data = JSON.parse(raw);
|
|
250
|
-
const connection = data.data?.repository?.issue?.comments;
|
|
251
|
-
const comments = connection?.nodes?.map((comment) => ({
|
|
252
|
-
author: comment.author?.login ?? "",
|
|
253
|
-
authorAssociation: comment.authorAssociation,
|
|
254
|
-
body: comment.body ?? "",
|
|
255
|
-
createdAt: comment.createdAt,
|
|
256
|
-
id: comment.databaseId,
|
|
257
|
-
url: comment.url,
|
|
258
|
-
})) ?? [];
|
|
259
|
-
return {
|
|
260
|
-
comments,
|
|
261
|
-
omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
export async function fetchPullRequestComments(exec, repository, pr, limit = 50) {
|
|
265
|
-
return (await fetchPullRequestCommentPage(exec, repository, pr, limit))
|
|
266
|
-
.comments;
|
|
267
|
-
}
|
|
268
|
-
export async function fetchPullRequestCommentPage(exec, repository, pr, limit = 50) {
|
|
269
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { comments(last: $limit) { totalCount nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
|
|
270
|
-
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} -F limit=${limit}`);
|
|
271
|
-
const data = JSON.parse(raw);
|
|
272
|
-
const connection = data.data?.repository?.pullRequest?.comments;
|
|
273
|
-
const comments = connection?.nodes?.map((comment) => ({
|
|
274
|
-
author: comment.author?.login ?? "",
|
|
275
|
-
authorAssociation: comment.authorAssociation,
|
|
276
|
-
body: comment.body ?? "",
|
|
277
|
-
createdAt: comment.createdAt,
|
|
278
|
-
id: comment.databaseId,
|
|
279
|
-
url: comment.url,
|
|
280
|
-
})) ?? [];
|
|
281
|
-
return {
|
|
282
|
-
comments,
|
|
283
|
-
omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
export async function fetchRelatedPullRequests(exec, repository, issue) {
|
|
287
|
-
const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { timelineItems(first: 50, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) { nodes { __typename ... on ConnectedEvent { subject { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } } } } } }`;
|
|
288
|
-
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 issue=${issue}`);
|
|
289
|
-
const data = JSON.parse(raw);
|
|
290
|
-
const prs = new Map();
|
|
291
|
-
for (const node of data.data?.repository?.issue?.timelineItems?.nodes ?? []) {
|
|
292
|
-
const source = (node.subject ?? node.source);
|
|
293
|
-
if (!source?.number || !source.url)
|
|
294
|
-
continue;
|
|
295
|
-
const state = source.mergedAt
|
|
296
|
-
? "MERGED"
|
|
297
|
-
: normalizeRelatedPullRequestState(source.state);
|
|
298
|
-
prs.set(source.number, {
|
|
299
|
-
author: source.author?.login ?? "",
|
|
300
|
-
body: source.body,
|
|
301
|
-
mergedAt: source.mergedAt,
|
|
302
|
-
number: source.number,
|
|
303
|
-
state,
|
|
304
|
-
title: source.title ?? `PR #${source.number}`,
|
|
305
|
-
url: source.url,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
const searchQuery = `repo:${repoSlug(repository)} is:pr ${issue}`;
|
|
309
|
-
const searchRaw = await exec(`gh search prs ${shellQuote(searchQuery)} --json number,title,url,state,body,author --limit 10`).catch(() => "[]");
|
|
310
|
-
const searchData = JSON.parse(searchRaw);
|
|
311
|
-
const closingReference = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, "i");
|
|
312
|
-
for (const item of searchData) {
|
|
313
|
-
if (!closingReference.test(item.body ?? ""))
|
|
314
|
-
continue;
|
|
315
|
-
prs.set(item.number, {
|
|
316
|
-
author: item.author?.login ?? "",
|
|
317
|
-
body: item.body,
|
|
318
|
-
number: item.number,
|
|
319
|
-
state: normalizeRelatedPullRequestState(item.state),
|
|
320
|
-
title: item.title,
|
|
321
|
-
url: item.url,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
return [...prs.values()];
|
|
325
|
-
}
|
|
326
|
-
function duplicateReferences(text) {
|
|
327
|
-
const refs = new Set();
|
|
328
|
-
const pattern = /duplicate(?:s)?\s+(?:of\s+)?#(\d+)/gi;
|
|
329
|
-
for (const match of text.matchAll(pattern))
|
|
330
|
-
refs.add(Number(match[1]));
|
|
331
|
-
return [...refs];
|
|
332
|
-
}
|
|
333
|
-
function issueTitleSearchQuery(title, fallback) {
|
|
334
|
-
return (title
|
|
335
|
-
.replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
|
|
336
|
-
.replaceAll(/\s+/g, " ")
|
|
337
|
-
.trim() || fallback);
|
|
338
|
-
}
|
|
339
|
-
async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
340
|
-
const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
|
|
341
|
-
if (!raw)
|
|
342
|
-
return undefined;
|
|
343
|
-
const data = JSON.parse(raw);
|
|
344
|
-
return { ...data, whyCandidate };
|
|
345
|
-
}
|
|
346
|
-
export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
|
|
347
|
-
const query = issueTitleSearchQuery(issue.title, String(issue.number));
|
|
348
|
-
const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
|
|
349
|
-
.filter((number) => number !== issue.number)
|
|
350
|
-
.map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
|
|
351
|
-
const raw = await exec(`gh search issues --repo ${shellQuote(repoSlug(repository))} --json number,title,url,state,body --limit ${limit} -- ${shellQuote(query)}`);
|
|
352
|
-
const data = JSON.parse(raw);
|
|
353
|
-
const candidates = new Map();
|
|
354
|
-
for (const candidate of explicitCandidates) {
|
|
355
|
-
if (candidate)
|
|
356
|
-
candidates.set(candidate.number, candidate);
|
|
357
|
-
}
|
|
358
|
-
for (const item of data
|
|
359
|
-
.filter((item) => item.number !== issue.number)
|
|
360
|
-
.map((item) => ({
|
|
361
|
-
...item,
|
|
362
|
-
whyCandidate: "GitHub issue search matched the title.",
|
|
363
|
-
}))) {
|
|
364
|
-
if (!candidates.has(item.number))
|
|
365
|
-
candidates.set(item.number, item);
|
|
366
|
-
}
|
|
367
|
-
return [...candidates.values()].slice(0, limit);
|
|
368
|
-
}
|
|
369
|
-
export async function postIssueComment(exec, repository, issue, account, body) {
|
|
370
|
-
const token = await ghToken(exec, repository, account);
|
|
371
|
-
const payloadPath = join(tmpdir(), `magi-issue-${process.pid}-${Date.now()}.json`);
|
|
372
|
-
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
373
|
-
try {
|
|
374
|
-
const raw = await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/issues/${issue}/comments --method POST --input ${shellQuote(payloadPath)} --jq '{id: .id, url: .html_url}'`, ghTokenEnv(token));
|
|
375
|
-
const data = JSON.parse(raw);
|
|
376
|
-
if (!data.id || !data.url)
|
|
377
|
-
throw new Error("GitHub issue comment response did not include id and url");
|
|
378
|
-
return { id: data.id, url: data.url };
|
|
379
|
-
}
|
|
380
|
-
finally {
|
|
381
|
-
await rm(payloadPath, { force: true });
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
export async function updateIssueComment(exec, repository, commentId, account, body) {
|
|
385
|
-
const token = await ghToken(exec, repository, account);
|
|
386
|
-
const payloadPath = join(tmpdir(), `magi-issue-comment-${process.pid}-${Date.now()}.json`);
|
|
387
|
-
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
388
|
-
try {
|
|
389
|
-
const raw = await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/issues/comments/${commentId} --method PATCH --input ${shellQuote(payloadPath)} --jq '{id: .id, url: .html_url}'`, ghTokenEnv(token));
|
|
390
|
-
const data = JSON.parse(raw);
|
|
391
|
-
if (!data.id || !data.url)
|
|
392
|
-
throw new Error("GitHub issue comment response did not include id and url");
|
|
393
|
-
return { id: data.id, url: data.url };
|
|
394
|
-
}
|
|
395
|
-
finally {
|
|
396
|
-
await rm(payloadPath, { force: true });
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
export async function closeIssue(exec, repository, issue, account) {
|
|
400
|
-
const token = await ghToken(exec, repository, account);
|
|
401
|
-
return exec(`gh issue close ${issue} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
402
|
-
}
|
|
403
|
-
export async function assignIssue(exec, repository, issue, account) {
|
|
404
|
-
const token = await ghToken(exec, repository, account);
|
|
405
|
-
return exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-assignee ${shellQuote(account)}`, ghTokenEnv(token));
|
|
406
|
-
}
|
|
407
|
-
export async function addIssueLabels(exec, repository, issue, labels, account) {
|
|
408
|
-
const token = await ghToken(exec, repository, account);
|
|
409
|
-
const added = [];
|
|
410
|
-
for (const label of labels) {
|
|
411
|
-
await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-label ${shellQuote(label)}`, ghTokenEnv(token));
|
|
412
|
-
added.push(label);
|
|
413
|
-
}
|
|
414
|
-
return added;
|
|
415
|
-
}
|
|
416
|
-
export async function removeIssueLabels(exec, repository, issue, labels, account) {
|
|
417
|
-
const token = await ghToken(exec, repository, account);
|
|
418
|
-
const removed = [];
|
|
419
|
-
for (const label of labels) {
|
|
420
|
-
await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --remove-label ${shellQuote(label)}`, ghTokenEnv(token));
|
|
421
|
-
removed.push(label);
|
|
422
|
-
}
|
|
423
|
-
return removed;
|
|
424
|
-
}
|
|
425
|
-
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
426
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100, after: $cursor) { nodes { author { login } submittedAt state body commit { oid } comments(first: 100) { nodes { body path line startLine } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
427
|
-
const reviews = [];
|
|
428
|
-
let cursor;
|
|
429
|
-
for (;;) {
|
|
430
|
-
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
431
|
-
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}`);
|
|
432
|
-
const data = JSON.parse(raw);
|
|
433
|
-
const connection = data.data.repository.pullRequest.reviews;
|
|
434
|
-
reviews.push(...connection.nodes);
|
|
435
|
-
if (!connection.pageInfo?.hasNextPage)
|
|
436
|
-
break;
|
|
437
|
-
cursor = connection.pageInfo.endCursor;
|
|
438
|
-
if (!cursor)
|
|
439
|
-
throw new Error("GitHub reviews page was truncated");
|
|
440
|
-
}
|
|
441
|
-
return reviews.map((review) => ({
|
|
442
|
-
...review,
|
|
443
|
-
comments: review.comments?.nodes ?? [],
|
|
444
|
-
}));
|
|
445
|
-
}
|
|
446
|
-
export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
447
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100, after: $cursor) { nodes { commit { oid committedDate parents { totalCount } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
448
|
-
const commits = [];
|
|
449
|
-
let cursor;
|
|
450
|
-
for (;;) {
|
|
451
|
-
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
452
|
-
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}`);
|
|
453
|
-
const data = JSON.parse(raw);
|
|
454
|
-
const connection = data.data.repository.pullRequest.commits;
|
|
455
|
-
commits.push(...connection.nodes);
|
|
456
|
-
if (!connection.pageInfo?.hasNextPage)
|
|
457
|
-
break;
|
|
458
|
-
cursor = connection.pageInfo.endCursor;
|
|
459
|
-
if (!cursor)
|
|
460
|
-
throw new Error("GitHub commits page was truncated");
|
|
461
|
-
}
|
|
462
|
-
return commits.map(({ commit }) => ({
|
|
463
|
-
committedDate: commit.committedDate,
|
|
464
|
-
oid: commit.oid,
|
|
465
|
-
parentCount: commit.parents.totalCount,
|
|
466
|
-
}));
|
|
467
|
-
}
|
|
468
|
-
export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
|
|
469
|
-
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 } } } } }`;
|
|
470
|
-
const files = [];
|
|
471
|
-
let author = "";
|
|
472
|
-
let changedFiles = 0;
|
|
473
|
-
let labels = [];
|
|
474
|
-
let cursor;
|
|
475
|
-
for (;;) {
|
|
476
|
-
const cursorFlag = cursor ? ` -F filesCursor=${shellQuote(cursor)}` : "";
|
|
477
|
-
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}`);
|
|
478
|
-
const data = JSON.parse(raw);
|
|
479
|
-
const pullRequest = data.data.repository.pullRequest;
|
|
480
|
-
author = pullRequest.author?.login ?? author;
|
|
481
|
-
changedFiles = pullRequest.changedFiles;
|
|
482
|
-
labels = pullRequest.labels.nodes.map((label) => label.name);
|
|
483
|
-
files.push(...pullRequest.files.nodes.map((file) => file.path));
|
|
484
|
-
if (!pullRequest.files.pageInfo.hasNextPage)
|
|
485
|
-
break;
|
|
486
|
-
cursor = pullRequest.files.pageInfo.endCursor;
|
|
487
|
-
if (!cursor)
|
|
488
|
-
break;
|
|
489
|
-
}
|
|
490
|
-
return { author, changedFiles, files, labels };
|
|
491
|
-
}
|
|
492
|
-
export async function watchChecks(exec, repository, pr, options = {}) {
|
|
493
|
-
const requiredFlag = options.requiredOnly ? " --required" : "";
|
|
494
|
-
await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch${requiredFlag}`);
|
|
495
|
-
}
|
|
496
|
-
export function isCancelledCheck(check) {
|
|
497
|
-
return check.bucket === "cancel" || check.state === "CANCELLED";
|
|
498
|
-
}
|
|
499
|
-
export function isFailedCheck(check) {
|
|
500
|
-
return check.bucket === "fail" || check.state === "FAILURE";
|
|
501
|
-
}
|
|
502
|
-
export async function fetchPullRequestChecks(exec, repository, pr, options = {}) {
|
|
503
|
-
let raw;
|
|
504
|
-
const requiredFlag = options.requiredOnly ? " --required" : "";
|
|
505
|
-
try {
|
|
506
|
-
raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow${requiredFlag}`);
|
|
507
|
-
}
|
|
508
|
-
catch (error) {
|
|
509
|
-
if (options.tolerateMissingChecks &&
|
|
510
|
-
/no checks reported on the '.+' branch/i.test(errorText(error))) {
|
|
511
|
-
return [];
|
|
512
|
-
}
|
|
513
|
-
throw error;
|
|
514
|
-
}
|
|
515
|
-
return JSON.parse(raw);
|
|
516
|
-
}
|
|
517
|
-
export async function fetchWorkflowRunMeta(exec, repository, runId) {
|
|
518
|
-
const endpoint = `repos/${repository.github.owner}/${repository.github.repo}/actions/runs/${runId}`;
|
|
519
|
-
const raw = await exec(`gh api${ghHostOption(repository)} ${shellQuote(endpoint)}`);
|
|
520
|
-
const data = JSON.parse(raw);
|
|
521
|
-
return {
|
|
522
|
-
conclusion: data.conclusion,
|
|
523
|
-
headSha: data.head_sha ?? "",
|
|
524
|
-
status: data.status ?? "",
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
function excludedCheckMatcher(pattern) {
|
|
528
|
-
if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 1) {
|
|
529
|
-
const regex = new RegExp(pattern.slice(1, -1));
|
|
530
|
-
return (check) => regex.test(check.name);
|
|
531
|
-
}
|
|
532
|
-
return (check) => check.name === pattern;
|
|
533
|
-
}
|
|
534
|
-
export function applyCheckExclusions(input) {
|
|
535
|
-
if (!input.patterns.length)
|
|
536
|
-
return input.checks;
|
|
537
|
-
const matchers = input.patterns.map(excludedCheckMatcher);
|
|
538
|
-
const kept = [];
|
|
539
|
-
for (const check of input.checks) {
|
|
540
|
-
if (matchers.some((matcher) => matcher(check))) {
|
|
541
|
-
input.excluded.push(check);
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
kept.push(check);
|
|
545
|
-
}
|
|
546
|
-
return kept;
|
|
547
|
-
}
|
|
548
|
-
export function checkJobId(check) {
|
|
549
|
-
return check.link.match(/\/actions\/runs\/\d+\/job\/(\d+)/)?.[1];
|
|
550
|
-
}
|
|
551
|
-
export function checkRunId(check) {
|
|
552
|
-
return check.link.match(/\/actions\/runs\/(\d+)\/job\/\d+/)?.[1];
|
|
553
|
-
}
|
|
554
|
-
export async function rerunCheckJob(exec, repository, jobId) {
|
|
555
|
-
await exec(`gh run rerun --repo ${shellQuote(repoSpecifier(repository))} --job ${shellQuote(jobId)}`);
|
|
556
|
-
}
|
|
557
|
-
export async function watchRun(exec, repository, runId) {
|
|
558
|
-
await exec(`gh run watch ${shellQuote(runId)} --repo ${shellQuote(repoSpecifier(repository))} --exit-status`);
|
|
559
|
-
}
|
|
560
|
-
export async function fetchCheckFailureLog(exec, repository, jobId) {
|
|
561
|
-
return exec(`gh run view --repo ${shellQuote(repoSpecifier(repository))} --job ${shellQuote(jobId)} --log-failed`);
|
|
562
|
-
}
|
|
563
|
-
export async function fetchMergeQueueRequirement(exec, repository, branch) {
|
|
564
|
-
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")}`);
|
|
565
|
-
const rules = JSON.parse(raw);
|
|
566
|
-
return rules.some((rule) => rule.type === "merge_queue");
|
|
567
|
-
}
|
|
568
|
-
export async function createWorktree(exec, repository, pr, worktreePath) {
|
|
569
|
-
const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
|
|
570
|
-
return withWorktreeCreateLock(lockKey, async () => {
|
|
571
|
-
let worktreeAdded = false;
|
|
572
|
-
try {
|
|
573
|
-
await mkdir(dirname(worktreePath), { recursive: true });
|
|
574
|
-
await exec(`git worktree add --detach ${shellQuote(worktreePath)}`);
|
|
575
|
-
worktreeAdded = true;
|
|
576
|
-
await checkoutPullRequestWithRetry(exec, repository, pr, worktreePath);
|
|
577
|
-
const branch = (await exec("git branch --show-current", { cwd: worktreePath })).trim();
|
|
578
|
-
return { branch: branch || undefined, path: worktreePath };
|
|
579
|
-
}
|
|
580
|
-
catch (error) {
|
|
581
|
-
if (worktreeAdded) {
|
|
582
|
-
await removeWorktree(exec, worktreePath).catch(() => undefined);
|
|
583
|
-
}
|
|
584
|
-
throw error;
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
export async function removeWorktree(exec, worktreePath) {
|
|
589
|
-
await exec(`git worktree remove --force ${shellQuote(worktreePath)}`);
|
|
590
|
-
await exec("git worktree prune");
|
|
591
|
-
}
|
|
592
|
-
export async function removeBranch(exec, branch) {
|
|
593
|
-
await exec(`git branch -D ${shellQuote(branch)}`);
|
|
594
|
-
}
|
|
595
|
-
export async function postApproval(exec, repository, pr, account, body) {
|
|
596
|
-
const token = await ghToken(exec, repository, account);
|
|
597
|
-
if (body != null) {
|
|
598
|
-
const payloadPath = join(tmpdir(), `magi-approve-${process.pid}-${Date.now()}.json`);
|
|
599
|
-
await writeFile(payloadPath, JSON.stringify({ body, event: "APPROVE" }));
|
|
600
|
-
try {
|
|
601
|
-
return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
|
|
602
|
-
}
|
|
603
|
-
finally {
|
|
604
|
-
await rm(payloadPath, { force: true });
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
|
|
608
|
-
}
|
|
609
|
-
export async function postCloseComment(exec, repository, pr, account, body) {
|
|
610
|
-
const token = await ghToken(exec, repository, account);
|
|
611
|
-
const payloadPath = join(tmpdir(), `magi-close-${process.pid}-${Date.now()}.json`);
|
|
612
|
-
await writeFile(payloadPath, JSON.stringify({ body, event: "COMMENT" }));
|
|
613
|
-
try {
|
|
614
|
-
return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
|
|
615
|
-
}
|
|
616
|
-
finally {
|
|
617
|
-
await rm(payloadPath, { force: true });
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
function findingComment(finding, body) {
|
|
621
|
-
const comment = {
|
|
622
|
-
body: body ?? `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
|
|
623
|
-
line: finding.line,
|
|
624
|
-
path: finding.path,
|
|
625
|
-
side: "RIGHT",
|
|
626
|
-
};
|
|
627
|
-
if (finding.startLine != null) {
|
|
628
|
-
comment.start_line = finding.startLine;
|
|
629
|
-
comment.start_side = "RIGHT";
|
|
630
|
-
}
|
|
631
|
-
return comment;
|
|
632
|
-
}
|
|
633
|
-
function changesRequestedBody(findings) {
|
|
634
|
-
return findings.length === 1
|
|
635
|
-
? "Changes requested: 1 inline comment."
|
|
636
|
-
: `Changes requested: ${findings.length} inline comments.`;
|
|
637
|
-
}
|
|
638
|
-
export async function postChangesRequested(exec, repository, pr, account, findings, options = {}) {
|
|
639
|
-
const token = await ghToken(exec, repository, account);
|
|
640
|
-
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
641
|
-
const body = options.body ?? changesRequestedBody(findings);
|
|
642
|
-
await writeFile(payloadPath, JSON.stringify({
|
|
643
|
-
body,
|
|
644
|
-
comments: findings.map((finding, index) => findingComment(finding, options.commentBodies?.[index])),
|
|
645
|
-
event: "REQUEST_CHANGES",
|
|
646
|
-
}));
|
|
647
|
-
try {
|
|
648
|
-
return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
|
|
649
|
-
}
|
|
650
|
-
finally {
|
|
651
|
-
await rm(payloadPath, { force: true });
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
export async function mergePullRequest(exec, repository, pr, account) {
|
|
655
|
-
const token = await ghToken(exec, repository, account);
|
|
656
|
-
if (repository.merge.mergeQueue) {
|
|
657
|
-
const queueInput = await fetchPullRequestQueueInput(exec, repository, pr, token);
|
|
658
|
-
const query = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }`;
|
|
659
|
-
return exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F pullRequestId=${shellQuote(queueInput.id)} -F expectedHeadOid=${shellQuote(queueInput.headRefOid)} --jq .data.enqueuePullRequest.mergeQueueEntry.id`, ghTokenEnv(token));
|
|
660
|
-
}
|
|
661
|
-
const methodFlag = repository.merge.method === "merge"
|
|
662
|
-
? "--merge"
|
|
663
|
-
: repository.merge.method === "rebase"
|
|
664
|
-
? "--rebase"
|
|
665
|
-
: "--squash";
|
|
666
|
-
const autoFlag = repository.merge.auto ? " --auto" : "";
|
|
667
|
-
const deleteFlag = repository.merge.deleteBranch ? " --delete-branch" : "";
|
|
668
|
-
return exec(`gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`, ghTokenEnv(token));
|
|
669
|
-
}
|
|
670
|
-
export async function fetchPullRequestMergeStatus(exec, repository, pr) {
|
|
671
|
-
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json state,mergeStateStatus,autoMergeRequest`);
|
|
672
|
-
return JSON.parse(json);
|
|
673
|
-
}
|
|
674
|
-
export async function fetchPullRequestQueueStatus(exec, repository, pr) {
|
|
675
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { state isInMergeQueue mergeQueueEntry { id } } } }`;
|
|
676
|
-
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}`);
|
|
677
|
-
const data = JSON.parse(raw);
|
|
678
|
-
const status = data.data?.repository?.pullRequest;
|
|
679
|
-
if (!status)
|
|
680
|
-
throw new Error(`Could not fetch merge queue status for #${pr}`);
|
|
681
|
-
return status;
|
|
682
|
-
}
|
|
683
|
-
export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_000) {
|
|
684
|
-
for (;;) {
|
|
685
|
-
const status = await fetchPullRequestQueueStatus(exec, repository, pr);
|
|
686
|
-
if (status.state === "MERGED")
|
|
687
|
-
return "merged";
|
|
688
|
-
if (status.state === "OPEN" &&
|
|
689
|
-
!status.isInMergeQueue &&
|
|
690
|
-
status.mergeQueueEntry == null) {
|
|
691
|
-
return "dequeued";
|
|
692
|
-
}
|
|
693
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000) {
|
|
697
|
-
for (;;) {
|
|
698
|
-
const status = await fetchPullRequestMergeStatus(exec, repository, pr);
|
|
699
|
-
if (status.state === "MERGED")
|
|
700
|
-
return "merged";
|
|
701
|
-
if (status.state !== "OPEN" || status.autoMergeRequest == null) {
|
|
702
|
-
return "dequeued";
|
|
703
|
-
}
|
|
704
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
export async function fetchBaseBranch(exec, repository, meta, worktreePath) {
|
|
708
|
-
await exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(repository, repository.github.owner, repository.github.repo))} ${shellQuote(`refs/heads/${meta.baseRefName}`)}`, { cwd: worktreePath });
|
|
709
|
-
}
|
|
710
|
-
export async function mergeBaseNoCommit(exec, baseSha, worktreePath) {
|
|
711
|
-
await exec(`git merge --no-commit --no-ff ${shellQuote(baseSha)}`, {
|
|
712
|
-
cwd: worktreePath,
|
|
713
|
-
}).catch(() => undefined);
|
|
714
|
-
}
|
|
715
|
-
export async function listUnmergedFiles(exec, worktreePath) {
|
|
716
|
-
const output = await exec("git diff --name-only --diff-filter=U", {
|
|
717
|
-
cwd: worktreePath,
|
|
718
|
-
});
|
|
719
|
-
return output
|
|
720
|
-
.split("\n")
|
|
721
|
-
.map((line) => line.trim())
|
|
722
|
-
.filter(Boolean);
|
|
723
|
-
}
|
|
724
|
-
export async function abortMerge(exec, worktreePath) {
|
|
725
|
-
await exec("git merge --abort", { cwd: worktreePath }).catch(() => undefined);
|
|
726
|
-
}
|
|
727
|
-
export async function currentHeadSha(exec, worktreePath) {
|
|
728
|
-
return (await exec("git rev-parse HEAD", { cwd: worktreePath })).trim();
|
|
729
|
-
}
|
|
730
|
-
export async function closePullRequest(exec, repository, pr, account) {
|
|
731
|
-
const token = await ghToken(exec, repository, account);
|
|
732
|
-
return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
733
|
-
}
|
|
734
|
-
export async function pushHead(exec, repository, worktreePath, account, head) {
|
|
735
|
-
const token = await ghToken(exec, repository, account);
|
|
736
|
-
const url = repositoryGitUrl(repository, head.owner, head.repo);
|
|
737
|
-
await exec(`git push ${shellQuote(url)} ${shellQuote(`HEAD:refs/heads/${head.ref}`)}`, {
|
|
738
|
-
cwd: worktreePath,
|
|
739
|
-
env: {
|
|
740
|
-
GIT_CONFIG_COUNT: "2",
|
|
741
|
-
GIT_CONFIG_KEY_0: "credential.helper",
|
|
742
|
-
GIT_CONFIG_KEY_1: "credential.helper",
|
|
743
|
-
GIT_CONFIG_VALUE_0: "",
|
|
744
|
-
GIT_CONFIG_VALUE_1: "!f() { echo username=x-access-token; echo password=$GIT_PASSWORD; }; f",
|
|
745
|
-
GIT_PASSWORD: token,
|
|
746
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
747
|
-
},
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
export async function createPullRequest(exec, repository, account, input) {
|
|
751
|
-
const token = await ghToken(exec, repository, account);
|
|
752
|
-
const baseFlag = input.base ? ` --base ${shellQuote(input.base)}` : "";
|
|
753
|
-
return exec(`gh pr create --repo ${shellQuote(repoSpecifier(repository))} --head ${shellQuote(input.head)}${baseFlag} --title ${shellQuote(input.title)} --body ${shellQuote(input.body)}`, ghTokenEnv(token));
|
|
754
|
-
}
|
|
755
|
-
export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
756
|
-
if (identity.name) {
|
|
757
|
-
await exec(`git config --worktree user.name ${shellQuote(identity.name)}`, {
|
|
758
|
-
cwd: worktreePath,
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
if (identity.email) {
|
|
762
|
-
await exec(`git config --worktree user.email ${shellQuote(identity.email)}`, {
|
|
763
|
-
cwd: worktreePath,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
768
|
-
const threadQuery = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { nodes { id isResolved comments(first: 100) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
769
|
-
const commentQuery = `query($threadId: ID!, $cursor: String) { node(id: $threadId) { ... on PullRequestReviewThread { comments(first: 100, after: $cursor) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } } }`;
|
|
770
|
-
const threads = [];
|
|
771
|
-
let cursor;
|
|
772
|
-
async function fetchRemainingComments(threadId, initialCursor) {
|
|
773
|
-
const comments = [];
|
|
774
|
-
let commentsCursor = initialCursor;
|
|
775
|
-
for (;;) {
|
|
776
|
-
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(commentQuery)} -F threadId=${shellQuote(threadId)} -F cursor=${shellQuote(commentsCursor)}`);
|
|
777
|
-
const data = JSON.parse(raw);
|
|
778
|
-
const connection = data.data.node?.comments;
|
|
779
|
-
if (!connection)
|
|
780
|
-
throw new Error("GitHub review thread comments were missing");
|
|
781
|
-
comments.push(...connection.nodes);
|
|
782
|
-
if (!connection.pageInfo?.hasNextPage)
|
|
783
|
-
break;
|
|
784
|
-
const nextCursor = connection.pageInfo.endCursor;
|
|
785
|
-
if (!nextCursor)
|
|
786
|
-
throw new Error("GitHub review thread comments page was truncated");
|
|
787
|
-
commentsCursor = nextCursor;
|
|
788
|
-
}
|
|
789
|
-
return comments;
|
|
790
|
-
}
|
|
791
|
-
for (;;) {
|
|
792
|
-
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
793
|
-
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(threadQuery)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
|
|
794
|
-
const data = JSON.parse(raw);
|
|
795
|
-
const connection = data.data.repository.pullRequest.reviewThreads;
|
|
796
|
-
for (const thread of connection.nodes) {
|
|
797
|
-
const commentsCursor = thread.comments.pageInfo?.endCursor;
|
|
798
|
-
if (thread.comments.pageInfo?.hasNextPage) {
|
|
799
|
-
if (!commentsCursor)
|
|
800
|
-
throw new Error("GitHub review thread comments page was truncated");
|
|
801
|
-
thread.comments.nodes.push(...(await fetchRemainingComments(thread.id, commentsCursor)));
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
threads.push(...connection.nodes);
|
|
805
|
-
if (!connection.pageInfo?.hasNextPage)
|
|
806
|
-
break;
|
|
807
|
-
cursor = connection.pageInfo.endCursor;
|
|
808
|
-
if (!cursor)
|
|
809
|
-
throw new Error("GitHub review threads page was truncated");
|
|
810
|
-
}
|
|
811
|
-
return threads.flatMap((thread) => {
|
|
812
|
-
if (thread.isResolved || !thread.comments.nodes.length)
|
|
813
|
-
return [];
|
|
814
|
-
const comments = [...thread.comments.nodes]
|
|
815
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
816
|
-
.map((comment) => ({
|
|
817
|
-
author: comment.author.login,
|
|
818
|
-
body: comment.body,
|
|
819
|
-
commentId: comment.databaseId,
|
|
820
|
-
createdAt: comment.createdAt,
|
|
821
|
-
}));
|
|
822
|
-
const first = thread.comments.nodes[0];
|
|
823
|
-
if (!author)
|
|
824
|
-
return [
|
|
825
|
-
{
|
|
826
|
-
body: first.body,
|
|
827
|
-
commentId: first.databaseId,
|
|
828
|
-
comments,
|
|
829
|
-
line: first.line,
|
|
830
|
-
path: first.path,
|
|
831
|
-
threadId: thread.id,
|
|
832
|
-
},
|
|
833
|
-
];
|
|
834
|
-
if (first.author.login !== author)
|
|
835
|
-
return [];
|
|
836
|
-
const authored = thread.comments.nodes.filter((comment) => comment.author.login === author);
|
|
837
|
-
const latest = authored.sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1) ??
|
|
838
|
-
first;
|
|
839
|
-
return [
|
|
840
|
-
{
|
|
841
|
-
commentId: first.databaseId,
|
|
842
|
-
comments,
|
|
843
|
-
latestBody: latest.body,
|
|
844
|
-
line: first.line,
|
|
845
|
-
path: first.path,
|
|
846
|
-
threadId: thread.id,
|
|
847
|
-
},
|
|
848
|
-
];
|
|
849
|
-
});
|
|
850
|
-
}
|
|
851
|
-
export async function fetchPullRequestReviewThreads(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
852
|
-
return (await fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit, commentsPerThread)).threads;
|
|
853
|
-
}
|
|
854
|
-
export async function fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
855
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int!, $threadLimit: Int!, $commentsPerThread: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(last: $threadLimit) { totalCount nodes { id isResolved comments(last: $commentsPerThread) { totalCount nodes { databaseId author { login } path line body createdAt } } } } } } }`;
|
|
856
|
-
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} -F threadLimit=${threadLimit} -F commentsPerThread=${commentsPerThread}`);
|
|
857
|
-
const data = JSON.parse(raw);
|
|
858
|
-
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
859
|
-
const nodes = connection?.nodes ?? [];
|
|
860
|
-
const threads = nodes.flatMap((thread) => {
|
|
861
|
-
const comments = [...thread.comments.nodes]
|
|
862
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
863
|
-
.map((comment) => ({
|
|
864
|
-
author: comment.author?.login ?? "",
|
|
865
|
-
body: comment.body ?? "",
|
|
866
|
-
commentId: comment.databaseId,
|
|
867
|
-
createdAt: comment.createdAt,
|
|
868
|
-
}));
|
|
869
|
-
const first = thread.comments.nodes[0];
|
|
870
|
-
if (!first)
|
|
871
|
-
return [];
|
|
872
|
-
return [
|
|
873
|
-
{
|
|
874
|
-
body: first.body ?? "",
|
|
875
|
-
commentId: first.databaseId,
|
|
876
|
-
comments,
|
|
877
|
-
isResolved: thread.isResolved,
|
|
878
|
-
line: first.line,
|
|
879
|
-
omittedComments: Math.max(0, (thread.comments.totalCount ?? comments.length) - comments.length),
|
|
880
|
-
path: first.path,
|
|
881
|
-
threadId: thread.id,
|
|
882
|
-
},
|
|
883
|
-
];
|
|
884
|
-
});
|
|
885
|
-
return {
|
|
886
|
-
omitted: Math.max(0, (connection?.totalCount ?? threads.length) - threads.length),
|
|
887
|
-
threads,
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
export async function postReply(exec, repository, pr, account, commentId, body) {
|
|
891
|
-
const token = await ghToken(exec, repository, account);
|
|
892
|
-
const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
|
|
893
|
-
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
894
|
-
try {
|
|
895
|
-
return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/comments/${commentId}/replies --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
|
|
896
|
-
}
|
|
897
|
-
finally {
|
|
898
|
-
await rm(payloadPath, { force: true });
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
export async function resolveThread(exec, repository, account, threadId) {
|
|
902
|
-
const token = await ghToken(exec, repository, account);
|
|
903
|
-
const query = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id } } }`;
|
|
904
|
-
await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`, ghTokenEnv(token));
|
|
905
|
-
}
|