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.
@@ -1,8 +1,8 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { prRunOutputDir } from "../config/output";
4
- import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, waitForMergeQueue, } from "../github/commands";
5
- import { composeEditPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
4
+ import { abortMerge, closePullRequest, configureGitIdentity, currentHeadSha, fetchBaseBranch, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, listUnmergedFiles, mergePullRequest, mergeBaseNoCommit, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, waitForMergeQueue, } from "../github/commands";
5
+ import { composeEditPrompt, composeMergeConflictPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
6
6
  import { parseEditOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, } from "../prompts/output";
7
7
  import { throwIfAborted, withAbortSignal } from "./abort";
8
8
  import { waitForChecksWithClassification } from "./ci";
@@ -11,7 +11,7 @@ import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
11
11
  import { runModelWithRepair } from "./model";
12
12
  import { mapPool } from "./pool";
13
13
  import { formatMergeReport } from "./report";
14
- import { inlineCommentTargetsForDiff, runReview, } from "./review";
14
+ import { inlineCommentTargetsForDiff, assignThreadsByReviewFindingMarker, formatReviewMarker, postSingleConsensusReview, runReview, reviewPostingAccount, } from "./review";
15
15
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
16
  function outputDir(input) {
17
17
  return prRunOutputDir({
@@ -131,6 +131,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
131
131
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
132
132
  if (!reviewer)
133
133
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
134
+ const account = reviewPostingAccount(input.repository, reviewer);
134
135
  if (input.dryRun) {
135
136
  if (output.verdict === "MERGE")
136
137
  return `dry-run:would-approve:${reviewerKey}`;
@@ -139,16 +140,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
139
140
  }
140
141
  return `dry-run:would-request-changes:${reviewerKey}`;
141
142
  }
142
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
143
- const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
143
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
144
+ const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
144
145
  if (output.verdict === "MERGE") {
145
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
146
+ return postApproval(input.exec, input.repository, input.pr, account);
146
147
  }
147
148
  if (output.verdict === "CLOSE") {
148
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
149
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
149
150
  }
150
151
  if (output.newFindings.length) {
151
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
152
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
152
153
  fix: "Please address this before merging.",
153
154
  issue: finding.body,
154
155
  path: finding.path,
@@ -212,10 +213,23 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
212
213
  worktreePath,
213
214
  });
214
215
  const artifactDir = outputDir(input);
216
+ const singleReviewMode = input.repository.review?.mode !== "multi";
217
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
218
+ const singleModeThreads = singleReviewMode
219
+ ? assignThreadsByReviewFindingMarker({
220
+ fallbackReviewerKeys: reviewerKeys,
221
+ pr: input.pr,
222
+ reviewerKeys,
223
+ threads: options.dryRunThreads == null
224
+ ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.review?.account ?? "")
225
+ : Object.values(options.dryRunThreads).flat(),
226
+ })
227
+ : undefined;
215
228
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
216
229
  throwIfAborted(input.signal);
217
- const unresolved = options.dryRunThreads?.[reviewer.key] ??
218
- (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewer.account));
230
+ const unresolved = singleModeThreads?.[reviewer.key] ??
231
+ options.dryRunThreads?.[reviewer.key] ??
232
+ (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
219
233
  const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
220
234
  const prompt = await composeRereviewPrompt({
221
235
  baseSha: meta.baseRefOid,
@@ -388,14 +402,44 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
388
402
  };
389
403
  }));
390
404
  }
391
- const posted = Object.fromEntries(await Promise.all(entries.map(async (entry) => [
392
- entry.reviewer,
393
- await postRereviewOutput(input, entry.reviewer, entry.output),
394
- ])));
395
405
  const verdict = mergeVerdictForPolicy(entries.map((entry) => ({
396
406
  reviewer: entry.reviewer,
397
407
  verdict: entry.verdict,
398
408
  })), input.repository.merge.approvalPolicy);
