opencode-magi 0.1.0 → 0.3.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.md +33 -10
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +124 -26
- package/dist/config/validate.js +486 -191
- package/dist/config/worktree.js +19 -0
- package/dist/github/commands.js +349 -17
- package/dist/index.js +257 -27
- package/dist/orchestrator/ci.js +1 -1
- package/dist/orchestrator/findings.js +4 -3
- package/dist/orchestrator/inline-comments.js +73 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/merge.js +24 -4
- package/dist/orchestrator/report.js +15 -1
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +78 -10
- package/dist/orchestrator/run-manager.js +418 -20
- package/dist/orchestrator/triage.js +1119 -0
- package/dist/permissions/editor.json +8 -1
- package/dist/prompts/compose.js +172 -15
- package/dist/prompts/contracts.js +119 -12
- package/dist/prompts/output.js +149 -14
- package/dist/prompts/templates/{close-reconsideration.md → review/close-reconsideration.md} +1 -2
- package/dist/prompts/templates/review/review.md +13 -0
- package/dist/prompts/templates/triage/acceptance.md +7 -0
- package/dist/prompts/templates/triage/action.md +5 -0
- package/dist/prompts/templates/triage/category.md +10 -0
- package/dist/prompts/templates/triage/comment-classification.md +7 -0
- package/dist/prompts/templates/triage/comment.md +5 -0
- package/dist/prompts/templates/triage/create.md +7 -0
- package/dist/prompts/templates/triage/duplicate.md +7 -0
- package/dist/prompts/templates/triage/existing-pr.md +7 -0
- package/dist/prompts/templates/triage/question.md +5 -0
- package/dist/prompts/templates/triage/reconsider.md +5 -0
- package/package.json +28 -27
- package/schema.json +234 -90
- package/dist/prompts/templates/rereview-close-reconsideration.md +0 -6
- package/dist/prompts/templates/review.md +0 -7
- /package/dist/prompts/templates/{ci-classification-after-edit.md → merge/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{edit.md → merge/edit.md} +0 -0
- /package/dist/prompts/templates/{ci-classification.md → review/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{finding-validation.md → review/finding-validation.md} +0 -0
- /package/dist/prompts/templates/{rereview.md → review/rereview.md} +0 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
const DEFAULT_WORKTREE_DIRS = {
|
|
3
|
+
issue: ".magi/worktrees/issue",
|
|
4
|
+
pr: ".magi/worktrees/pr",
|
|
5
|
+
};
|
|
6
|
+
function resolvePath(directory, path) {
|
|
7
|
+
return isAbsolute(path) ? path : join(directory, path);
|
|
8
|
+
}
|
|
9
|
+
export function worktreeBaseDir(directory, config, kind) {
|
|
10
|
+
return resolvePath(directory, kind === "issue"
|
|
11
|
+
? (config.triage?.worktree ?? DEFAULT_WORKTREE_DIRS[kind])
|
|
12
|
+
: (config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]));
|
|
13
|
+
}
|
|
14
|
+
export function worktreeBaseDirs(directory, config = {}) {
|
|
15
|
+
return [
|
|
16
|
+
worktreeBaseDir(directory, config, "pr"),
|
|
17
|
+
worktreeBaseDir(directory, config, "issue"),
|
|
18
|
+
];
|
|
19
|
+
}
|
package/dist/github/commands.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
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
|
+
}
|
|
4
12
|
const WORKTREE_CHECKOUT_RETRY_ATTEMPTS = 5;
|
|
5
13
|
const WORKTREE_CHECKOUT_RETRY_DELAY_MS = 100;
|
|
6
14
|
const worktreeCreateLocks = new Map();
|
|
@@ -41,7 +49,7 @@ async function withWorktreeCreateLock(key, run) {
|
|
|
41
49
|
async function checkoutPullRequestWithRetry(exec, repository, pr, worktreePath) {
|
|
42
50
|
for (let attempt = 0;; attempt += 1) {
|
|
43
51
|
try {
|
|
44
|
-
await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, {
|
|
52
|
+
await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))} --detach`, {
|
|
45
53
|
cwd: worktreePath,
|
|
46
54
|
});
|
|
47
55
|
return;
|
|
@@ -70,6 +78,9 @@ export function repoSpecifier(repository) {
|
|
|
70
78
|
? repoSlug(repository)
|
|
71
79
|
: `${host}/${repoSlug(repository)}`;
|
|
72
80
|
}
|
|
81
|
+
function repositoryGitUrl(repository, owner, repo) {
|
|
82
|
+
return `https://${githubHost(repository)}/${owner}/${repo}.git`;
|
|
83
|
+
}
|
|
73
84
|
export function ghHostOption(repository) {
|
|
74
85
|
const host = githubHost(repository);
|
|
75
86
|
return host === "github.com" ? "" : ` --hostname ${shellQuote(host)}`;
|
|
@@ -77,10 +88,241 @@ export function ghHostOption(repository) {
|
|
|
77
88
|
export async function ghToken(exec, repository, account) {
|
|
78
89
|
return (await exec(`gh auth token${ghHostOption(repository)} --user ${shellQuote(account)}`)).trim();
|
|
79
90
|
}
|
|
91
|
+
function ghTokenEnv(token) {
|
|
92
|
+
return { env: { GH_TOKEN: token } };
|
|
93
|
+
}
|
|
94
|
+
async function fetchPullRequestQueueInput(exec, repository, pr, token) {
|
|
95
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { id headRefOid } } }`;
|
|
96
|
+
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));
|
|
97
|
+
const data = JSON.parse(raw);
|
|
98
|
+
const pullRequest = data.data?.repository?.pullRequest;
|
|
99
|
+
if (!pullRequest?.id || !pullRequest.headRefOid) {
|
|
100
|
+
throw new Error(`Could not fetch pull request queue metadata for #${pr}`);
|
|
101
|
+
}
|
|
102
|
+
return { headRefOid: pullRequest.headRefOid, id: pullRequest.id };
|
|
103
|
+
}
|
|
80
104
|
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`);
|
|
105
|
+
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`);
|
|
82
106
|
return JSON.parse(json);
|
|
83
107
|
}
|
|
108
|
+
export async function fetchPullRequestClosingIssues(exec, repository, pr) {
|
|
109
|
+
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 } } } } } }`;
|
|
110
|
+
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}`);
|
|
111
|
+
const data = JSON.parse(raw);
|
|
112
|
+
return (data.data?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue) => ({
|
|
113
|
+
author: issue.author?.login ?? "",
|
|
114
|
+
body: issue.body ?? "",
|
|
115
|
+
labels: issue.labels?.nodes?.map((label) => label.name) ?? [],
|
|
116
|
+
number: issue.number,
|
|
117
|
+
state: issue.state,
|
|
118
|
+
title: issue.title,
|
|
119
|
+
type: issue.issueType?.name,
|
|
120
|
+
url: issue.url,
|
|
121
|
+
})) ?? []);
|
|
122
|
+
}
|
|
123
|
+
export async function fetchIssue(exec, repository, issue) {
|
|
124
|
+
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 } } } }`;
|
|
125
|
+
try {
|
|
126
|
+
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}`);
|
|
127
|
+
const data = JSON.parse(raw);
|
|
128
|
+
const graphqlIssue = data.data?.repository?.issue;
|
|
129
|
+
if (!graphqlIssue)
|
|
130
|
+
throw new Error(`Could not fetch issue #${issue}`);
|
|
131
|
+
return {
|
|
132
|
+
author: graphqlIssue.author?.login ?? "",
|
|
133
|
+
body: graphqlIssue.body ?? "",
|
|
134
|
+
labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
|
|
135
|
+
number: graphqlIssue.number,
|
|
136
|
+
state: graphqlIssue.state,
|
|
137
|
+
title: graphqlIssue.title,
|
|
138
|
+
type: graphqlIssue.issueType?.name,
|
|
139
|
+
url: graphqlIssue.url,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return fetchIssueWithCli(exec, repository, issue);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function fetchIssueWithCli(exec, repository, issue) {
|
|
147
|
+
const raw = await exec(`gh issue view ${issue} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,labels`);
|
|
148
|
+
const data = JSON.parse(raw);
|
|
149
|
+
return {
|
|
150
|
+
author: data.author?.login ?? "",
|
|
151
|
+
body: data.body ?? "",
|
|
152
|
+
labels: data.labels?.map((label) => label.name) ?? [],
|
|
153
|
+
number: data.number,
|
|
154
|
+
state: data.state,
|
|
155
|
+
title: data.title,
|
|
156
|
+
url: data.url,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export async function fetchIssueComments(exec, repository, issue, limit = 50) {
|
|
160
|
+
return (await fetchIssueCommentPage(exec, repository, issue, limit)).comments;
|
|
161
|
+
}
|
|
162
|
+
export async function fetchIssueCommentPage(exec, repository, issue, limit = 50) {
|
|
163
|
+
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 } } } } }`;
|
|
164
|
+
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}`);
|
|
165
|
+
const data = JSON.parse(raw);
|
|
166
|
+
const connection = data.data?.repository?.issue?.comments;
|
|
167
|
+
const comments = connection?.nodes?.map((comment) => ({
|
|
168
|
+
author: comment.author?.login ?? "",
|
|
169
|
+
authorAssociation: comment.authorAssociation,
|
|
170
|
+
body: comment.body ?? "",
|
|
171
|
+
createdAt: comment.createdAt,
|
|
172
|
+
id: comment.databaseId,
|
|
173
|
+
url: comment.url,
|
|
174
|
+
})) ?? [];
|
|
175
|
+
return {
|
|
176
|
+
comments,
|
|
177
|
+
omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export async function fetchPullRequestComments(exec, repository, pr, limit = 50) {
|
|
181
|
+
return (await fetchPullRequestCommentPage(exec, repository, pr, limit))
|
|
182
|
+
.comments;
|
|
183
|
+
}
|
|
184
|
+
export async function fetchPullRequestCommentPage(exec, repository, pr, limit = 50) {
|
|
185
|
+
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 } } } } }`;
|
|
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} -F limit=${limit}`);
|
|
187
|
+
const data = JSON.parse(raw);
|
|
188
|
+
const connection = data.data?.repository?.pullRequest?.comments;
|
|
189
|
+
const comments = connection?.nodes?.map((comment) => ({
|
|
190
|
+
author: comment.author?.login ?? "",
|
|
191
|
+
authorAssociation: comment.authorAssociation,
|
|
192
|
+
body: comment.body ?? "",
|
|
193
|
+
createdAt: comment.createdAt,
|
|
194
|
+
id: comment.databaseId,
|
|
195
|
+
url: comment.url,
|
|
196
|
+
})) ?? [];
|
|
197
|
+
return {
|
|
198
|
+
comments,
|
|
199
|
+
omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export async function fetchRelatedPullRequests(exec, repository, issue) {
|
|
203
|
+
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 } } } } } } } } }`;
|
|
204
|
+
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}`);
|
|
205
|
+
const data = JSON.parse(raw);
|
|
206
|
+
const prs = new Map();
|
|
207
|
+
for (const node of data.data?.repository?.issue?.timelineItems?.nodes ?? []) {
|
|
208
|
+
const source = (node.subject ?? node.source);
|
|
209
|
+
if (!source?.number || !source.url)
|
|
210
|
+
continue;
|
|
211
|
+
const state = source.mergedAt
|
|
212
|
+
? "MERGED"
|
|
213
|
+
: normalizeRelatedPullRequestState(source.state);
|
|
214
|
+
prs.set(source.number, {
|
|
215
|
+
author: source.author?.login ?? "",
|
|
216
|
+
body: source.body,
|
|
217
|
+
mergedAt: source.mergedAt,
|
|
218
|
+
number: source.number,
|
|
219
|
+
state,
|
|
220
|
+
title: source.title ?? `PR #${source.number}`,
|
|
221
|
+
url: source.url,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const searchQuery = `repo:${repoSlug(repository)} is:pr ${issue}`;
|
|
225
|
+
const searchRaw = await exec(`gh search prs ${shellQuote(searchQuery)} --json number,title,url,state,body,author --limit 10`).catch(() => "[]");
|
|
226
|
+
const searchData = JSON.parse(searchRaw);
|
|
227
|
+
const closingReference = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, "i");
|
|
228
|
+
for (const item of searchData) {
|
|
229
|
+
if (!closingReference.test(item.body ?? ""))
|
|
230
|
+
continue;
|
|
231
|
+
prs.set(item.number, {
|
|
232
|
+
author: item.author?.login ?? "",
|
|
233
|
+
body: item.body,
|
|
234
|
+
number: item.number,
|
|
235
|
+
state: normalizeRelatedPullRequestState(item.state),
|
|
236
|
+
title: item.title,
|
|
237
|
+
url: item.url,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return [...prs.values()];
|
|
241
|
+
}
|
|
242
|
+
function duplicateReferences(text) {
|
|
243
|
+
const refs = new Set();
|
|
244
|
+
const pattern = /duplicate(?:s)?\s+(?:of\s+)?#(\d+)/gi;
|
|
245
|
+
for (const match of text.matchAll(pattern))
|
|
246
|
+
refs.add(Number(match[1]));
|
|
247
|
+
return [...refs];
|
|
248
|
+
}
|
|
249
|
+
async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
250
|
+
const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
|
|
251
|
+
if (!raw)
|
|
252
|
+
return undefined;
|
|
253
|
+
const data = JSON.parse(raw);
|
|
254
|
+
return { ...data, whyCandidate };
|
|
255
|
+
}
|
|
256
|
+
export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
|
|
257
|
+
const query = issue.title;
|
|
258
|
+
const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
|
|
259
|
+
.filter((number) => number !== issue.number)
|
|
260
|
+
.map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
|
|
261
|
+
const raw = await exec(`gh search issues --repo ${shellQuote(repoSlug(repository))} --json number,title,url,state,body --limit ${limit} -- ${shellQuote(query)}`);
|
|
262
|
+
const data = JSON.parse(raw);
|
|
263
|
+
const candidates = new Map();
|
|
264
|
+
for (const candidate of explicitCandidates) {
|
|
265
|
+
if (candidate)
|
|
266
|
+
candidates.set(candidate.number, candidate);
|
|
267
|
+
}
|
|
268
|
+
for (const item of data
|
|
269
|
+
.filter((item) => item.number !== issue.number)
|
|
270
|
+
.map((item) => ({
|
|
271
|
+
...item,
|
|
272
|
+
whyCandidate: "GitHub issue search matched the title.",
|
|
273
|
+
}))) {
|
|
274
|
+
if (!candidates.has(item.number))
|
|
275
|
+
candidates.set(item.number, item);
|
|
276
|
+
}
|
|
277
|
+
return [...candidates.values()].slice(0, limit);
|
|
278
|
+
}
|
|
279
|
+
export async function postIssueComment(exec, repository, issue, account, body) {
|
|
280
|
+
const token = await ghToken(exec, repository, account);
|
|
281
|
+
const payloadPath = join(tmpdir(), `magi-issue-${process.pid}-${Date.now()}.json`);
|
|
282
|
+
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
283
|
+
try {
|
|
284
|
+
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));
|
|
285
|
+
const data = JSON.parse(raw);
|
|
286
|
+
if (!data.id || !data.url)
|
|
287
|
+
throw new Error("GitHub issue comment response did not include id and url");
|
|
288
|
+
return { id: data.id, url: data.url };
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await rm(payloadPath, { force: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
export async function updateIssueComment(exec, repository, commentId, account, body) {
|
|
295
|
+
const token = await ghToken(exec, repository, account);
|
|
296
|
+
const payloadPath = join(tmpdir(), `magi-issue-comment-${process.pid}-${Date.now()}.json`);
|
|
297
|
+
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
298
|
+
try {
|
|
299
|
+
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));
|
|
300
|
+
const data = JSON.parse(raw);
|
|
301
|
+
if (!data.id || !data.url)
|
|
302
|
+
throw new Error("GitHub issue comment response did not include id and url");
|
|
303
|
+
return { id: data.id, url: data.url };
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
await rm(payloadPath, { force: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
export async function closeIssue(exec, repository, issue, account) {
|
|
310
|
+
const token = await ghToken(exec, repository, account);
|
|
311
|
+
return exec(`gh issue close ${issue} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
312
|
+
}
|
|
313
|
+
export async function assignIssue(exec, repository, issue, account) {
|
|
314
|
+
const token = await ghToken(exec, repository, account);
|
|
315
|
+
return exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-assignee ${shellQuote(account)}`, ghTokenEnv(token));
|
|
316
|
+
}
|
|
317
|
+
export async function removeIssueLabels(exec, repository, issue, labels, account) {
|
|
318
|
+
const token = await ghToken(exec, repository, account);
|
|
319
|
+
const removed = [];
|
|
320
|
+
for (const label of labels) {
|
|
321
|
+
await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --remove-label ${shellQuote(label)}`, ghTokenEnv(token));
|
|
322
|
+
removed.push(label);
|
|
323
|
+
}
|
|
324
|
+
return removed;
|
|
325
|
+
}
|
|
84
326
|
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
85
327
|
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
328
|
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}`);
|
|
@@ -98,7 +340,7 @@ export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
|
98
340
|
}));
|
|
99
341
|
}
|
|
100
342
|
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 } } } } }
|
|
343
|
+
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
344
|
const files = [];
|
|
103
345
|
let author = "";
|
|
104
346
|
let changedFiles = 0;
|
|
@@ -159,8 +401,18 @@ export function isCancelledCheck(check) {
|
|
|
159
401
|
export function isFailedCheck(check) {
|
|
160
402
|
return check.bucket === "fail" || check.state === "FAILURE";
|
|
161
403
|
}
|
|
162
|
-
export async function fetchPullRequestChecks(exec, repository, pr) {
|
|
163
|
-
|
|
404
|
+
export async function fetchPullRequestChecks(exec, repository, pr, options = {}) {
|
|
405
|
+
let raw;
|
|
406
|
+
try {
|
|
407
|
+
raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow`);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (options.tolerateMissingChecks &&
|
|
411
|
+
/no checks reported on the '.+' branch/i.test(errorText(error))) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
164
416
|
return JSON.parse(raw);
|
|
165
417
|
}
|
|
166
418
|
export async function fetchWorkflowRunMeta(exec, repository, runId) {
|
|
@@ -244,14 +496,14 @@ export async function removeBranch(exec, branch) {
|
|
|
244
496
|
}
|
|
245
497
|
export async function postApproval(exec, repository, pr, account) {
|
|
246
498
|
const token = await ghToken(exec, repository, account);
|
|
247
|
-
return exec(`
|
|
499
|
+
return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
|
|
248
500
|
}
|
|
249
501
|
export async function postCloseComment(exec, repository, pr, account, body) {
|
|
250
502
|
const token = await ghToken(exec, repository, account);
|
|
251
503
|
const payloadPath = join(tmpdir(), `magi-close-${process.pid}-${Date.now()}.json`);
|
|
252
504
|
await writeFile(payloadPath, JSON.stringify({ body, event: "COMMENT" }));
|
|
253
505
|
try {
|
|
254
|
-
return await exec(`
|
|
506
|
+
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));
|
|
255
507
|
}
|
|
256
508
|
finally {
|
|
257
509
|
await rm(payloadPath, { force: true });
|
|
@@ -270,11 +522,19 @@ function findingComment(finding) {
|
|
|
270
522
|
}
|
|
271
523
|
return comment;
|
|
272
524
|
}
|
|
273
|
-
|
|
525
|
+
function requirementFindingSummary(finding) {
|
|
526
|
+
return [
|
|
527
|
+
`- Missing issue #${finding.issueNumber} requirement: ${finding.requirement}`,
|
|
528
|
+
` Evidence: ${finding.evidence}`,
|
|
529
|
+
` Fix: ${finding.fix}`,
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
|
|
274
533
|
const token = await ghToken(exec, repository, account);
|
|
275
534
|
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
276
535
|
const body = findings
|
|
277
536
|
.map((finding) => `- ${finding.issue.split("\n")[0]}`)
|
|
537
|
+
.concat(requirementFindings.map(requirementFindingSummary))
|
|
278
538
|
.join("\n");
|
|
279
539
|
await writeFile(payloadPath, JSON.stringify({
|
|
280
540
|
body,
|
|
@@ -282,7 +542,7 @@ export async function postChangesRequested(exec, repository, pr, account, findin
|
|
|
282
542
|
event: "REQUEST_CHANGES",
|
|
283
543
|
}));
|
|
284
544
|
try {
|
|
285
|
-
return await exec(`
|
|
545
|
+
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));
|
|
286
546
|
}
|
|
287
547
|
finally {
|
|
288
548
|
await rm(payloadPath, { force: true });
|
|
@@ -290,6 +550,11 @@ export async function postChangesRequested(exec, repository, pr, account, findin
|
|
|
290
550
|
}
|
|
291
551
|
export async function mergePullRequest(exec, repository, pr, account) {
|
|
292
552
|
const token = await ghToken(exec, repository, account);
|
|
553
|
+
if (repository.merge.mergeQueue) {
|
|
554
|
+
const queueInput = await fetchPullRequestQueueInput(exec, repository, pr, token);
|
|
555
|
+
const query = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }`;
|
|
556
|
+
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));
|
|
557
|
+
}
|
|
293
558
|
const methodFlag = repository.merge.method === "merge"
|
|
294
559
|
? "--merge"
|
|
295
560
|
: repository.merge.method === "rebase"
|
|
@@ -297,18 +562,29 @@ export async function mergePullRequest(exec, repository, pr, account) {
|
|
|
297
562
|
: "--squash";
|
|
298
563
|
const autoFlag = repository.merge.auto ? " --auto" : "";
|
|
299
564
|
const deleteFlag = repository.merge.deleteBranch ? " --delete-branch" : "";
|
|
300
|
-
return exec(`
|
|
565
|
+
return exec(`gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`, ghTokenEnv(token));
|
|
301
566
|
}
|
|
302
567
|
export async function fetchPullRequestMergeStatus(exec, repository, pr) {
|
|
303
568
|
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json state,mergeStateStatus,autoMergeRequest`);
|
|
304
569
|
return JSON.parse(json);
|
|
305
570
|
}
|
|
571
|
+
export async function fetchPullRequestQueueStatus(exec, repository, pr) {
|
|
572
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { state isInMergeQueue mergeQueueEntry { id } } } }`;
|
|
573
|
+
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}`);
|
|
574
|
+
const data = JSON.parse(raw);
|
|
575
|
+
const status = data.data?.repository?.pullRequest;
|
|
576
|
+
if (!status)
|
|
577
|
+
throw new Error(`Could not fetch merge queue status for #${pr}`);
|
|
578
|
+
return status;
|
|
579
|
+
}
|
|
306
580
|
export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_000) {
|
|
307
581
|
for (;;) {
|
|
308
|
-
const status = await
|
|
582
|
+
const status = await fetchPullRequestQueueStatus(exec, repository, pr);
|
|
309
583
|
if (status.state === "MERGED")
|
|
310
584
|
return "merged";
|
|
311
|
-
if (status.state === "OPEN" &&
|
|
585
|
+
if (status.state === "OPEN" &&
|
|
586
|
+
!status.isInMergeQueue &&
|
|
587
|
+
status.mergeQueueEntry == null) {
|
|
312
588
|
return "dequeued";
|
|
313
589
|
}
|
|
314
590
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
@@ -316,11 +592,28 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
|
|
|
316
592
|
}
|
|
317
593
|
export async function closePullRequest(exec, repository, pr, account) {
|
|
318
594
|
const token = await ghToken(exec, repository, account);
|
|
319
|
-
return exec(`
|
|
595
|
+
return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
320
596
|
}
|
|
321
|
-
export async function pushHead(exec, repository, worktreePath, account) {
|
|
597
|
+
export async function pushHead(exec, repository, worktreePath, account, head) {
|
|
322
598
|
const token = await ghToken(exec, repository, account);
|
|
323
|
-
|
|
599
|
+
const url = repositoryGitUrl(repository, head.owner, head.repo);
|
|
600
|
+
await exec(`git push ${shellQuote(url)} ${shellQuote(`HEAD:refs/heads/${head.ref}`)}`, {
|
|
601
|
+
cwd: worktreePath,
|
|
602
|
+
env: {
|
|
603
|
+
GIT_CONFIG_COUNT: "2",
|
|
604
|
+
GIT_CONFIG_KEY_0: "credential.helper",
|
|
605
|
+
GIT_CONFIG_KEY_1: "credential.helper",
|
|
606
|
+
GIT_CONFIG_VALUE_0: "",
|
|
607
|
+
GIT_CONFIG_VALUE_1: "!f() { echo username=x-access-token; echo password=$GIT_PASSWORD; }; f",
|
|
608
|
+
GIT_PASSWORD: token,
|
|
609
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
export async function createPullRequest(exec, repository, account, input) {
|
|
614
|
+
const token = await ghToken(exec, repository, account);
|
|
615
|
+
const baseFlag = input.base ? ` --base ${shellQuote(input.base)}` : "";
|
|
616
|
+
return exec(`gh pr create --repo ${shellQuote(repoSpecifier(repository))} --head ${shellQuote(input.head)}${baseFlag} --title ${shellQuote(input.title)} --body ${shellQuote(input.body)}`, ghTokenEnv(token));
|
|
324
617
|
}
|
|
325
618
|
export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
326
619
|
if (identity.name) {
|
|
@@ -380,12 +673,51 @@ export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
|
380
673
|
];
|
|
381
674
|
});
|
|
382
675
|
}
|
|
676
|
+
export async function fetchPullRequestReviewThreads(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
677
|
+
return (await fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit, commentsPerThread)).threads;
|
|
678
|
+
}
|
|
679
|
+
export async function fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
680
|
+
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 } } } } } } }`;
|
|
681
|
+
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}`);
|
|
682
|
+
const data = JSON.parse(raw);
|
|
683
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
684
|
+
const nodes = connection?.nodes ?? [];
|
|
685
|
+
const threads = nodes.flatMap((thread) => {
|
|
686
|
+
const comments = [...thread.comments.nodes]
|
|
687
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
688
|
+
.map((comment) => ({
|
|
689
|
+
author: comment.author?.login ?? "",
|
|
690
|
+
body: comment.body ?? "",
|
|
691
|
+
commentId: comment.databaseId,
|
|
692
|
+
createdAt: comment.createdAt,
|
|
693
|
+
}));
|
|
694
|
+
const first = thread.comments.nodes[0];
|
|
695
|
+
if (!first)
|
|
696
|
+
return [];
|
|
697
|
+
return [
|
|
698
|
+
{
|
|
699
|
+
body: first.body ?? "",
|
|
700
|
+
commentId: first.databaseId,
|
|
701
|
+
comments,
|
|
702
|
+
isResolved: thread.isResolved,
|
|
703
|
+
line: first.line,
|
|
704
|
+
omittedComments: Math.max(0, (thread.comments.totalCount ?? comments.length) - comments.length),
|
|
705
|
+
path: first.path,
|
|
706
|
+
threadId: thread.id,
|
|
707
|
+
},
|
|
708
|
+
];
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
omitted: Math.max(0, (connection?.totalCount ?? threads.length) - threads.length),
|
|
712
|
+
threads,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
383
715
|
export async function postReply(exec, repository, pr, account, commentId, body) {
|
|
384
716
|
const token = await ghToken(exec, repository, account);
|
|
385
717
|
const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
|
|
386
718
|
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
387
719
|
try {
|
|
388
|
-
return await exec(`
|
|
720
|
+
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));
|
|
389
721
|
}
|
|
390
722
|
finally {
|
|
391
723
|
await rm(payloadPath, { force: true });
|
|
@@ -394,5 +726,5 @@ export async function postReply(exec, repository, pr, account, commentId, body)
|
|
|
394
726
|
export async function resolveThread(exec, repository, account, threadId) {
|
|
395
727
|
const token = await ghToken(exec, repository, account);
|
|
396
728
|
const query = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id } } }`;
|
|
397
|
-
await exec(`
|
|
729
|
+
await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`, ghTokenEnv(token));
|
|
398
730
|
}
|