opencode-magi 0.4.0 → 0.6.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 CHANGED
@@ -61,26 +61,33 @@ Add the following content to the configuration file.
61
61
  ```json
62
62
  {
63
63
  "$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
64
- "review": {
65
- "agents": [
66
- {
67
- "account": "your-account-1",
68
- "model": "openai/gpt-5.5"
64
+ "agents": {
65
+ "refs": {
66
+ "account-1": {
67
+ "model": "openai/gpt-5.5",
68
+ "account": "account-1"
69
69
  },
70
- {
71
- "account": "your-account-2",
72
- "model": "anthropic/claude-opus-4-7"
70
+ "account-2": {
71
+ "model": "anthropic/claude-opus-4-7",
72
+ "account": "account-2"
73
73
  },
74
- {
75
- "account": "your-account-3",
76
- "model": "opencode/kimi-k2-6"
74
+ "account-3": {
75
+ "model": "opencode/kimi-k2-6",
76
+ "account": "account-3"
77
77
  }
78
+ }
79
+ },
80
+ "review": {
81
+ "agents": [
82
+ { "ref": "account-1" },
83
+ { "ref": "account-2" },
84
+ { "ref": "account-3" }
78
85
  ]
79
86
  }
80
87
  }
81
88
  ```
82
89
 
83
- `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
90
+ After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
84
91
 
85
92
  #### Set project config
86
93
 
@@ -101,53 +108,54 @@ Add the following content to the configuration file.
101
108
  "owner": "your-owner",
102
109
  "repo": "your-repo"
103
110
  },
104
- "review": {
105
- "agents": [
106
- {
107
- "account": "your-account-1",
108
- "model": "openai/gpt-5.5"
111
+ "agents": {
112
+ "refs": {
113
+ "account-1": {
114
+ "model": "openai/gpt-5.5",
115
+ "account": "account-1"
109
116
  },
110
- {
111
- "account": "your-account-2",
112
- "model": "anthropic/claude-opus-4-7"
117
+ "account-2": {
118
+ "model": "anthropic/claude-opus-4-7",
119
+ "account": "account-2"
113
120
  },
114
- {
115
- "account": "your-account-3",
116
- "model": "opencode/kimi-k2-6"
121
+ "account-3": {
122
+ "model": "opencode/kimi-k2-6",
123
+ "account": "account-3"
124
+ },
125
+ "account-4": {
126
+ "model": "openai/gpt-5.5",
127
+ "account": "account-4",
128
+ "author": {
129
+ "name": "account-4",
130
+ "email": "your-email@example.com"
131
+ }
117
132
  }
133
+ }
134
+ },
135
+ "review": {
136
+ "agents": [
137
+ { "ref": "account-1" },
138
+ { "ref": "account-2" },
139
+ { "ref": "account-3" }
118
140
  ]
119
141
  },
120
142
  "merge": {
121
- "editor": {
122
- "account": "your-editor-account",
123
- "model": "openai/gpt-5.5",
124
- "author": {
125
- "name": "your-account",
126
- "email": "your-email@example.com"
127
- }
128
- }
143
+ "editor": { "ref": "account-4" }
129
144
  },
130
145
  "triage": {
131
- "account": "your-triage-account",
146
+ "account": "account-5",
132
147
  "agents": [
133
- {
134
- "id": "general",
135
- "model": "openai/gpt-5.5"
136
- },
137
- {
138
- "id": "maintenance",
139
- "model": "anthropic/claude-opus-4-7"
140
- },
141
- {
142
- "id": "product",
143
- "model": "opencode/kimi-k2-6"
144
- }
148
+ { "ref": "account-1" },
149
+ { "ref": "account-2" },
150
+ { "ref": "account-3" }
145
151
  ]
146
152
  }
147
153
  }
148
154
  ```
149
155
 
