opencode-magi 0.2.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.
Files changed (35) hide show
  1. package/README.md +19 -0
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +81 -1
  5. package/dist/config/validate.js +290 -3
  6. package/dist/config/worktree.js +8 -2
  7. package/dist/github/commands.js +343 -15
  8. package/dist/index.js +252 -26
  9. package/dist/orchestrator/ci.js +1 -1
  10. package/dist/orchestrator/findings.js +4 -3
  11. package/dist/orchestrator/inline-comments.js +73 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +16 -3
  14. package/dist/orchestrator/report.js +15 -1
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +49 -9
  17. package/dist/orchestrator/run-manager.js +408 -17
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +162 -1
  21. package/dist/prompts/contracts.js +119 -12
  22. package/dist/prompts/output.js +149 -14
  23. package/dist/prompts/templates/review/review.md +6 -0
  24. package/dist/prompts/templates/triage/acceptance.md +7 -0
  25. package/dist/prompts/templates/triage/action.md +5 -0
  26. package/dist/prompts/templates/triage/category.md +10 -0
  27. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  28. package/dist/prompts/templates/triage/comment.md +5 -0
  29. package/dist/prompts/templates/triage/create.md +7 -0
  30. package/dist/prompts/templates/triage/duplicate.md +7 -0
  31. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  32. package/dist/prompts/templates/triage/question.md +5 -0
  33. package/dist/prompts/templates/triage/reconsider.md +5 -0
  34. package/package.json +5 -2
  35. package/schema.json +127 -2
@@ -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
- const raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow`);
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,14 +496,14 @@ 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(`GH_TOKEN=${shellQuote(token)} gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`);
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(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
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 });
@@ -273,11 +522,19 @@ function findingComment(finding) {
273
522
  }
274
523
  return comment;
275
524
  }
276
- export async function postChangesRequested(exec, repository, pr, account, findings) {
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 = []) {
277
533
  const token = await ghToken(exec, repository, account);
278
534
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
279
535
  const body = findings
280
536
  .map((finding) => `- ${finding.issue.split("\n")[0]}`)
537
+ .concat(requirementFindings.map(requirementFindingSummary))
281
538
  .join("\n");
282
539
  await writeFile(payloadPath, JSON.stringify({
283
540
  body,
@@ -285,7 +542,7 @@ export async function postChangesRequested(exec, repository, pr, account, findin
285
542
  event: "REQUEST_CHANGES",
286
543
  }));
287
544
  try {
288
- return await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
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));
289
546
  }
290
547
  finally {
291
548
  await rm(payloadPath, { force: true });
@@ -293,6 +550,11 @@ export async function postChangesRequested(exec, repository, pr, account, findin
293
550
  }
294
551
  export async function mergePullRequest(exec, repository, pr, account) {
295
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
+ }
296
558
  const methodFlag = repository.merge.method === "merge"
297
559
  ? "--merge"
298
560
  : repository.merge.method === "rebase"
@@ -300,18 +562,29 @@ export async function mergePullRequest(exec, repository, pr, account) {
300
562
  : "--squash";
301
563
  const autoFlag = repository.merge.auto ? " --auto" : "";
302
564
  const deleteFlag = repository.merge.deleteBranch ? " --delete-branch" : "";
303
- return exec(`GH_TOKEN=${shellQuote(token)} gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`);
565
+ return exec(`gh pr merge ${pr} --repo ${shellQuote(repoSpecifier(repository))} ${methodFlag}${autoFlag}${deleteFlag}`, ghTokenEnv(token));
304
566
  }
305
567
  export async function fetchPullRequestMergeStatus(exec, repository, pr) {
306
568
  const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json state,mergeStateStatus,autoMergeRequest`);
307
569
  return JSON.parse(json);
308
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
+ }
309
580
  export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_000) {
310
581
  for (;;) {
311
- const status = await fetchPullRequestMergeStatus(exec, repository, pr);
582
+ const status = await fetchPullRequestQueueStatus(exec, repository, pr);
312
583
  if (status.state === "MERGED")
313
584
  return "merged";
314
- if (status.state === "OPEN" && status.autoMergeRequest == null) {
585
+ if (status.state === "OPEN" &&
586
+ !status.isInMergeQueue &&
587
+ status.mergeQueueEntry == null) {
315
588
  return "dequeued";
316
589
  }
317
590
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -319,12 +592,28 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
319
592
  }
320
593
  export async function closePullRequest(exec, repository, pr, account) {
321
594
  const token = await ghToken(exec, repository, account);
322
- return exec(`GH_TOKEN=${shellQuote(token)} gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`);
595
+ return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
323
596
  }
324
597
  export async function pushHead(exec, repository, worktreePath, account, head) {
325
598
  const token = await ghToken(exec, repository, account);
326
599
  const url = repositoryGitUrl(repository, head.owner, head.repo);
327
- await exec(`git -c credential.helper= -c credential.helper=${shellQuote(`!f() { echo username=x-access-token; echo password=${token}; }; f`)} push ${shellQuote(url)} ${shellQuote(`HEAD:refs/heads/${head.ref}`)}`, { cwd: worktreePath });
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));
328
617
  }
329
618
  export async function configureGitIdentity(exec, worktreePath, identity) {
330
619
  if (identity.name) {
@@ -384,12 +673,51 @@ export async function fetchUnresolvedThreads(exec, repository, pr, author) {
384
673
  ];
385
674
  });
386
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
+ }
387
715
  export async function postReply(exec, repository, pr, account, commentId, body) {
388
716
  const token = await ghToken(exec, repository, account);
389
717
  const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
390
718
  await writeFile(payloadPath, JSON.stringify({ body }));
391
719
  try {
392
- return await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/comments/${commentId}/replies --method POST --input ${shellQuote(payloadPath)} --jq .html_url`);
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));
393
721
  }
394
722
  finally {
395
723
  await rm(payloadPath, { force: true });
@@ -398,5 +726,5 @@ export async function postReply(exec, repository, pr, account, commentId, body)
398
726
  export async function resolveThread(exec, repository, account, threadId) {
399
727
  const token = await ghToken(exec, repository, account);
400
728
  const query = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id } } }`;
401
- await exec(`GH_TOKEN=${shellQuote(token)} gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`);
729
+ await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F threadId=${shellQuote(threadId)}`, ghTokenEnv(token));
402
730
  }