opencode-magi 0.2.0 → 0.4.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 +19 -0
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +81 -1
- package/dist/config/validate.js +341 -3
- package/dist/config/worktree.js +8 -2
- package/dist/github/commands.js +381 -19
- package/dist/index.js +252 -26
- package/dist/orchestrator/ci.js +1 -1
- package/dist/orchestrator/findings.js +4 -3
- package/dist/orchestrator/inline-comments.js +79 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/merge.js +108 -34
- package/dist/orchestrator/report.js +25 -7
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +122 -14
- package/dist/orchestrator/run-manager.js +408 -17
- package/dist/orchestrator/triage.js +1119 -0
- package/dist/permissions/editor.json +8 -1
- package/dist/prompts/compose.js +163 -1
- package/dist/prompts/contracts.js +131 -18
- package/dist/prompts/output.js +173 -22
- package/dist/prompts/templates/merge/edit.md +12 -5
- package/dist/prompts/templates/review/review.md +6 -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 +5 -2
- package/schema.json +162 -5
package/dist/config/worktree.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { isAbsolute, join } from "node:path";
|
|
2
2
|
const DEFAULT_WORKTREE_DIRS = {
|
|
3
|
+
issue: ".magi/worktrees/issue",
|
|
3
4
|
pr: ".magi/worktrees/pr",
|
|
4
5
|
};
|
|
5
6
|
function resolvePath(directory, path) {
|
|
6
7
|
return isAbsolute(path) ? path : join(directory, path);
|
|
7
8
|
}
|
|
8
9
|
export function worktreeBaseDir(directory, config, kind) {
|
|
9
|
-
return resolvePath(directory,
|
|
10
|
+
return resolvePath(directory, kind === "issue"
|
|
11
|
+
? (config.triage?.worktree ?? DEFAULT_WORKTREE_DIRS[kind])
|
|
12
|
+
: (config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]));
|
|
10
13
|
}
|
|
11
14
|
export function worktreeBaseDirs(directory, config = {}) {
|
|
12
|
-
return [
|
|
15
|
+
return [
|
|
16
|
+
worktreeBaseDir(directory, config, "pr"),
|
|
17
|
+
worktreeBaseDir(directory, config, "issue"),
|
|
18
|
+
];
|
|
13
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();
|
|
@@ -80,10 +88,241 @@ export function ghHostOption(repository) {
|
|
|
80
88
|
export async function ghToken(exec, repository, account) {
|
|
81
89
|
return (await exec(`gh auth token${ghHostOption(repository)} --user ${shellQuote(account)}`)).trim();
|
|
82
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
|
+
}
|
|
83
104
|
export async function fetchPullRequest(exec, repository, pr) {
|
|
84
|
-
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner`);
|
|
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`);
|
|
85
106
|
return JSON.parse(json);
|
|
86
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
|
+
}
|
|
87
326
|
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
88
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 } } } } } }`;
|
|
89
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}`);
|
|
@@ -101,7 +340,7 @@ export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
|
101
340
|
}));
|
|
102
341
|
}
|
|
103
342
|
export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
|
|
104
|
-
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 } } } } }`;
|
|
105
344
|
const files = [];
|
|
106
345
|
let author = "";
|
|
107
346
|
let changedFiles = 0;
|
|
@@ -162,8 +401,18 @@ export function isCancelledCheck(check) {
|
|
|
162
401
|
export function isFailedCheck(check) {
|
|
163
402
|
return check.bucket === "fail" || check.state === "FAILURE";
|
|
164
403
|
}
|
|
165
|
-
export async function fetchPullRequestChecks(exec, repository, pr) {
|
|
166
|
-
|
|
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
|
+
}
|
|
167
416
|
return JSON.parse(raw);
|
|
168
417
|
}
|
|
169
418
|
export async function fetchWorkflowRunMeta(exec, repository, runId) {
|
|
@@ -247,19 +496,22 @@ export async function removeBranch(exec, branch) {
|
|
|
247
496
|
}
|
|
248
497
|
export async function postApproval(exec, repository, pr, account) {
|
|
249
498
|
const token = await ghToken(exec, repository, account);
|
|
250
|
-
return exec(`
|
|
499
|
+
return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
|
|
251
500
|
}
|
|
252
501
|
export async function postCloseComment(exec, repository, pr, account, body) {
|
|
253
502
|
const token = await ghToken(exec, repository, account);
|
|
254
503
|
const payloadPath = join(tmpdir(), `magi-close-${process.pid}-${Date.now()}.json`);
|
|
255
504
|
await writeFile(payloadPath, JSON.stringify({ body, event: "COMMENT" }));
|
|
256
505
|
try {
|
|
257
|
-
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));
|
|
258
507
|
}
|
|
259
508
|
finally {
|
|
260
509
|
await rm(payloadPath, { force: true });
|
|
261
510
|
}
|
|
262
511
|
}
|
|
512
|
+
function isInlineFinding(finding) {
|
|
513
|
+
return finding.line != null;
|
|
514
|
+
}
|
|
263
515
|
function findingComment(finding) {
|
|
264
516
|
const comment = {
|
|
265
517
|
body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
|
|
@@ -273,19 +525,58 @@ function findingComment(finding) {
|
|
|
273
525
|
}
|
|
274
526
|
return comment;
|
|
275
527
|
}
|
|
276
|
-
|
|
528
|
+
function requirementFindingSummary(finding) {
|
|
529
|
+
return [
|
|
530
|
+
`- Missing issue #${finding.issueNumber} requirement: ${finding.requirement}`,
|
|
531
|
+
` Evidence: ${finding.evidence}`,
|
|
532
|
+
` Fix: ${finding.fix}`,
|
|
533
|
+
].join("\n");
|
|
534
|
+
}
|
|
535
|
+
function findingLocation(finding) {
|
|
536
|
+
if (finding.line == null)
|
|
537
|
+
return finding.path;
|
|
538
|
+
if (finding.startLine == null)
|
|
539
|
+
return `${finding.path}:${finding.line}`;
|
|
540
|
+
return `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
541
|
+
}
|
|
542
|
+
function findingSummary(finding) {
|
|
543
|
+
return [
|
|
544
|
+
`- ${findingLocation(finding)}: ${finding.issue}`,
|
|
545
|
+
` Fix: ${finding.fix}`,
|
|
546
|
+
]
|
|
547
|
+
.filter(Boolean)
|
|
548
|
+
.join("\n");
|
|
549
|
+
}
|
|
550
|
+
function changesRequestedBody(findings, requirementFindings) {
|
|
551
|
+
const inlineFindings = findings.filter(isInlineFinding);
|
|
552
|
+
const fileLevelFindings = findings.filter((finding) => !isInlineFinding(finding));
|
|
553
|
+
const sections = [];
|
|
554
|
+
if (inlineFindings.length) {
|
|
555
|
+
sections.push(["Inline findings:", ...inlineFindings.map(findingSummary)].join("\n"));
|
|
556
|
+
}
|
|
557
|
+
if (fileLevelFindings.length) {
|
|
558
|
+
sections.push(["File-level findings:", ...fileLevelFindings.map(findingSummary)].join("\n"));
|
|
559
|
+
}
|
|
560
|
+
if (requirementFindings.length) {
|
|
561
|
+
sections.push([
|
|
562
|
+
"Requirement findings:",
|
|
563
|
+
...requirementFindings.map(requirementFindingSummary),
|
|
564
|
+
].join("\n"));
|
|
565
|
+
}
|
|
566
|
+
return sections.join("\n\n");
|
|
567
|
+
}
|
|
568
|
+
export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
|
|
277
569
|
const token = await ghToken(exec, repository, account);
|
|
278
570
|
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
.join("\n");
|
|
571
|
+
const inlineFindings = findings.filter(isInlineFinding);
|
|
572
|
+
const body = changesRequestedBody(findings, requirementFindings);
|
|
282
573
|
await writeFile(payloadPath, JSON.stringify({
|
|
283
574
|
body,
|
|
284
|
-
comments:
|
|
575
|
+
comments: inlineFindings.map(findingComment),
|
|
285
576
|
event: "REQUEST_CHANGES",
|
|
286
577
|
}));
|
|
287
578
|
try {
|
|
288
|
-
return await exec(`
|
|
579
|
+
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));
|
|
289
580
|
}
|
|
290
581
|
finally {
|
|
291
582
|
await rm(payloadPath, { force: true });
|
|
@@ -293,6 +584,11 @@ export async function postChangesRequested(exec, repository, pr, account, findin
|
|
|
293
584
|
}
|
|
294
585
|
export async function mergePullRequest(exec, repository, pr, account) {
|
|
295
586
|
const token = await ghToken(exec, repository, account);
|
|
587
|
+
if (repository.merge.mergeQueue) {
|
|
588
|
+
const queueInput = await fetchPullRequestQueueInput(exec, repository, pr, token);
|
|
589
|
+
const query = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }`;
|
|
590
|
+
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));
|
|
591
|
+
}
|
|
296
592
|
const methodFlag = repository.merge.method === "merge"
|
|
297
593
|
? "--merge"
|
|
298
594
|
: repository.merge.method === "rebase"
|
|
@@ -300,18 +596,29 @@ export async function mergePullRequest(exec, repository, pr, account) {
|
|
|
300
596
|
: "--squash";
|
|
301
597
|
const autoFlag = repository.merge.auto ? " --auto" : "";
|
|
302
598
|
const deleteFlag = repository.merge.deleteBranch ? " --delete-branch" : "";
|
|
303
|
-
return exec(`
|
|
599
|
+
return exec(`gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`, ghTokenEnv(token));
|
|
304
600
|
}
|
|
305
601
|
export async function fetchPullRequestMergeStatus(exec, repository, pr) {
|
|
306
602
|
const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json state,mergeStateStatus,autoMergeRequest`);
|
|
307
603
|
return JSON.parse(json);
|
|
308
604
|
}
|
|
605
|
+
export async function fetchPullRequestQueueStatus(exec, repository, pr) {
|
|
606
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { state isInMergeQueue mergeQueueEntry { id } } } }`;
|
|
607
|
+
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}`);
|
|
608
|
+
const data = JSON.parse(raw);
|
|
609
|
+
const status = data.data?.repository?.pullRequest;
|
|
610
|
+
if (!status)
|
|
611
|
+
throw new Error(`Could not fetch merge queue status for #${pr}`);
|
|
612
|
+
return status;
|
|
613
|
+
}
|
|
309
614
|
export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_000) {
|
|
310
615
|
for (;;) {
|
|
311
|
-
const status = await
|
|
616
|
+
const status = await fetchPullRequestQueueStatus(exec, repository, pr);
|
|
312
617
|
if (status.state === "MERGED")
|
|
313
618
|
return "merged";
|
|
314
|
-
if (status.state === "OPEN" &&
|
|
619
|
+
if (status.state === "OPEN" &&
|
|
620
|
+
!status.isInMergeQueue &&
|
|
621
|
+
status.mergeQueueEntry == null) {
|
|
315
622
|
return "dequeued";
|
|
316
623
|
}
|
|
317
624
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
@@ -319,12 +626,28 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
|
|
|
319
626
|
}
|
|
320
627
|
export async function closePullRequest(exec, repository, pr, account) {
|
|
321
628
|
const token = await ghToken(exec, repository, account);
|
|
322
|
-
return exec(`
|
|
629
|
+
return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
323
630
|
}
|
|
324
631
|
export async function pushHead(exec, repository, worktreePath, account, head) {
|
|
325
632
|
const token = await ghToken(exec, repository, account);
|
|
326
633
|
const url = repositoryGitUrl(repository, head.owner, head.repo);
|
|
327
|
-
await exec(`git
|
|
634
|
+
await exec(`git push ${shellQuote(url)} ${shellQuote(`HEAD:refs/heads/${head.ref}`)}`, {
|
|
635
|
+
cwd: worktreePath,
|
|
636
|
+
env: {
|
|
637
|
+
GIT_CONFIG_COUNT: "2",
|
|
638
|
+
GIT_CONFIG_KEY_0: "credential.helper",
|
|
639
|
+
GIT_CONFIG_KEY_1: "credential.helper",
|
|
640
|
+
GIT_CONFIG_VALUE_0: "",
|
|
641
|
+
GIT_CONFIG_VALUE_1: "!f() { echo username=x-access-token; echo password=$GIT_PASSWORD; }; f",
|
|
642
|
+
GIT_PASSWORD: token,
|
|
643
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
export async function createPullRequest(exec, repository, account, input) {
|
|
648
|
+
const token = await ghToken(exec, repository, account);
|
|
649
|
+
const baseFlag = input.base ? ` --base ${shellQuote(input.base)}` : "";
|
|
650
|
+
return exec(`gh pr create --repo ${shellQuote(repoSpecifier(repository))} --head ${shellQuote(input.head)}${baseFlag} --title ${shellQuote(input.title)} --body ${shellQuote(input.body)}`, ghTokenEnv(token));
|
|
328
651
|
}
|
|
329
652
|
export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
330
653
|
if (identity.name) {
|
|
@@ -384,12 +707,51 @@ export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
|
384
707
|
];
|
|
385
708
|
});
|
|
386
709
|
}
|
|
710
|
+
export async function fetchPullRequestReviewThreads(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
711
|
+
return (await fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit, commentsPerThread)).threads;
|
|
712
|
+
}
|
|
713
|
+
export async function fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
|
|
714
|
+
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 } } } } } } }`;
|
|
715
|
+
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}`);
|
|
716
|
+
const data = JSON.parse(raw);
|
|
717
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
718
|
+
const nodes = connection?.nodes ?? [];
|
|
719
|
+
const threads = nodes.flatMap((thread) => {
|
|
720
|
+
const comments = [...thread.comments.nodes]
|
|
721
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
722
|
+
.map((comment) => ({
|
|
723
|
+
author: comment.author?.login ?? "",
|
|
724
|
+
body: comment.body ?? "",
|
|
725
|
+
commentId: comment.databaseId,
|
|
726
|
+
createdAt: comment.createdAt,
|
|
727
|
+
}));
|
|
728
|
+
const first = thread.comments.nodes[0];
|
|
729
|
+
if (!first)
|
|
730
|
+
return [];
|
|
731
|
+
return [
|
|
732
|
+
{
|
|
733
|
+
body: first.body ?? "",
|
|
734
|
+
commentId: first.databaseId,
|
|
735
|
+
comments,
|
|
736
|
+
isResolved: thread.isResolved,
|
|
737
|
+
line: first.line,
|
|
738
|
+
omittedComments: Math.max(0, (thread.comments.totalCount ?? comments.length) - comments.length),
|
|
739
|
+
path: first.path,
|
|
740
|
+
threadId: thread.id,
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
});
|
|
744
|
+
return {
|
|
745
|
+
omitted: Math.max(0, (connection?.totalCount ?? threads.length) - threads.length),
|
|
746
|
+
threads,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
387
749
|
export async function postReply(exec, repository, pr, account, commentId, body) {
|
|
388
750
|
const token = await ghToken(exec, repository, account);
|
|
389
751
|
const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
|
|
390
752
|
await writeFile(payloadPath, JSON.stringify({ body }));
|
|
391
753
|
try {
|
|
392
|
-
return await exec(`
|
|
754
|
+
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));
|
|
393
755
|
}
|
|
394
756
|
finally {
|
|
395
757
|
await rm(payloadPath, { force: true });
|
|
@@ -398,5 +760,5 @@ export async function postReply(exec, repository, pr, account, commentId, body)
|
|
|
398
760
|
export async function resolveThread(exec, repository, account, threadId) {
|
|
399
761
|
const token = await ghToken(exec, repository, account);
|
|
400
762
|
const query = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id } } }`;
|
|
401
|
-
await exec(`
|
|
763
|
+
await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`, ghTokenEnv(token));
|
|
402
764
|
}
|