150
- `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
156
+ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
157
+
158
+ After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
151
159
 
152
160
  #### Validate config
153
161
 
@@ -28,7 +28,7 @@ export function reviewerKey(reviewer, index) {
28
28
  return reviewer.id ?? `reviewer-${index + 1}`;
29
29
  }
30
30
  export function triageAgentKey(agent, index) {
31
- return agent.id ?? `triage-${index + 1}`;
31
+ return agent.id ?? `voter-${index + 1}`;
32
32
  }
33
33
  export function validateReviewerId(id) {
34
34
  return ID_PATTERN.test(id);
@@ -104,7 +104,7 @@ export function resolveAgents(config) {
104
104
  triageCreator: creator
105
105
  ? {
106
106
  ...creator,
107
- account: creator.account ?? config.triage?.account ?? "",
107
+ account: creator.account ?? "",
108
108
  permission: resolveTriageCreatorPermission(agents, creator),
109
109
  }
110
110
  : undefined,
@@ -179,7 +179,6 @@ export function resolveRepository(config) {
179
179
  requiredLabels: config.review?.safety?.requiredLabels ?? [],
180
180
  },
181
181
  triage: {
182
- account: config.triage?.account,
183
182
  automation: {
184
183
  clear: config.triage?.automation?.clear ?? ["triage"],
185
184
  close: config.triage?.automation?.close ?? false,
@@ -193,6 +192,7 @@ export function resolveRepository(config) {
193
192
  },
194
193
  output: config.triage?.output,
195
194
  prompts: config.triage?.prompts ?? {},
195
+ reporter: config.triage?.reporter,
196
196
  safety: {
197
197
  allowAuthors: config.triage?.safety?.allowAuthors ?? [],
198
198
  allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
@@ -40,6 +40,7 @@ const EDITOR_KEYS = new Set([
40
40
  "persona",
41
41
  ]);
42
42
  const TRIAGE_AGENT_KEYS = new Set([
43
+ "account",
43
44
  "id",
44
45
  "model",
45
46
  "options",
@@ -75,7 +76,6 @@ const MERGE_KEYS = new Set([
75
76
  "prompts",
76
77
  ]);
77
78
  const TRIAGE_KEYS = new Set([
78
- "account",
79
79
  "agents",
80
80
  "automation",
81
81
  "categories",
@@ -83,6 +83,7 @@ const TRIAGE_KEYS = new Set([
83
83
  "creator",
84
84
  "output",
85
85
  "prompts",
86
+ "reporter",
86
87
  "safety",
87
88
  "worktree",
88
89
  ]);
@@ -135,7 +136,6 @@ const MERGE_PROMPT_KEYS = new Set([
135
136
  "editGuidelines",
136
137
  ]);
137
138
  const TRIAGE_PROMPT_KEYS = new Set([
138
- "action",
139
139
  "acceptance",
140
140
  "category",
141
141
  "comment",
@@ -357,6 +357,9 @@ function validateTriageAgentList(agents, path, errors, catalog) {
357
357
  errors.push(`${path}[${index}].model is required`);
358
358
  validateString(agent.model, `${path}[${index}].model`, errors);
359
359
  validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
360
+ if (!agent.account)
361
+ errors.push(`${path}[${index}].account is required`);
362
+ validateString(agent.account, `${path}[${index}].account`, errors);
360
363
  validateString(agent.persona, `${path}[${index}].persona`, errors);
361
364
  if (agent.options != null && !isPlainObject(agent.options))
362
365
  errors.push(`${path}[${index}].options must be an object`);
@@ -383,12 +386,16 @@ function validateResolvedReviewers(reviewers, path, errors) {
383
386
  accounts.add(reviewer.account);
384
387
  }
385
388
  }
386
- function validateResolvedAgentKeys(agents, path, errors) {
389
+ function validateResolvedTriageAgents(agents, path, errors) {
387
390
  const keys = new Set();
391
+ const accounts = new Set();
388
392
  for (const agent of agents) {
389
393
  if (keys.has(agent.key))
390
394
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
391
395
  keys.add(agent.key);
396
+ if (accounts.has(agent.account))
397
+ errors.push(`${path} has duplicate agent account: ${agent.account}`);
398
+ accounts.add(agent.account);
392
399
  }
393
400
  }
394
401
  function validateEditor(editor, path, errors, catalog) {
@@ -672,19 +679,26 @@ function validateTriage(config, errors, options) {
672
679
  validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
673
680
  const automation = triage.automation;
674
681
  const concurrency = triage.concurrency;
682
+ const creator = triage.creator;
683
+ const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
675
684
  const safety = triage.safety;
676
- if (!triage.account)
677
- errors.push("triage.account is required");
678
- validateString(triage.account, "triage.account", errors);
679
685
  if (!triage.agents)
680
686
  errors.push("triage.agents is required");
681
687
  validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
682
688
  if (Array.isArray(triage.agents)) {
683
- validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
689
+ const resolvedTriageAgents = resolveAgents(config).triage ?? [];
690
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
691
+ if (reporter != null &&
692
+ !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
693
+ errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
694
+ }
684
695
  }
685
- validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
686
- if (automation?.create && !triage.creator)
696
+ validateString(triage.reporter, "triage.reporter", errors);
697
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
698
+ if (automation?.create && !creator)
687
699
  errors.push("triage.creator is required when triage.automation.create is true");
700
+ if (automation?.create && creator && !creator.account)
701
+ errors.push("triage.creator.account is required when triage.automation.create is true");
688
702
  if (automation != null && !isPlainObject(automation)) {
689
703
  errors.push("triage.automation must be an object");
690
704
  }
@@ -745,10 +759,10 @@ async function validateAuth(config, exec, errors) {
745
759
  const agents = resolveAgents(config);
746
760
  for (const reviewer of agents.reviewers)
747
761
  accounts.add(reviewer.account);
762
+ for (const agent of agents.triage ?? [])
763
+ accounts.add(agent.account);
748
764
  if (agents.editor)
749
765
  accounts.add(agents.editor.account);
750
- if (config.triage?.account)
751
- accounts.add(config.triage.account);
752
766
  if (agents.triageCreator?.account)
753
767
  accounts.add(agents.triageCreator.account);
754
768
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
@@ -802,19 +816,18 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
802
816
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
803
817
  }
804
818
  }));
805
- if (config.triage?.account) {
819
+ await Promise.all((agents.triage ?? []).map(async (agent) => {
806
820
  try {
807
- const permissions = await fetchPermissions(config, exec, config.triage.account);
821
+ const permissions = await fetchPermissions(config, exec, agent.account);
808
822
  if (!permissions.pull) {
809
- errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
823
+ errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
810
824
  }
811
825
  }
812
826
  catch (error) {
813
- warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
827
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
814
828
  }
815
- }
816
- if (agents.triageCreator?.account &&
817
- agents.triageCreator.account !== config.triage?.account) {
829
+ }));
830
+ if (agents.triageCreator?.account) {
818
831
  try {
819
832
  const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
820
833
  if (!permissions.push) {
@@ -17,3 +17,9 @@ export function worktreeBaseDirs(directory, config = {}) {
17
17
  worktreeBaseDir(directory, config, "issue"),
18
18
  ];
19
19
  }
20
+ export function prRunWorktreeDir(input) {
21
+ return join(worktreeBaseDir(input.directory, input.config, "pr"), String(input.pr), input.runId);
22
+ }
23
+ export function issueRunWorktreeDir(input) {
24
+ return join(worktreeBaseDir(input.directory, input.config, "issue"), String(input.issue), input.runId);
25
+ }
@@ -20,6 +20,68 @@ function errorText(error) {
20
20
  .filter((item) => typeof item === "string")
21
21
  .join("\n");
22
22
  }
23
+ async function localCommitExists(exec, worktreePath, sha) {
24
+ try {
25
+ await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
26
+ cwd: worktreePath,
27
+ });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function pullRequestCommitSource(input) {
35
+ if (input.source === "base") {
36
+ return {
37
+ owner: input.repository.github.owner,
38
+ refName: input.meta.baseRefName,
39
+ repo: input.repository.github.repo,
40
+ };
41
+ }
42
+ return {
43
+ owner: input.meta.headRepositoryOwner?.login ?? input.repository.github.owner,
44
+ refName: input.meta.headRefName,
45
+ repo: input.meta.headRepository?.name ?? input.repository.github.repo,
46
+ };
47
+ }
48
+ async function fetchPullRequestCommitSource(input) {
49
+ const commitSource = pullRequestCommitSource(input);
50
+ try {
51
+ await input.exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(input.repository, commitSource.owner, commitSource.repo))} ${shellQuote(`refs/heads/${commitSource.refName}`)}`, { cwd: input.worktreePath });
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Could not fetch ${input.source} ref ${commitSource.refName} for #${input.meta.number}: ${errorText(error)}`);
55
+ }
56
+ }
57
+ export async function ensurePullRequestCommits(input) {
58
+ const missing = [];
59
+ for (const commit of input.commits) {
60
+ if (!(await localCommitExists(input.exec, input.worktreePath, commit.sha))) {
61
+ missing.push(commit);
62
+ }
63
+ }
64
+ for (const source of new Set(missing.map((commit) => commit.source))) {
65
+ await fetchPullRequestCommitSource({
66
+ exec: input.exec,
67
+ meta: input.meta,
68
+ repository: input.repository,
69
+ source,
70
+ worktreePath: input.worktreePath,
71
+ });
72
+ }
73
+ for (const commit of missing) {
74
+ if (await localCommitExists(input.exec, input.worktreePath, commit.sha)) {
75
+ continue;
76
+ }
77
+ const source = pullRequestCommitSource({
78
+ meta: input.meta,
79
+ repository: input.repository,
80
+ source: commit.source,
81
+ });
82
+ throw new Error(`${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${source.refName}`);
83
+ }
84
+ }
23
85
  function isCheckoutConfigLockError(error) {
24
86
  const text = errorText(error);
25
87
  return (/could not lock config file/i.test(text) ||
@@ -246,6 +308,12 @@ function duplicateReferences(text) {
246
308
  refs.add(Number(match[1]));
247
309
  return [...refs];
248
310
  }
311
+ function issueTitleSearchQuery(title, fallback) {
312
+ return (title
313
+ .replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
314
+ .replaceAll(/\s+/g, " ")
315
+ .trim() || fallback);
316
+ }
249
317
  async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
250
318
  const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
251
319
  if (!raw)
@@ -254,7 +322,7 @@ async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
254
322
  return { ...data, whyCandidate };
255
323
  }
256
324
  export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
257
- const query = issue.title;
325
+ const query = issueTitleSearchQuery(issue.title, String(issue.number));
258
326
  const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
259
327
  .filter((number) => number !== issue.number)
260
328
  .map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
@@ -324,16 +392,43 @@ export async function removeIssueLabels(exec, repository, issue, labels, account
324
392
  return removed;
325
393
  }
326
394
  export async function fetchPullRequestReviews(exec, repository, pr) {
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 } } } } } }`;
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}`);
329
- const data = JSON.parse(raw);
330
- return data.data.repository.pullRequest.reviews.nodes;
395
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100, after: $cursor) { nodes { author { login } submittedAt state body commit { oid } comments(first: 100) { nodes { body path line startLine } } } pageInfo { hasNextPage endCursor } } } } }`;
396
+ const reviews = [];
397
+ let cursor;
398
+ for (;;) {
399
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
400
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
401
+ const data = JSON.parse(raw);
402
+ const connection = data.data.repository.pullRequest.reviews;
403
+ reviews.push(...connection.nodes);
404
+ if (!connection.pageInfo?.hasNextPage)
405
+ break;
406
+ cursor = connection.pageInfo.endCursor;
407
+ if (!cursor)
408
+ throw new Error("GitHub reviews page was truncated");
409
+ }
410
+ return reviews.map((review) => ({
411
+ ...review,
412
+ comments: review.comments?.nodes ?? [],
413
+ }));
331
414
  }
332
415
  export async function fetchPullRequestCommits(exec, repository, pr) {
333
- const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100) { nodes { commit { oid committedDate parents { totalCount } } } } } } }`;
334
- 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}`);
335
- const data = JSON.parse(raw);
336
- return data.data.repository.pullRequest.commits.nodes.map(({ commit }) => ({
416
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100, after: $cursor) { nodes { commit { oid committedDate parents { totalCount } } } pageInfo { hasNextPage endCursor } } } } }`;
417
+ const commits = [];
418
+ let cursor;
419
+ for (;;) {
420
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
421
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
422
+ const data = JSON.parse(raw);
423
+ const connection = data.data.repository.pullRequest.commits;
424
+ commits.push(...connection.nodes);
425
+ if (!connection.pageInfo?.hasNextPage)
426
+ break;
427
+ cursor = connection.pageInfo.endCursor;
428
+ if (!cursor)
429
+ throw new Error("GitHub commits page was truncated");
430
+ }
431
+ return commits.map(({ commit }) => ({
337
432
  committedDate: commit.committedDate,
338
433
  oid: commit.oid,
339
434
  parentCount: commit.parents.totalCount,
@@ -363,38 +458,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
363
458
  }
364
459
  return { author, changedFiles, files, labels };
365
460
  }
366
- export async function waitForChecks(exec, repository, pr, enabled = repository.checks.waitBeforeReview) {
367
- if (!enabled)
368
- return undefined;
369
- const report = {
370
- attempts: 0,
371
- excluded: [],
372
- failed: [],
373
- rerun: [],
374
- scopeInside: [],
375
- scopeOutsideRecovered: [],
376
- scopeOutsideUnresolved: [],
377
- };
378
- try {
379
- await watchChecks(exec, repository, pr);
380
- return report;
381
- }
382
- catch {
383
- report.failed = applyCheckExclusions({
384
- checks: await fetchFailedChecks(exec, repository, pr),
385
- excluded: report.excluded,
386
- patterns: repository.checks.exclude,
387
- });
388
- return report;
389
- }
390
- }
391
461
  export async function watchChecks(exec, repository, pr) {
392
462
  await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
393
463
  }
394
- export async function fetchFailedChecks(exec, repository, pr) {
395
- const checks = await fetchPullRequestChecks(exec, repository, pr);
396
- return checks.filter((check) => isFailedCheck(check) || isCancelledCheck(check));
397
- }
398
464
  export function isCancelledCheck(check) {
399
465
  return check.bucket === "cancel" || check.state === "CANCELLED";
400
466
  }
@@ -466,9 +532,8 @@ export async function fetchMergeQueueRequirement(exec, repository, branch) {
466
532
  const rules = JSON.parse(raw);
467
533
  return rules.some((rule) => rule.type === "merge_queue");
468
534
  }
469
- export async function createWorktree(exec, repository, pr, root) {
470
- const worktreePath = join(root, `pr-${pr}`);
471
- const lockKey = `${repoSpecifier(repository)}:${root}`;
535
+ export async function createWorktree(exec, repository, pr, worktreePath) {
536
+ const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
472
537
  return withWorktreeCreateLock(lockKey, async () => {
473
538
  let worktreeAdded = false;
474
539
  try {
@@ -509,9 +574,6 @@ export async function postCloseComment(exec, repository, pr, account, body) {
509
574
  await rm(payloadPath, { force: true });
510
575
  }
511
576
  }
512
- function isInlineFinding(finding) {
513
- return finding.line != null;
514
- }
515
577
  function findingComment(finding) {
516
578
  const comment = {
517
579
  body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
@@ -525,54 +587,18 @@ function findingComment(finding) {
525
587
  }
526
588
  return comment;
527
589
  }
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");
590
+ function changesRequestedBody(findings) {
591
+ return findings.length === 1
592
+ ? "Changes requested: 1 inline comment."
593
+ : `Changes requested: ${findings.length} inline comments.`;
567
594
  }
568
- export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
595
+ export async function postChangesRequested(exec, repository, pr, account, findings) {
569
596
  const token = await ghToken(exec, repository, account);
570
597
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
571
- const inlineFindings = findings.filter(isInlineFinding);
572
- const body = changesRequestedBody(findings, requirementFindings);
598
+ const body = changesRequestedBody(findings);
573
599
  await writeFile(payloadPath, JSON.stringify({
574
600
  body,
575
- comments: inlineFindings.map(findingComment),
601
+ comments: findings.map(findingComment),
576
602
  event: "REQUEST_CHANGES",
577
603
  }));
578
604
  try {
@@ -624,6 +650,17 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
624
650
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
625
651
  }
626
652
  }
653
+ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000) {
654
+ for (;;) {
655
+ const status = await fetchPullRequestMergeStatus(exec, repository, pr);
656
+ if (status.state === "MERGED")
657
+ return "merged";
658
+ if (status.state !== "OPEN" || status.autoMergeRequest == null) {
659
+ return "dequeued";
660
+ }
661
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
662
+ }
663
+ }
627
664
  export async function closePullRequest(exec, repository, pr, account) {
628
665
  const token = await ghToken(exec, repository, account);
629
666
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
@@ -662,11 +699,49 @@ export async function configureGitIdentity(exec, worktreePath, identity) {
662
699
  }
663
700
  }
664
701
  export async function fetchUnresolvedThreads(exec, repository, pr, author) {
665
- const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { id isResolved comments(first: 50) { nodes { databaseId author { login } path line body createdAt } } } } } } }`;
666
- 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}`);
667
- const data = JSON.parse(raw);
668
- const threads = data.data.repository.pullRequest.reviewThreads
669
- .nodes;
702
+ const threadQuery = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { nodes { id isResolved comments(first: 100) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`;
703
+ const commentQuery = `query($threadId: ID!, $cursor: String) { node(id: $threadId) { ... on PullRequestReviewThread { comments(first: 100, after: $cursor) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } } }`;
704
+ const threads = [];
705
+ let cursor;
706
+ async function fetchRemainingComments(threadId, initialCursor) {
707
+ const comments = [];
708
+ let commentsCursor = initialCursor;
709
+ for (;;) {
710
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(commentQuery)} -F threadId=${shellQuote(threadId)} -F cursor=${shellQuote(commentsCursor)}`);
711
+ const data = JSON.parse(raw);
712
+ const connection = data.data.node?.comments;
713
+ if (!connection)
714
+ throw new Error("GitHub review thread comments were missing");
715
+ comments.push(...connection.nodes);
716
+ if (!connection.pageInfo?.hasNextPage)
717
+ break;
718
+ const nextCursor = connection.pageInfo.endCursor;
719
+ if (!nextCursor)
720
+ throw new Error("GitHub review thread comments page was truncated");
721
+ commentsCursor = nextCursor;
722
+ }
723
+ return comments;
724
+ }
725
+ for (;;) {
726
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
727
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(threadQuery)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
728
+ const data = JSON.parse(raw);
729
+ const connection = data.data.repository.pullRequest.reviewThreads;
730
+ for (const thread of connection.nodes) {
731
+ const commentsCursor = thread.comments.pageInfo?.endCursor;
732
+ if (thread.comments.pageInfo?.hasNextPage) {
733
+ if (!commentsCursor)
734
+ throw new Error("GitHub review thread comments page was truncated");
735
+ thread.comments.nodes.push(...(await fetchRemainingComments(thread.id, commentsCursor)));
736
+ }
737
+ }
738
+ threads.push(...connection.nodes);
739
+ if (!connection.pageInfo?.hasNextPage)
740
+ break;
741
+ cursor = connection.pageInfo.endCursor;
742
+ if (!cursor)
743
+ throw new Error("GitHub review threads page was truncated");
744
+ }
670
745
  return threads.flatMap((thread) => {
671
746
  if (thread.isResolved || !thread.comments.nodes.length)
672
747
  return [];