409
+ const outputs = Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output]));
410
+ const posted = singleReviewMode
411
+ ? input.dryRun
412
+ ? { consensus: `dry-run:would-post-single-review:${verdict}` }
413
+ : {
414
+ consensus: await (async () => {
415
+ const account = input.repository.review?.account ?? "";
416
+ await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
417
+ await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
418
+ `**Reviewer:** ${entry.reviewer}`,
419
+ "",
420
+ item.body,
421
+ "",
422
+ formatReviewMarker({
423
+ head: headSha,
424
+ pr: input.pr,
425
+ reviewer: entry.reviewer,
426
+ verdict: entry.output.verdict,
427
+ }),
428
+ ].join("\n")))));
429
+ return postSingleConsensusReview({
430
+ exec: input.exec,
431
+ headSha,
432
+ outputs,
433
+ pr: input.pr,
434
+ repository: input.repository,
435
+ verdict,
436
+ });
437
+ })(),
438
+ }
439
+ : Object.fromEntries(await Promise.all(entries.map(async (entry) => [
440
+ entry.reviewer,
441
+ await postRereviewOutput(input, entry.reviewer, entry.output),
442
+ ])));
399
443
  await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
400
444
  approvalPolicy: input.repository.merge.approvalPolicy,
401
445
  verdict,
@@ -405,7 +449,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
405
449
  })),
406
450
  }, null, 2));
407
451
  return {
408
- outputs: Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output])),
452
+ outputs,
409
453
  posted,
410
454
  verdict,
411
455
  };
@@ -433,6 +477,224 @@ async function mergeWithQueue(input, exec, editorAccount) {
433
477
  }
434
478
  return waitForMergeQueue(exec, input.repository, input.pr);
435
479
  }
