opencode-magi 0.7.0 → 0.9.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
@@ -17,7 +17,7 @@ OpenCode Magi recreates the review cycle humans already run on GitHub: multiple
17
17
  - Multi-agent reviews with an odd-number majority of 3 or more reviewers.
18
18
  - Optional unanimous approval policy for merge automation when every reviewer must approve before a PR is merged.
19
19
  - Finding-level voting before posting change requests, so only findings accepted by reviewer majority are submitted.
20
- - Each reviewer acts through its configured GitHub account, posting real reviews, approvals, change requests, and follow-up comments.
20
+ - Multi-account review mode where each reviewer posts through its configured GitHub account, plus single-account review mode where one GitHub account posts the consensus result for multiple logical reviewers.
21
21
  - Re-review support for edited PRs: fixed threads are resolved, satisfied reviewers approve, and remaining issues are posted as additional comments.
22
22
  - Optional merge and close automation where an editor agent responds on behalf of the author, fixes changes it agrees with, pushes commits when needed, and repeats the reviewer/editor cycle until the PR can be approved, queued, merged, or closed.
23
23
  - Per-agent OpenCode permissions for reviewer, CI classifier, and editor child sessions.
@@ -64,20 +64,18 @@ Add the following content to the configuration file.
64
64
  "agents": {
65
65
  "refs": {
66
66
  "account-1": {
67
- "model": "openai/gpt-5.5",
68
- "account": "account-1"
67
+ "model": "openai/gpt-5.5"
69
68
  },
70
69
  "account-2": {
71
- "model": "anthropic/claude-opus-4-7",
72
- "account": "account-2"
70
+ "model": "anthropic/claude-opus-4-7"
73
71
  },
74
72
  "account-3": {
75
- "model": "opencode/kimi-k2-6",
76
- "account": "account-3"
73
+ "model": "opencode/kimi-k2-6"
77
74
  }
78
75
  }
79
76
  },
80
77
  "review": {
78
+ "account": "your-account",
81
79
  "reviewers": [
82
80
  { "ref": "account-1" },
83
81
  { "ref": "account-2" },
@@ -87,7 +85,26 @@ Add the following content to the configuration file.
87
85
  }
88
86
  ```
89
87
 
90
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
88
+ By default, `review.mode` is `"single"`. Magi uses one `review.account` to post reviewer-originated GitHub mutations while still running multiple logical reviewer agents and preserving majority voting, finding validation, and close reconsideration. The account must be authenticated with `gh auth token --user <account>`.
89
+
90
+ For team setups that need separate GitHub review identities, set `review.mode: "multi"` and configure a unique account for each reviewer.
91
+
92
+ ```json
93
+ {
94
+ "review": {
95
+ "mode": "multi",
96
+ "reviewers": [
97
+ { "id": "general", "model": "openai/gpt-5.5", "account": "account-1" },
98
+ {
99
+ "id": "security",
100
+ "model": "anthropic/claude-opus-4-7",
101
+ "account": "account-2"
102
+ },
103
+ { "id": "compat", "model": "opencode/kimi-k2-6", "account": "account-3" }
104
+ ]
105
+ }
106
+ }
107
+ ```
91
108
 
92
109
  #### Set project config
93
110
 
@@ -165,7 +182,7 @@ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` o
165
182
  }
166
183
  ```
167
184
 
168
- After `refs` are expanded, `review.reviewers[].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.
185
+ After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals in `multi` mode. Must be authenticated with `gh auth token --user <account>` and must be unique. In `single` mode, `review.account` is used for reviewer-originated review posts, approvals, change requests, close comments, reviewer replies, and reviewer thread resolutions. `merge.editor.account` is still used by `/magi:merge` to push fixes, close PRs, and merge PRs.
169
186
 
170
187
  #### Validate config
171
188
 
@@ -24,6 +24,31 @@ const DEFAULT_TRIAGE_CATEGORIES = [
24
24
  types: ["Feature"],
25
25
  },
26
26
  ];
