opencode-magi 0.7.0 → 0.8.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.
@@ -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
  }
@@ -138,6 +163,7 @@ export function resolveRepository(config) {
138
163
  agents: resolveAgents(config),
139
164
  automation: {
140
165
  close: config.merge?.automation?.close ?? false,
166
+ conflict: config.merge?.automation?.conflict ?? false,
141
167
  merge: config.merge?.automation?.merge ?? true,
142
168
  },
143
169
  checks: {
@@ -190,9 +216,9 @@ export function resolveRepository(config) {
190
216
  },
191
217
  triage: {
192
218
  automation: {
193
- clear: config.triage?.automation?.clear ?? ["triage"],
194
219
  close: config.triage?.automation?.close ?? false,
195
220
  create: config.triage?.automation?.create ?? false,
221
+ label: config.triage?.automation?.label ?? DEFAULT_TRIAGE_LABEL_RULES,
196
222
  merge: config.triage?.automation?.merge ?? false,
197
223
  review: config.triage?.automation?.review ?? false,
198
224
  },
@@ -215,6 +241,7 @@ export function resolveRepository(config) {
215
241
  blockedLabels: config.triage?.safety?.blockedLabels ?? [],
216
242
  requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
217
243
  },
244
+ signals: config.triage?.signals ?? [],
218
245
  worktree: config.triage?.worktree,
219
246
  },
220
247
  };
@@ -80,6 +80,7 @@ const TRIAGE_KEYS = new Set([
80
80
  "prompts",
81
81
  "reporter",
82
82
  "safety",
83
+ "signals",
83
84
  "voters",
84
85
  "worktree",
85
86
  ]);
@@ -93,17 +94,24 @@ const REVIEW_MERGE_KEYS = new Set([
93
94
  const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
94
95
  const MERGE_CHECKS_KEYS = new Set(["wait"]);
95
96
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
97
+ const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
96
98
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
97
99
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
98
100
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
99
101
  const TRIAGE_AUTOMATION_KEYS = new Set([
100
- "clear",
101
102
  "close",
102
103
  "create",
104
+ "label",
103
105
  "merge",
104
106
  "review",
105
107
  ]);
106
108
  const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
109
+ const TRIAGE_LABEL_RULE_KEYS = new Set(["add", "remove", "when"]);
110
+ const TRIAGE_LABEL_RULE_WHEN_KEYS = new Set([
111
+ "category",
112
+ "disposition",
113
+ "signals",
114
+ ]);
107
115
  const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
108
116
  const TRIAGE_SAFETY_KEYS = new Set([
109
117
  "allowAuthors",
@@ -112,6 +120,18 @@ const TRIAGE_SAFETY_KEYS = new Set([
112
120
  "blockedLabels",
113
121
  "requiredLabels",
114
122
  ]);
123
+ const TRIAGE_SIGNAL_KEYS = new Set(["description", "id"]);
124
+ const TRIAGE_DISPOSITIONS = new Set([
125
+ "accepted",
126
+ "rejected",
127
+ "invalid",
128
+ "duplicate",
129
+ "already_handled",
130
+ "needs_category",
131
+ "needs_acceptance",
132
+ "blocked",
133
+ "failed",
134
+ ]);
115
135
  const SAFETY_KEYS = new Set([
116
136
  "allowAuthors",
117
137
  "blockedPaths",
@@ -552,7 +572,7 @@ function validateMerge(config, errors, options) {
552
572
  errors.push("merge must be an object");
553
573
  }
554
574
  validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
555
- validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
575
+ validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
556
576
  const checks = merge?.checks;
557
577
  validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
558
578
  validateBoolean(checks?.wait, "merge.checks.wait", errors);
@@ -691,6 +711,79 @@ function validateTriageCategories(categories, path, errors) {
691
711
  validateString(category.description, `${itemPath}.description`, errors);
692
712
  });
693
713
  }
714
+ function validateTriageSignals(signals, path, errors) {
715
+ if (signals == null)
716
+ return;
717
+ if (!Array.isArray(signals)) {
718
+ errors.push(`${path} must be an array`);
719
+ return;
720
+ }
721
+ const ids = new Set();
722
+ signals.forEach((item, index) => {
723
+ const itemPath = `${path}[${index}]`;
724
+ if (!isPlainObject(item)) {
725
+ errors.push(`${itemPath} must be an object`);
726
+ return;
727
+ }
728
+ const signal = item;
729
+ validateKnownKeys(signal, itemPath, TRIAGE_SIGNAL_KEYS, errors);
730
+ if (!signal.id) {
731
+ errors.push(`${itemPath}.id is required`);
732
+ }
733
+ else if (typeof signal.id !== "string") {
734
+ errors.push(`${itemPath}.id must be a string`);
735
+ }
736
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(signal.id)) {
737
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
738
+ }
739
+ else if (ids.has(signal.id)) {
740
+ errors.push(`${itemPath}.id must be unique`);
741
+ }
742
+ else {
743
+ ids.add(signal.id);
744
+ }
745
+ if (!signal.description) {
746
+ errors.push(`${itemPath}.description is required`);
747
+ }
748
+ else {
749
+ validateString(signal.description, `${itemPath}.description`, errors);
750
+ }
751
+ });
752
+ }
753
+ function validateTriageLabelRules(rules, path, errors) {
754
+ if (rules == null)
755
+ return;
756
+ if (!Array.isArray(rules)) {
757
+ errors.push(`${path} must be an array`);
758
+ return;
759
+ }
760
+ rules.forEach((item, index) => {
761
+ const itemPath = `${path}[${index}]`;
762
+ if (!isPlainObject(item)) {
763
+ errors.push(`${itemPath} must be an object`);
764
+ return;
765
+ }
766
+ const rule = item;
767
+ validateKnownKeys(rule, itemPath, TRIAGE_LABEL_RULE_KEYS, errors);
768
+ validateStringArray(rule.add, `${itemPath}.add`, errors);
769
+ validateStringArray(rule.remove, `${itemPath}.remove`, errors);
770
+ if (!isPlainObject(rule.when)) {
771
+ errors.push(`${itemPath}.when must be an object`);
772
+ return;
773
+ }
774
+ validateKnownKeys(rule.when, `${itemPath}.when`, TRIAGE_LABEL_RULE_WHEN_KEYS, errors);
775
+ if (!Object.keys(rule.when).length) {
776
+ errors.push(`${itemPath}.when must not be empty`);
777
+ }
778
+ if (rule.when.disposition != null &&
779
+ (typeof rule.when.disposition !== "string" ||
780
+ !TRIAGE_DISPOSITIONS.has(rule.when.disposition))) {
781
+ errors.push(`${itemPath}.when.disposition must be a triage disposition`);
782
+ }
783
+ validateString(rule.when.category, `${itemPath}.when.category`, errors);
784
+ validateStringArray(rule.when.signals, `${itemPath}.when.signals`, errors);
785
+ });
786
+ }
694
787
  function validateSafety(config, errors) {
695
788
  const safety = config.review?.safety;
696
789
  if (safety != null && !isPlainObject(safety)) {
@@ -764,7 +857,7 @@ function validateTriage(config, errors, options) {
764
857
  validateBoolean(automation?.create, "triage.automation.create", errors);
765
858
  validateBoolean(automation?.merge, "triage.automation.merge", errors);
766
859
  validateBoolean(automation?.review, "triage.automation.review", errors);
767
- validateStringArray(automation?.clear, "triage.automation.clear", errors);
860
+ validateTriageLabelRules(automation?.label, "triage.automation.label", errors);
768
861
  if (automation?.review && !automation.create) {
769
862
  errors.push("triage.automation.review requires triage.automation.create to be true");
770
863
  }
@@ -779,6 +872,7 @@ function validateTriage(config, errors, options) {
779
872
  errors.push("triage.concurrency.runs must be a positive integer");
780
873
  }
781
874
  validateTriageCategories(triage.categories, "triage.categories", errors);
875
+ validateTriageSignals(triage.signals, "triage.signals", errors);
782
876
  validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
783
877
  validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
784
878
  validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
@@ -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 = [];
@@ -685,6 +694,29 @@ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000
685
694
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
686
695
  }
687
696
  }
697
+ export async function fetchBaseBranch(exec, repository, meta, worktreePath) {
698
+ await exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(repository, repository.github.owner, repository.github.repo))} ${shellQuote(`refs/heads/${meta.baseRefName}`)}`, { cwd: worktreePath });
699
+ }
700
+ export async function mergeBaseNoCommit(exec, baseSha, worktreePath) {
701
+ await exec(`git merge --no-commit --no-ff ${shellQuote(baseSha)}`, {
702
+ cwd: worktreePath,
703
+ }).catch(() => undefined);
704
+ }
705
+ export async function listUnmergedFiles(exec, worktreePath) {
706
+ const output = await exec("git diff --name-only --diff-filter=U", {
707
+ cwd: worktreePath,
708
+ });
709
+ return output
710
+ .split("\n")
711
+ .map((line) => line.trim())
712
+ .filter(Boolean);
713
+ }
714
+ export async function abortMerge(exec, worktreePath) {
715
+ await exec("git merge --abort", { cwd: worktreePath }).catch(() => undefined);
716
+ }
717
+ export async function currentHeadSha(exec, worktreePath) {
718
+ return (await exec("git rev-parse HEAD", { cwd: worktreePath })).trim();
719
+ }
688
720
  export async function closePullRequest(exec, repository, pr, account) {
689
721
  const token = await ghToken(exec, repository, account);
690
722
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
@@ -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";
@@ -433,6 +433,224 @@ async function mergeWithQueue(input, exec, editorAccount) {
433
433
  }
434
434
  return waitForMergeQueue(exec, input.repository, input.pr);
435
435
  }
436
+ async function runConflictEditor(input) {
437
+ const editor = input.run.repository.agents.editor;
438
+ if (!editor)
439
+ throw new Error("merge.editor is required for magi_merge");
440
+ await configureGitIdentity(input.run.exec, input.worktreePath, {
441
+ email: editor.author?.email,
442
+ name: editor.author?.name,
443
+ });
444
+ const artifactDir = outputDir(input.run);
445
+ const prompt = await composeMergeConflictPrompt({
446
+ baseBranch: input.baseBranch,
447
+ baseSha: input.baseSha,
448
+ conflictedFiles: JSON.stringify(input.conflictedFiles, null, 2),
449
+ directory: input.run.directory,
450
+ headSha: input.headSha,
451
+ pr: input.run.pr,
452
+ repository: input.run.repository,
453
+ worktreePath: input.worktreePath,
454
+ });
455
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_started" });
456
+ const result = await withEditorFailureProgress({
457
+ cycle: input.cycle,
458
+ onProgress: input.run.onProgress,
459
+ run: () => runModelWithRepair({
460
+ client: input.run.client,
461
+ model: editor.model,
462
+ onProgress: async (progress) => {
463
+ if (progress.type === "session_created") {
464
+ await input.run.onProgress?.({
465
+ cycle: input.cycle,
466
+ options: progress.options,
467
+ sessionId: progress.sessionId,
468
+ type: "editor_session",
469
+ });
470
+ }
471
+ if (progress.type === "repair") {
472
+ await input.run.onProgress?.({
473
+ cycle: input.cycle,
474
+ type: "editor_repair",
475
+ });
476
+ }
477
+ if (progress.type === "response") {
478
+ await input.run.onProgress?.({
479
+ cycle: input.cycle,
480
+ sessionId: progress.sessionId,
481
+ type: "editor_response",
482
+ });
483
+ }
484
+ },
485
+ options: editor.options,
486
+ parentSessionId: input.run.parentSessionId,
487
+ parse: parseEditOutput,
488
+ permission: editor.permission,
489
+ prompt,
490
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
491
+ schemaName: "edit",
492
+ signal: input.run.signal,
493
+ title: `magi resolve conflict ${input.run.repository.alias}#${input.run.pr}`,
494
+ }),
495
+ });
496
+ await writeFile(join(artifactDir, "editor.conflict.prompt.txt"), prompt);
497
+ await writeFile(join(artifactDir, "editor.conflict.raw.txt"), result.raw);
498
+ await writeFile(join(artifactDir, "editor.conflict.json"), JSON.stringify(result.value, null, 2));
499
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_completed" });
500
+ return result.value;
501
+ }
502
+ async function recoverMergeQueueConflict(input) {
503
+ await input.run.onProgress?.({
504
+ phase: "checking merge queue conflict",
505
+ type: "phase",
506
+ });
507
+ const meta = await fetchPullRequest(input.exec, input.run.repository, input.run.pr);
508
+ if (meta.state && meta.state.toUpperCase() !== "OPEN")
509
+ return undefined;
510
+ await fetchBaseBranch(input.exec, input.run.repository, meta, input.worktreePath);
511
+ await mergeBaseNoCommit(input.exec, meta.baseRefOid, input.worktreePath);
512
+ const conflictedFiles = await listUnmergedFiles(input.exec, input.worktreePath);
513
+ if (!conflictedFiles.length) {
514
+ await abortMerge(input.exec, input.worktreePath);
515
+ return undefined;
516
+ }
517
+ await input.run.onProgress?.({
518
+ phase: "resolving merge conflict",
519
+ type: "phase",
520
+ });
521
+ const editorOutput = await runConflictEditor({
522
+ baseBranch: meta.baseRefName,
523
+ baseSha: meta.baseRefOid,
524
+ conflictedFiles,
525
+ cycle: input.cycle,
526
+ headSha: input.previousHeadSha,
527
+ run: input.run,
528
+ worktreePath: input.worktreePath,
529
+ });
530
+ if (editorOutput.mode !== "EDITED") {
531
+ return {
532
+ ciReports: input.ciReports,
533
+ editorOutput,
534
+ headSha: input.previousHeadSha,
535
+ outputs: {},
536
+ posted: {},
537
+ status: "changes_unresolved",
538
+ };
539
+ }
540
+ const remainingConflicts = await listUnmergedFiles(input.exec, input.worktreePath);
541
+ const editedHeadSha = await currentHeadSha(input.exec, input.worktreePath);
542
+ if (remainingConflicts.length || editedHeadSha === input.previousHeadSha) {
543
+ return {
544
+ ciReports: input.ciReports,
545
+ editorOutput,
546
+ headSha: input.previousHeadSha,
547
+ outputs: {},
548
+ posted: {},
549
+ status: "changes_unresolved",
550
+ };
551
+ }
552
+ const editor = input.run.repository.agents.editor;
553
+ if (!editor)
554
+ throw new Error("merge.editor is required for magi_merge");
555
+ const headOwner = meta.headRepositoryOwner?.login;
556
+ const headRepo = meta.headRepository?.name;
557
+ if (!headOwner || !headRepo) {
558
+ throw new Error("Pull request head repository is missing");
559
+ }
560
+ await pushHead(input.exec, input.run.repository, input.worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
561
+ const ciReports = [...input.ciReports];
562
+ let ciFailureContext = "";
563
+ await input.run.onProgress?.({
564
+ phase: "waiting for checks after conflict resolution",
565
+ type: "phase",
566
+ });
567
+ const checkResult = await waitForChecksWithClassification({
568
+ afterEdit: {
569
+ cycle: input.cycle,
570
+ headSha: editedHeadSha,
571
+ previousHeadSha: input.previousHeadSha,
572
+ worktreePath: input.worktreePath,
573
+ },
574
+ client: input.run.client,
575
+ directory: input.run.directory,
576
+ exec: input.exec,
577
+ headSha: editedHeadSha,
578
+ onProgress: (phase) => input.run.onProgress?.({ phase, type: "phase" }),
579
+ parentSessionId: input.run.parentSessionId,
580
+ pr: input.run.pr,
581
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
582
+ repository: input.run.repository,
583
+ signal: input.run.signal,
584
+ wait: input.run.repository.checks.waitAfterEdit,
585
+ });
586
+ ciFailureContext = checkResult?.ciFailureContext ?? "";
587
+ if (checkResult &&
588
+ (checkResult.report.scopeOutsideRecovered.length ||
589
+ checkResult.report.scopeOutsideUnresolved.length ||
590
+ checkResult.report.scopeInside.length)) {
591
+ ciReports.push(checkResult.report);
592
+ await input.run.onProgress?.({
593
+ report: checkResult.report,
594
+ type: "ci_report",
595
+ });
596
+ }
597
+ await input.run.onProgress?.({
598
+ phase: "rereview after conflict resolution",
599
+ type: "phase",
600
+ });
601
+ const rereview = await runRereview(input.run, input.worktreePath, input.previousHeadSha, input.cycle, input.sessionIds, ciFailureContext);
602
+ if (rereview.verdict === "CLOSE") {
603
+ if (!input.run.repository.automation.close) {
604
+ return {
605
+ ciReports,
606
+ editorOutput,
607
+ headSha: editedHeadSha,
608
+ outputs: rereview.outputs,
609
+ posted: rereview.posted,
610
+ status: "close_requested",
611
+ };
612
+ }
613
+ await input.run.onProgress?.({ phase: "closing PR", type: "phase" });
614
+ await closePullRequest(input.exec, input.run.repository, input.run.pr, editor.account);
615
+ return {
616
+ ciReports,
617
+ editorOutput,
618
+ headSha: editedHeadSha,
619
+ outputs: rereview.outputs,
620
+ posted: rereview.posted,
621
+ status: "closed",
622
+ };
623
+ }
624
+ if (rereview.verdict === "MERGE") {
625
+ if (hasBlockingCiReports(ciReports)) {
626
+ return {
627
+ ciReports,
628
+ editorOutput,
629
+ headSha: editedHeadSha,
630
+ outputs: rereview.outputs,
631
+ posted: rereview.posted,
632
+ status: "ci_unresolved",
633
+ };
634
+ }
635
+ await input.run.onProgress?.({ phase: "re-enqueueing PR", type: "phase" });
636
+ const status = await mergeWithQueue(input.run, input.exec, editor.account);
637
+ return {
638
+ ciReports,
639
+ editorOutput,
640
+ headSha: editedHeadSha,
641
+ outputs: rereview.outputs,
642
+ posted: rereview.posted,
643
+ status,
644
+ };
645
+ }
646
+ return {
647
+ ciReports,
648
+ editorOutput,
649
+ headSha: editedHeadSha,
650
+ outputs: rereview.outputs,
651
+ posted: rereview.posted,
652
+ };
653
+ }
436
654
  export function hasBlockingCiReports(reports) {
437
655
  return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
438
656
  }
@@ -656,6 +874,28 @@ export async function runMerge(input) {
656
874
  outputs: reportOutputs,
657
875
  posted: reportPosted,
658
876
  });