480
+ async function runConflictEditor(input) {
481
+ const editor = input.run.repository.agents.editor;
482
+ if (!editor)
483
+ throw new Error("merge.editor is required for magi_merge");
484
+ await configureGitIdentity(input.run.exec, input.worktreePath, {
485
+ email: editor.author?.email,
486
+ name: editor.author?.name,
487
+ });
488
+ const artifactDir = outputDir(input.run);
489
+ const prompt = await composeMergeConflictPrompt({
490
+ baseBranch: input.baseBranch,
491
+ baseSha: input.baseSha,
492
+ conflictedFiles: JSON.stringify(input.conflictedFiles, null, 2),
493
+ directory: input.run.directory,
494
+ headSha: input.headSha,
495
+ pr: input.run.pr,
496
+ repository: input.run.repository,
497
+ worktreePath: input.worktreePath,
498
+ });
499
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_started" });
500
+ const result = await withEditorFailureProgress({
501
+ cycle: input.cycle,
502
+ onProgress: input.run.onProgress,
503
+ run: () => runModelWithRepair({
504
+ client: input.run.client,
505
+ model: editor.model,
506
+ onProgress: async (progress) => {
507
+ if (progress.type === "session_created") {
508
+ await input.run.onProgress?.({
509
+ cycle: input.cycle,
510
+ options: progress.options,
511
+ sessionId: progress.sessionId,
512
+ type: "editor_session",
513
+ });
514
+ }
515
+ if (progress.type === "repair") {
516
+ await input.run.onProgress?.({
517
+ cycle: input.cycle,
518
+ type: "editor_repair",
519
+ });
520
+ }
521
+ if (progress.type === "response") {
522
+ await input.run.onProgress?.({
523
+ cycle: input.cycle,
524
+ sessionId: progress.sessionId,
525
+ type: "editor_response",
526
+ });
527
+ }
528
+ },
529
+ options: editor.options,
530
+ parentSessionId: input.run.parentSessionId,
531
+ parse: parseEditOutput,
532
+ permission: editor.permission,
533
+ prompt,
534
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
535
+ schemaName: "edit",
536
+ signal: input.run.signal,
537
+ title: `magi resolve conflict ${input.run.repository.alias}#${input.run.pr}`,
538
+ }),
539
+ });
540
+ await writeFile(join(artifactDir, "editor.conflict.prompt.txt"), prompt);
541
+ await writeFile(join(artifactDir, "editor.conflict.raw.txt"), result.raw);
542
+ await writeFile(join(artifactDir, "editor.conflict.json"), JSON.stringify(result.value, null, 2));
543
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_completed" });
544
+ return result.value;
545
+ }
546
+ async function recoverMergeQueueConflict(input) {
547
+ await input.run.onProgress?.({
548
+ phase: "checking merge queue conflict",
549
+ type: "phase",
550
+ });
551
+ const meta = await fetchPullRequest(input.exec, input.run.repository, input.run.pr);
552
+ if (meta.state && meta.state.toUpperCase() !== "OPEN")
553
+ return undefined;
554
+ await fetchBaseBranch(input.exec, input.run.repository, meta, input.worktreePath);
555
+ await mergeBaseNoCommit(input.exec, meta.baseRefOid, input.worktreePath);
556
+ const conflictedFiles = await listUnmergedFiles(input.exec, input.worktreePath);
557
+ if (!conflictedFiles.length) {
558
+ await abortMerge(input.exec, input.worktreePath);
559
+ return undefined;
560
+ }
561
+ await input.run.onProgress?.({
562
+ phase: "resolving merge conflict",
563
+ type: "phase",
564
+ });
565
+ const editorOutput = await runConflictEditor({
566
+ baseBranch: meta.baseRefName,
567
+ baseSha: meta.baseRefOid,
568
+ conflictedFiles,
569
+ cycle: input.cycle,
570
+ headSha: input.previousHeadSha,
571
+ run: input.run,
572
+ worktreePath: input.worktreePath,
573
+ });
574
+ if (editorOutput.mode !== "EDITED") {
575
+ return {
576
+ ciReports: input.ciReports,
577
+ editorOutput,
578
+ headSha: input.previousHeadSha,
579
+ outputs: {},
580
+ posted: {},
581
+ status: "changes_unresolved",
582
+ };
583
+ }
584
+ const remainingConflicts = await listUnmergedFiles(input.exec, input.worktreePath);
585
+ const editedHeadSha = await currentHeadSha(input.exec, input.worktreePath);
586
+ if (remainingConflicts.length || editedHeadSha === input.previousHeadSha) {
587
+ return {
588
+ ciReports: input.ciReports,
589
+ editorOutput,
590
+ headSha: input.previousHeadSha,
591
+ outputs: {},
592
+ posted: {},
593
+ status: "changes_unresolved",
594
+ };
595
+ }
596
+ const editor = input.run.repository.agents.editor;
597
+ if (!editor)
598
+ throw new Error("merge.editor is required for magi_merge");
599
+ const headOwner = meta.headRepositoryOwner?.login;
600
+ const headRepo = meta.headRepository?.name;
601
+ if (!headOwner || !headRepo) {
602
+ throw new Error("Pull request head repository is missing");
603
+ }
604
+ await pushHead(input.exec, input.run.repository, input.worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
605
+ const ciReports = [...input.ciReports];
606
+ let ciFailureContext = "";
607
+ await input.run.onProgress?.({
608
+ phase: "waiting for checks after conflict resolution",
609
+ type: "phase",
610
+ });
611
+ const checkResult = await waitForChecksWithClassification({
612
+ afterEdit: {
613
+ cycle: input.cycle,
614
+ headSha: editedHeadSha,
615
+ previousHeadSha: input.previousHeadSha,
616
+ worktreePath: input.worktreePath,
617
+ },
618
+ client: input.run.client,
619
+ directory: input.run.directory,
620
+ exec: input.exec,
621
+ headSha: editedHeadSha,
622
+ onProgress: (phase) => input.run.onProgress?.({ phase, type: "phase" }),
623
+ parentSessionId: input.run.parentSessionId,
624
+ pr: input.run.pr,
625
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
626
+ repository: input.run.repository,
627
+ signal: input.run.signal,
628
+ wait: input.run.repository.checks.waitAfterEdit,
629
+ });
630
+ ciFailureContext = checkResult?.ciFailureContext ?? "";
631
+ if (checkResult &&
632
+ (checkResult.report.scopeOutsideRecovered.length ||
633
+ checkResult.report.scopeOutsideUnresolved.length ||
634
+ checkResult.report.scopeInside.length)) {
635
+ ciReports.push(checkResult.report);
636
+ await input.run.onProgress?.({
637
+ report: checkResult.report,
638
+ type: "ci_report",
639
+ });
640
+ }
641
+ await input.run.onProgress?.({
642
+ phase: "rereview after conflict resolution",
643
+ type: "phase",
644
+ });
645
+ const rereview = await runRereview(input.run, input.worktreePath, input.previousHeadSha, input.cycle, input.sessionIds, ciFailureContext);
646
+ if (rereview.verdict === "CLOSE") {
647
+ if (!input.run.repository.automation.close) {
648
+ return {
649
+ ciReports,
650
+ editorOutput,
651
+ headSha: editedHeadSha,
652
+ outputs: rereview.outputs,
653
+ posted: rereview.posted,
654
+ status: "close_requested",
655
+ };
656
+ }
657
+ await input.run.onProgress?.({ phase: "closing PR", type: "phase" });
658
+ await closePullRequest(input.exec, input.run.repository, input.run.pr, editor.account);
659
+ return {
660
+ ciReports,
661
+ editorOutput,
662
+ headSha: editedHeadSha,
663
+ outputs: rereview.outputs,
664
+ posted: rereview.posted,
665
+ status: "closed",
666
+ };
667
+ }
668
+ if (rereview.verdict === "MERGE") {
669
+ if (hasBlockingCiReports(ciReports)) {
670
+ return {
671
+ ciReports,
672
+ editorOutput,
673
+ headSha: editedHeadSha,
674
+ outputs: rereview.outputs,
675
+ posted: rereview.posted,
676
+ status: "ci_unresolved",
677
+ };
678
+ }
679
+ await input.run.onProgress?.({ phase: "re-enqueueing PR", type: "phase" });
680
+ const status = await mergeWithQueue(input.run, input.exec, editor.account);
681
+ return {
682
+ ciReports,
683
+ editorOutput,
684
+ headSha: editedHeadSha,
685
+ outputs: rereview.outputs,
686
+ posted: rereview.posted,
687
+ status,
688
+ };
689
+ }
690
+ return {
691
+ ciReports,
692
+ editorOutput,
693
+ headSha: editedHeadSha,
694
+ outputs: rereview.outputs,
695
+ posted: rereview.posted,
696
+ };
697
+ }
436
698
  export function hasBlockingCiReports(reports) {
437
699
  return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
438
700
  }
@@ -656,6 +918,28 @@ export async function runMerge(input) {
656
918
  outputs: reportOutputs,
657
919
  posted: reportPosted,
658
920
  });