27
+ export const DEFAULT_TRIAGE_LABEL_RULES = [
28
+ { remove: ["triage"], when: { disposition: "accepted" } },
29
+ {
30
+ add: ["duplicate"],
31
+ remove: ["triage"],
32
+ when: { disposition: "duplicate" },
33
+ },
34
+ {
35
+ add: ["duplicate"],
36
+ remove: ["triage"],
37
+ when: { disposition: "already_handled" },
38
+ },
39
+ {
40
+ add: ["wontfix"],
41
+ remove: ["triage"],
42
+ when: { disposition: "rejected" },
43
+ },
44
+ {
45
+ add: ["invalid"],
46
+ remove: ["triage"],
47
+ when: { disposition: "invalid" },
48
+ },
49
+ { add: ["question"], when: { disposition: "needs_category" } },
50
+ { add: ["question"], when: { disposition: "needs_acceptance" } },
51
+ ];
27
52
  export function reviewerKey(reviewer, index) {
28
53
  return reviewer.id ?? `reviewer-${index + 1}`;
29
54
  }
@@ -88,6 +113,9 @@ export function resolveAgents(config) {
88
113
  const agents = config.agents ?? {};
89
114
  const editor = config.merge?.editor;
90
115
  const creator = config.triage?.creator;
116
+ const singleReviewAccount = config.review && config.review.mode !== "multi"
117
+ ? config.review.account
118
+ : undefined;
91
119
  return {
92
120
  editor: editor
93
121
  ? {
@@ -98,6 +126,7 @@ export function resolveAgents(config) {
98
126
  : undefined,
99
127
  reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
100
128
  ...reviewer,
129
+ account: singleReviewAccount ?? reviewer.account ?? "",
101
130
  key: reviewerKey(reviewer, index),
102
131
  index,
103
132
  model: normalizedModel(reviewer.model),
@@ -138,6 +167,7 @@ export function resolveRepository(config) {
138
167
  agents: resolveAgents(config),
139
168
  automation: {
140
169
  close: config.merge?.automation?.close ?? false,
170
+ conflict: config.merge?.automation?.conflict ?? false,
141
171
  merge: config.merge?.automation?.merge ?? true,
142
172
  },
143
173
  checks: {
@@ -178,6 +208,10 @@ export function resolveRepository(config) {
178
208
  review: config.review?.prompts?.review,
179
209
  reviewGuidelines: config.review?.prompts?.reviewGuidelines,
180
210
  },
211
+ review: {
212
+ account: config.review?.account,
213
+ mode: config.review?.mode ?? "single",
214
+ },
181
215
  reviewAutomation: {
182
216
  close: config.review?.automation?.close ?? false,
183
217
  merge: config.review?.automation?.merge ?? true,
@@ -190,9 +224,9 @@ export function resolveRepository(config) {
190
224
  },
191
225
  triage: {
192
226
  automation: {
193
- clear: config.triage?.automation?.clear ?? ["triage"],
194
227
  close: config.triage?.automation?.close ?? false,
195
228
  create: config.triage?.automation?.create ?? false,
229
+ label: config.triage?.automation?.label ?? DEFAULT_TRIAGE_LABEL_RULES,
196
230
  merge: config.triage?.automation?.merge ?? false,
197
231
  review: config.triage?.automation?.review ?? false,
198
232
  },
@@ -215,6 +249,7 @@ export function resolveRepository(config) {
215
249
  blockedLabels: config.triage?.safety?.blockedLabels ?? [],
216
250
  requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
217
251
  },
252
+ signals: config.triage?.signals ?? [],
218
253
  worktree: config.triage?.worktree,
219
254
  },
220
255
  };
@@ -54,10 +54,12 @@ const TRIAGE_CREATOR_KEYS = new Set([
54
54
  const AUTHOR_KEYS = new Set(["email", "name"]);
55
55
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
56
56
  const REVIEW_KEYS = new Set([
57
+ "account",
57
58
  "automation",
58
59
  "checks",
59
60
  "concurrency",
60
61
  "merge",
62
+ "mode",
61
63
  "output",
62
64
  "prompts",
63
65
  "reviewers",
@@ -80,6 +82,7 @@ const TRIAGE_KEYS = new Set([
80
82
  "prompts",
81
83
  "reporter",
82
84
  "safety",
85
+ "signals",
83
86
  "voters",
84
87
  "worktree",
85
88
  ]);
@@ -93,17 +96,24 @@ const REVIEW_MERGE_KEYS = new Set([
93
96
  const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
94
97
  const MERGE_CHECKS_KEYS = new Set(["wait"]);
95
98
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
99
+ const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
96
100
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
97
101
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
98
102
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
99
103
  const TRIAGE_AUTOMATION_KEYS = new Set([
100
- "clear",
101
104
  "close",
102
105
  "create",
106
+ "label",
103
107
  "merge",
104
108
  "review",
105
109
  ]);
106
110
  const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
111
+ const TRIAGE_LABEL_RULE_KEYS = new Set(["add", "remove", "when"]);
112
+ const TRIAGE_LABEL_RULE_WHEN_KEYS = new Set([
113
+ "category",
114
+ "disposition",
115
+ "signals",
116
+ ]);
107
117
  const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
108
118
  const TRIAGE_SAFETY_KEYS = new Set([
109
119
  "allowAuthors",
@@ -112,6 +122,18 @@ const TRIAGE_SAFETY_KEYS = new Set([
112
122
  "blockedLabels",
113
123
  "requiredLabels",
114
124
  ]);
125
+ const TRIAGE_SIGNAL_KEYS = new Set(["description", "id"]);
126
+ const TRIAGE_DISPOSITIONS = new Set([
127
+ "accepted",
128
+ "rejected",
129
+ "invalid",
130
+ "duplicate",
131
+ "already_handled",
132
+ "needs_category",
133
+ "needs_acceptance",
134
+ "blocked",
135
+ "failed",
136
+ ]);
115
137
  const SAFETY_KEYS = new Set([
116
138
  "allowAuthors",
117
139
  "blockedPaths",
@@ -364,7 +386,7 @@ function validateAndNormalizeModel(target, path, errors, catalog) {
364
386
  }
365
387
  errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
366
388
  }
367
- function validateReviewerList(reviewers, path, errors, catalog) {
389
+ function validateReviewerList(reviewers, path, errors, catalog, mode = "single") {
368
390
  if (reviewers == null)
369
391
  return;
370
392
  if (!Array.isArray(reviewers)) {
@@ -384,7 +406,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
384
406
  if (!reviewer.model)
385
407
  errors.push(`${path}[${index}].model is required`);
386
408
  validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
387
- if (!reviewer.account)
409
+ if (mode === "multi" && !reviewer.account)
388
410
  errors.push(`${path}[${index}].account is required`);
389
411
  validateString(reviewer.account, `${path}[${index}].account`, errors);
390
412
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
@@ -434,18 +456,31 @@ function validateTriageAgentList(voters, path, errors, catalog) {
434
456
  }
435
457
  });
436
458
  }
437
- function validateResolvedReviewers(reviewers, path, errors) {
459
+ function validateResolvedReviewers(reviewers, path, errors, mode = "single") {
438
460
  const keys = new Set();
439
461
  const accounts = new Set();
440
462
  for (const reviewer of reviewers) {
441
463
  if (keys.has(reviewer.key))
442
464
  errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
443
465
  keys.add(reviewer.key);
444
- if (accounts.has(reviewer.account))
466
+ if (mode === "multi" && accounts.has(reviewer.account))
445
467
  errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
446
468
  accounts.add(reviewer.account);
447
469
  }
448
470
  }
471
+ function reviewMode(config) {
472
+ return config.review?.mode === "multi" ? "multi" : "single";
473
+ }
474
+ function validateReviewIdentity(config, errors) {
475
+ const mode = config.review?.mode;
476
+ if (mode != null && mode !== "multi" && mode !== "single") {
477
+ errors.push("review.mode must be multi or single");
478
+ }
479
+ validateString(config.review?.account, "review.account", errors);
480
+ if ((mode == null || mode === "single") && !config.review?.account) {
481
+ errors.push("review.account is required when review.mode is single");
482
+ }
483
+ }
449
484
  function validateResolvedTriageAgents(agents, path, errors) {
450
485
  const keys = new Set();
451
486
  const accounts = new Set();
@@ -552,7 +587,7 @@ function validateMerge(config, errors, options) {
552
587
  errors.push("merge must be an object");
553
588
  }
554
589
  validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
555
- validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
590
+ validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
556
591
  const checks = merge?.checks;
557
592
  validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
558
593
  validateBoolean(checks?.wait, "merge.checks.wait", errors);
@@ -691,6 +726,79 @@ function validateTriageCategories(categories, path, errors) {
691
726
  validateString(category.description, `${itemPath}.description`, errors);
692
727
  });
693
728
  }
729
+ function validateTriageSignals(signals, path, errors) {
730
+ if (signals == null)
731
+ return;
732
+ if (!Array.isArray(signals)) {
733
+ errors.push(`${path} must be an array`);
734
+ return;
735
+ }
736
+ const ids = new Set();
737
+ signals.forEach((item, index) => {
738
+ const itemPath = `${path}[${index}]`;
739
+ if (!isPlainObject(item)) {
740
+ errors.push(`${itemPath} must be an object`);
741
+ return;
742
+ }
743
+ const signal = item;
744
+ validateKnownKeys(signal, itemPath, TRIAGE_SIGNAL_KEYS, errors);
745
+ if (!signal.id) {
746
+ errors.push(`${itemPath}.id is required`);
747
+ }
748
+ else if (typeof signal.id !== "string") {
749
+ errors.push(`${itemPath}.id must be a string`);
750
+ }
751
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(signal.id)) {
752
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
753
+ }
754
+ else if (ids.has(signal.id)) {
755
+ errors.push(`${itemPath}.id must be unique`);
756
+ }
757
+ else {
758
+ ids.add(signal.id);
759
+ }
760
+ if (!signal.description) {
761
+ errors.push(`${itemPath}.description is required`);
762
+ }
763
+ else {
764
+ validateString(signal.description, `${itemPath}.description`, errors);
765
+ }
766
+ });
767
+ }
768
+ function validateTriageLabelRules(rules, path, errors) {
769
+ if (rules == null)
770
+ return;
771
+ if (!Array.isArray(rules)) {
772
+ errors.push(`${path} must be an array`);
773
+ return;
774
+ }
775
+ rules.forEach((item, index) => {
776
+ const itemPath = `${path}[${index}]`;
777
+ if (!isPlainObject(item)) {
778
+ errors.push(`${itemPath} must be an object`);
779
+ return;
780
+ }
781
+ const rule = item;
782
+ validateKnownKeys(rule, itemPath, TRIAGE_LABEL_RULE_KEYS, errors);
783
+ validateStringArray(rule.add, `${itemPath}.add`, errors);
784
+ validateStringArray(rule.remove, `${itemPath}.remove`, errors);
785
+ if (!isPlainObject(rule.when)) {
786
+ errors.push(`${itemPath}.when must be an object`);
787
+ return;
788
+ }
789
+ validateKnownKeys(rule.when, `${itemPath}.when`, TRIAGE_LABEL_RULE_WHEN_KEYS, errors);
790
+ if (!Object.keys(rule.when).length) {
791
+ errors.push(`${itemPath}.when must not be empty`);
792
+ }
793
+ if (rule.when.disposition != null &&
794
+ (typeof rule.when.disposition !== "string" ||
795
+ !TRIAGE_DISPOSITIONS.has(rule.when.disposition))) {
796
+ errors.push(`${itemPath}.when.disposition must be a triage disposition`);
797
+ }
798
+ validateString(rule.when.category, `${itemPath}.when.category`, errors);
799
+ validateStringArray(rule.when.signals, `${itemPath}.when.signals`, errors);
800
+ });
801
+ }
694
802
  function validateSafety(config, errors) {
695
803
  const safety = config.review?.safety;
696
804
  if (safety != null && !isPlainObject(safety)) {
@@ -764,7 +872,7 @@ function validateTriage(config, errors, options) {
764
872
  validateBoolean(automation?.create, "triage.automation.create", errors);
765
873
  validateBoolean(automation?.merge, "triage.automation.merge", errors);
766
874
  validateBoolean(automation?.review, "triage.automation.review", errors);
767
- validateStringArray(automation?.clear, "triage.automation.clear", errors);
875
+ validateTriageLabelRules(automation?.label, "triage.automation.label", errors);
768
876
  if (automation?.review && !automation.create) {
769
877
  errors.push("triage.automation.review requires triage.automation.create to be true");
770
878
  }
@@ -779,6 +887,7 @@ function validateTriage(config, errors, options) {
779
887
  errors.push("triage.concurrency.runs must be a positive integer");
780
888
  }
781
889
  validateTriageCategories(triage.categories, "triage.categories", errors);
890
+ validateTriageSignals(triage.signals, "triage.signals", errors);
782
891
  validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
783
892
  validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
784
893
  validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
@@ -814,8 +923,14 @@ async function validatePrompts(config, errors, directory) {
814
923
  async function validateAuth(config, exec, errors) {
815
924
  const accounts = new Set();
816
925
  const agents = resolveAgents(config);
817
- for (const reviewer of agents.reviewers)
818
- accounts.add(reviewer.account);
926
+ if (reviewMode(config) === "single") {
927
+ if (config.review?.account)
928
+ accounts.add(config.review.account);
929
+ }
930
+ else {
931
+ for (const reviewer of agents.reviewers)
932
+ accounts.add(reviewer.account);
933
+ }
819
934
  for (const agent of agents.triage ?? [])
820
935
  accounts.add(agent.account);
821
936
  if (agents.editor)
@@ -862,15 +977,20 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
862
977
  if (!config.github?.owner || !config.github.repo)
863
978
  return;
864
979
  const agents = resolveAgents(config);
865
- await Promise.all(agents.reviewers.map(async (reviewer) => {
980
+ const reviewAccounts = reviewMode(config) === "single"
981
+ ? config.review?.account
982
+ ? [config.review.account]
983
+ : []
984
+ : agents.reviewers.map((reviewer) => reviewer.account);
985
+ await Promise.all(reviewAccounts.map(async (account) => {
866
986
  try {
867
- const permissions = await fetchPermissions(config, exec, reviewer.account);
987
+ const permissions = await fetchPermissions(config, exec, account);
868
988
  if (!permissions.pull) {
869
- errors.push(`GitHub account cannot read repository for PR review: ${reviewer.account}`);
989
+ errors.push(`GitHub account cannot read repository for PR review: ${account}`);
870
990
  }
871
991
  }
872
992
  catch (error) {
873
- warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
993
+ warnings.push(`Could not validate repository permissions for GitHub account: ${account} (${error.message})`);
874
994
  }
875
995
  }));
876
996
  await Promise.all((agents.triage ?? []).map(async (agent) => {
@@ -932,15 +1052,17 @@ export async function validateConfig(config, options = {}) {
932
1052
  errors.push("review is required");
933
1053
  }
934
1054
  else if (config.review) {
1055
+ const mode = reviewMode(config);
935
1056
  if (!isPlainObject(config.review)) {
936
1057
  errors.push("review must be an object");
937
1058
  }
938
1059
  else {
939
1060
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
940
1061
  }
1062
+ validateReviewIdentity(config, errors);
941
1063
  if (!config.review.reviewers)
942
1064
  errors.push("review.reviewers is required");
943
- validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
1065
+ validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
944
1066
  if (Array.isArray(config.review.reviewers)) {
945
1067
  validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
946
1068
  account: reviewer &&
@@ -951,7 +1073,7 @@ export async function validateConfig(config, options = {}) {
951
1073
  key: reviewer && typeof reviewer === "object"
952
1074
  ? reviewerKey(reviewer, index)
953
1075
  : "",
954
- })), "review.resolvedReviewers", errors);
1076
+ })), "review.resolvedReviewers", errors, mode);
955
1077
  }
956
1078
  }
957
1079
  if (options.requireTriage && !config.triage) {
@@ -404,6 +404,15 @@ export async function assignIssue(exec, repository, issue, account) {
404
404
  const token = await ghToken(exec, repository, account);
405
405
  return exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-assignee ${shellQuote(account)}`, ghTokenEnv(token));
406
406
  }
407
+ export async function addIssueLabels(exec, repository, issue, labels, account) {
408
+ const token = await ghToken(exec, repository, account);
409
+ const added = [];
410
+ for (const label of labels) {
411
+ await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-label ${shellQuote(label)}`, ghTokenEnv(token));
412
+ added.push(label);
413
+ }
414
+ return added;
415
+ }
407
416
  export async function removeIssueLabels(exec, repository, issue, labels, account) {
408
417
  const token = await ghToken(exec, repository, account);
409
418
  const removed = [];
@@ -583,8 +592,18 @@ export async function removeWorktree(exec, worktreePath) {
583
592
  export async function removeBranch(exec, branch) {
584
593
  await exec(`git branch -D ${shellQuote(branch)}`);
585
594
  }
586
- export async function postApproval(exec, repository, pr, account) {
595
+ export async function postApproval(exec, repository, pr, account, body) {
587
596
  const token = await ghToken(exec, repository, account);
597
+ if (body != null) {
598
+ const payloadPath = join(tmpdir(), `magi-approve-${process.pid}-${Date.now()}.json`);
599
+ await writeFile(payloadPath, JSON.stringify({ body, event: "APPROVE" }));
600
+ try {
601
+ return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
602
+ }
603
+ finally {
604
+ await rm(payloadPath, { force: true });
605
+ }
606
+ }
588
607
  return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
589
608
  }
590
609
  export async function postCloseComment(exec, repository, pr, account, body) {
@@ -598,9 +617,9 @@ export async function postCloseComment(exec, repository, pr, account, body) {
598
617
  await rm(payloadPath, { force: true });
599
618
  }
600
619
  }
601
- function findingComment(finding) {
620
+ function findingComment(finding, body) {
602
621
  const comment = {
603
- body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
622
+ body: body ?? `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
604
623
  line: finding.line,
605
624
  path: finding.path,
606
625
  side: "RIGHT",
@@ -616,13 +635,13 @@ function changesRequestedBody(findings) {
616
635
  ? "Changes requested: 1 inline comment."
617
636
  : `Changes requested: ${findings.length} inline comments.`;
618
637
  }
619
- export async function postChangesRequested(exec, repository, pr, account, findings) {
638
+ export async function postChangesRequested(exec, repository, pr, account, findings, options = {}) {
620
639
  const token = await ghToken(exec, repository, account);
621
640
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
622
- const body = changesRequestedBody(findings);
641
+ const body = options.body ?? changesRequestedBody(findings);
623
642
  await writeFile(payloadPath, JSON.stringify({
624
643
  body,
625
- comments: findings.map(findingComment),
644
+ comments: findings.map((finding, index) => findingComment(finding, options.commentBodies?.[index])),
626
645
  event: "REQUEST_CHANGES",
627
646
  }));
628
647
  try {
@@ -685,6 +704,29 @@ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000
685
704
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
686
705
  }
687
706
  }
707
+ export async function fetchBaseBranch(exec, repository, meta, worktreePath) {
708
+ await exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(repository, repository.github.owner, repository.github.repo))} ${shellQuote(`refs/heads/${meta.baseRefName}`)}`, { cwd: worktreePath });
709
+ }
710
+ export async function mergeBaseNoCommit(exec, baseSha, worktreePath) {
711
+ await exec(`git merge --no-commit --no-ff ${shellQuote(baseSha)}`, {
712
+ cwd: worktreePath,
713
+ }).catch(() => undefined);
714
+ }
715
+ export async function listUnmergedFiles(exec, worktreePath) {
716
+ const output = await exec("git diff --name-only --diff-filter=U", {
717
+ cwd: worktreePath,
718
+ });
719
+ return output
720
+ .split("\n")
721
+ .map((line) => line.trim())
722
+ .filter(Boolean);
723
+ }
724
+ export async function abortMerge(exec, worktreePath) {
725
+ await exec("git merge --abort", { cwd: worktreePath }).catch(() => undefined);
726
+ }
727
+ export async function currentHeadSha(exec, worktreePath) {
728
+ return (await exec("git rev-parse HEAD", { cwd: worktreePath })).trim();
729
+ }
688
730
  export async function closePullRequest(exec, repository, pr, account) {
689
731
  const token = await ghToken(exec, repository, account);
690
732
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));