877
+ let previousHeadSha = review.headSha;
878
+ const ciReports = [...review.ciReports];
879
+ const threadAttempts = {};
880
+ let dryRunThreads = input.dryRun
881
+ ? syntheticReviewThreads(reportOutputs)
882
+ : undefined;
883
+ let conflictRecoveryAttempted = false;
884
+ const applyConflictRecovery = (recovery) => {
885
+ editorOutputs.push({
886
+ ...recovery.editorOutput,
887
+ label: "Conflict recovery",
888
+ });
889
+ ciReports.length = 0;
890
+ ciReports.push(...recovery.ciReports);
891
+ reportCiReports = [...recovery.ciReports];
892
+ if (Object.keys(recovery.outputs).length)
893
+ reportOutputs = recovery.outputs;
894
+ if (Object.keys(recovery.posted).length)
895
+ reportPosted = recovery.posted;
896
+ previousHeadSha = recovery.headSha;
897
+ dryRunThreads = undefined;
898
+ };
659
899
  if (review.verdict === "SAFETY_BLOCKED") {
660
900
  await input.onProgress?.({
661
901
  status: "safety_blocked",
@@ -693,15 +933,45 @@ export async function runMerge(input) {
693
933
  }
694
934
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
695
935
  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 });
936
+ if (status === "dequeued" &&
937
+ input.repository.automation.conflict &&
938
+ input.repository.merge.mergeQueue) {
939
+ if (!review.worktreePath)
940
+ throw new Error("Review worktree is missing");
941
+ conflictRecoveryAttempted = true;
942
+ const recovery = await recoverMergeQueueConflict({
943
+ ciReports,
944
+ cycle: 1,
945
+ exec,
946
+ previousHeadSha,
947
+ run: abortableInput,
948
+ sessionIds: review.sessionIds,
949
+ worktreePath: review.worktreePath,
950
+ });
951
+ if (recovery) {
952
+ applyConflictRecovery(recovery);
953
+ if (recovery.status) {
954
+ await input.onProgress?.({
955
+ status: recovery.status,
956
+ type: "merge_completed",
957
+ });
958
+ return complete({
959
+ cycles: 1,
960
+ pr: input.pr,
961
+ status: recovery.status,
962
+ });
963
+ }
964
+ }
965
+ else {
966
+ await input.onProgress?.({ status, type: "merge_completed" });
967
+ return complete({ cycles: 0, pr: input.pr, status });
968
+ }
969
+ }
970
+ else {
971
+ await input.onProgress?.({ status, type: "merge_completed" });
972
+ return complete({ cycles: 0, pr: input.pr, status });
973
+ }
698
974
  }