921
+ let previousHeadSha = review.headSha;
922
+ const ciReports = [...review.ciReports];
923
+ const threadAttempts = {};
924
+ let dryRunThreads = input.dryRun
925
+ ? syntheticReviewThreads(reportOutputs)
926
+ : undefined;
927
+ let conflictRecoveryAttempted = false;
928
+ const applyConflictRecovery = (recovery) => {
929
+ editorOutputs.push({
930
+ ...recovery.editorOutput,
931
+ label: "Conflict recovery",
932
+ });
933
+ ciReports.length = 0;
934
+ ciReports.push(...recovery.ciReports);
935
+ reportCiReports = [...recovery.ciReports];
936
+ if (Object.keys(recovery.outputs).length)
937
+ reportOutputs = recovery.outputs;
938
+ if (Object.keys(recovery.posted).length)
939
+ reportPosted = recovery.posted;
940
+ previousHeadSha = recovery.headSha;
941
+ dryRunThreads = undefined;
942
+ };
659
943
  if (review.verdict === "SAFETY_BLOCKED") {
660
944
  await input.onProgress?.({
661
945
  status: "safety_blocked",
@@ -693,15 +977,45 @@ export async function runMerge(input) {
693
977
  }
694
978
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
695
979
  const status = await mergeWithQueue(input, exec, editor.account);
696
- await input.onProgress?.({ status, type: "merge_completed" });
697
- return complete({ cycles: 0, pr: input.pr, status });
980
+ if (status === "dequeued" &&
981
+ input.repository.automation.conflict &&
982
+ input.repository.merge.mergeQueue) {
983
+ if (!review.worktreePath)
984
+ throw new Error("Review worktree is missing");
985
+ conflictRecoveryAttempted = true;
986
+ const recovery = await recoverMergeQueueConflict({
987
+ ciReports,
988
+ cycle: 1,
989
+ exec,
990
+ previousHeadSha,
991
+ run: abortableInput,
992
+ sessionIds: review.sessionIds,
993
+ worktreePath: review.worktreePath,
994
+ });
995
+ if (recovery) {
996
+ applyConflictRecovery(recovery);
997
+ if (recovery.status) {
998
+ await input.onProgress?.({
999
+ status: recovery.status,
1000
+ type: "merge_completed",
1001
+ });
1002
+ return complete({
1003
+ cycles: 1,
1004
+ pr: input.pr,
1005
+ status: recovery.status,
1006
+ });
1007
+ }
1008
+ }
1009
+ else {
1010
+ await input.onProgress?.({ status, type: "merge_completed" });
1011
+ return complete({ cycles: 0, pr: input.pr, status });
1012
+ }
1013
+ }
1014
+ else {
1015
+ await input.onProgress?.({ status, type: "merge_completed" });
1016
+ return complete({ cycles: 0, pr: input.pr, status });
1017
+ }
698
1018
  }
699
- let previousHeadSha = review.headSha;
700
- const ciReports = [...review.ciReports];
701
- const threadAttempts = {};
702
- let dryRunThreads = input.dryRun
703
- ? syntheticReviewThreads(reportOutputs)
704
- : undefined;
705
1019
  for (let cycle = 1;; cycle += 1) {
706
1020
  const unresolvedThreads = input.dryRun
707
1021
  ? flattenSyntheticThreads(dryRunThreads ?? {})
@@ -886,6 +1200,36 @@ export async function runMerge(input) {
886
1200
  }
887
1201
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
888
1202
  const status = await mergeWithQueue(input, exec, editor.account);
1203
+ if (status === "dequeued" &&
1204
+ input.repository.automation.conflict &&
1205
+ input.repository.merge.mergeQueue &&
1206
+ !conflictRecoveryAttempted) {
1207
+ conflictRecoveryAttempted = true;
1208
+ const recovery = await recoverMergeQueueConflict({
1209
+ ciReports,
1210
+ cycle: cycle + 1,
1211
+ exec,
1212
+ previousHeadSha,
1213
+ run: abortableInput,
1214
+ sessionIds: review.sessionIds,
1215
+ worktreePath: review.worktreePath,
1216
+ });
1217
+ if (recovery) {
1218
+ applyConflictRecovery(recovery);
1219
+ if (recovery.status) {
1220
+ await input.onProgress?.({
1221
+ status: recovery.status,
1222
+ type: "merge_completed",
1223
+ });
1224
+ return complete({
1225
+ cycles: cycle + 1,
1226
+ pr: input.pr,
1227
+ status: recovery.status,
1228
+ });
1229
+ }
1230
+ continue;
1231
+ }
1232
+ }
889
1233
  await input.onProgress?.({ status, type: "merge_completed" });
890
1234
  return complete({ cycles: cycle, pr: input.pr, status });
891
1235
  }
@@ -138,7 +138,7 @@ function editorLines(outputs) {
138
138
  return [
139
139
  "- **Editor**:",
140
140
  ...outputs.flatMap((output, index) => {
141
- const label = ` - Cycle ${index + 1}`;
141
+ const label = ` - ${output.label ?? `Cycle ${index + 1}`}`;
142
142
  if (output.mode === "REPLIED") {
143
143
  return [
144
144
  `${label}: replied without code changes`,