699
- let previousHeadSha = review.headSha;
700
- const ciReports = [...review.ciReports];
701
- const threadAttempts = {};
702
- let dryRunThreads = input.dryRun
703
- ? syntheticReviewThreads(reportOutputs)
704
- : undefined;
705
975
  for (let cycle = 1;; cycle += 1) {
706
976
  const unresolvedThreads = input.dryRun
707
977
  ? flattenSyntheticThreads(dryRunThreads ?? {})
@@ -886,6 +1156,36 @@ export async function runMerge(input) {
886
1156
  }
887
1157
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
888
1158
  const status = await mergeWithQueue(input, exec, editor.account);
1159
+ if (status === "dequeued" &&
1160
+ input.repository.automation.conflict &&
1161
+ input.repository.merge.mergeQueue &&
1162
+ !conflictRecoveryAttempted) {
1163
+ conflictRecoveryAttempted = true;
1164
+ const recovery = await recoverMergeQueueConflict({
1165
+ ciReports,
1166
+ cycle: cycle + 1,
1167
+ exec,
1168
+ previousHeadSha,
1169
+ run: abortableInput,
1170
+ sessionIds: review.sessionIds,
1171
+ worktreePath: review.worktreePath,
1172
+ });
1173
+ if (recovery) {
1174
+ applyConflictRecovery(recovery);
1175
+ if (recovery.status) {
1176
+ await input.onProgress?.({
1177
+ status: recovery.status,
1178
+ type: "merge_completed",
1179
+ });
1180
+ return complete({
1181
+ cycles: cycle + 1,
1182
+ pr: input.pr,
1183
+ status: recovery.status,
1184
+ });
1185
+ }
1186
+ continue;
1187
+ }
1188
+ }
889
1189
  await input.onProgress?.({ status, type: "merge_completed" });
890
1190
  return complete({ cycles: cycle, pr: input.pr, status });
891
1191
  }
@@ -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`,