opencode-magi 0.0.0-dev-20260525031145 → 0.0.0-dev-20260525064434
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/dist/config/resolve.js +1 -0
- package/dist/config/validate.js +2 -1
- package/dist/github/commands.js +23 -0
- package/dist/orchestrator/merge.js +310 -10
- package/dist/orchestrator/report.js +1 -1
- package/dist/orchestrator/review.js +97 -1
- package/dist/prompts/compose.js +46 -0
- package/dist/prompts/templates/merge/conflict.md +10 -0
- package/dist/prompts/templates/review/rereview.md +2 -0
- package/dist/prompts/templates/review/review.md +2 -0
- package/package.json +1 -1
- package/schema.json +10 -1
package/dist/config/resolve.js
CHANGED
|
@@ -138,6 +138,7 @@ export function resolveRepository(config) {
|
|
|
138
138
|
agents: resolveAgents(config),
|
|
139
139
|
automation: {
|
|
140
140
|
close: config.merge?.automation?.close ?? false,
|
|
141
|
+
conflict: config.merge?.automation?.conflict ?? false,
|
|
141
142
|
merge: config.merge?.automation?.merge ?? true,
|
|
142
143
|
},
|
|
143
144
|
checks: {
|
package/dist/config/validate.js
CHANGED
|
@@ -93,6 +93,7 @@ const REVIEW_MERGE_KEYS = new Set([
|
|
|
93
93
|
const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
|
|
94
94
|
const MERGE_CHECKS_KEYS = new Set(["wait"]);
|
|
95
95
|
const AUTOMATION_KEYS = new Set(["close", "merge"]);
|
|
96
|
+
const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
|
|
96
97
|
const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
|
|
97
98
|
const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
|
|
98
99
|
const OUTPUT_KEYS = new Set(["repairAttempts"]);
|
|
@@ -552,7 +553,7 @@ function validateMerge(config, errors, options) {
|
|
|
552
553
|
errors.push("merge must be an object");
|
|
553
554
|
}
|
|
554
555
|
validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
|
|
555
|
-
validateBooleanObject(merge?.automation, "merge.automation",
|
|
556
|
+
validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
|
|
556
557
|
const checks = merge?.checks;
|
|
557
558
|
validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
|
|
558
559
|
validateBoolean(checks?.wait, "merge.checks.wait", errors);
|
package/dist/github/commands.js
CHANGED
|
@@ -685,6 +685,29 @@ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000
|
|
|
685
685
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
686
686
|
}
|
|
687
687
|
}
|
|
688
|
+
export async function fetchBaseBranch(exec, repository, meta, worktreePath) {
|
|
689
|
+
await exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(repository, repository.github.owner, repository.github.repo))} ${shellQuote(`refs/heads/${meta.baseRefName}`)}`, { cwd: worktreePath });
|
|
690
|
+
}
|
|
691
|
+
export async function mergeBaseNoCommit(exec, baseSha, worktreePath) {
|
|
692
|
+
await exec(`git merge --no-commit --no-ff ${shellQuote(baseSha)}`, {
|
|
693
|
+
cwd: worktreePath,
|
|
694
|
+
}).catch(() => undefined);
|
|
695
|
+
}
|
|
696
|
+
export async function listUnmergedFiles(exec, worktreePath) {
|
|
697
|
+
const output = await exec("git diff --name-only --diff-filter=U", {
|
|
698
|
+
cwd: worktreePath,
|
|
699
|
+
});
|
|
700
|
+
return output
|
|
701
|
+
.split("\n")
|
|
702
|
+
.map((line) => line.trim())
|
|
703
|
+
.filter(Boolean);
|
|
704
|
+
}
|
|
705
|
+
export async function abortMerge(exec, worktreePath) {
|
|
706
|
+
await exec("git merge --abort", { cwd: worktreePath }).catch(() => undefined);
|
|
707
|
+
}
|
|
708
|
+
export async function currentHeadSha(exec, worktreePath) {
|
|
709
|
+
return (await exec("git rev-parse HEAD", { cwd: worktreePath })).trim();
|
|
710
|
+
}
|
|
688
711
|
export async function closePullRequest(exec, repository, pr, account) {
|
|
689
712
|
const token = await ghToken(exec, repository, account);
|
|
690
713
|
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
|
-
|
|
697
|
-
|
|
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`,
|
|
@@ -163,6 +163,90 @@ export async function inlineCommentTargetsForDiff(input) {
|
|
|
163
163
|
cwd: input.worktreePath,
|
|
164
164
|
}));
|
|
165
165
|
}
|
|
166
|
+
function firstTargetLine(targets, path) {
|
|
167
|
+
const lines = targets.get(path);
|
|
168
|
+
if (!lines?.size)
|
|
169
|
+
return undefined;
|
|
170
|
+
return [...lines].sort((a, b) => a - b)[0];
|
|
171
|
+
}
|
|
172
|
+
function mergeInlineCommentTargets(left, right) {
|
|
173
|
+
const merged = new Map();
|
|
174
|
+
for (const [path, lines] of [...left, ...right]) {
|
|
175
|
+
const targetLines = merged.get(path) ?? new Set();
|
|
176
|
+
for (const line of lines)
|
|
177
|
+
targetLines.add(line);
|
|
178
|
+
merged.set(path, targetLines);
|
|
179
|
+
}
|
|
180
|
+
return merged;
|
|
181
|
+
}
|
|
182
|
+
function targetLineSummary(targets, path) {
|
|
183
|
+
const lines = targets.get(path);
|
|
184
|
+
if (!lines?.size)
|
|
185
|
+
return "(none)";
|
|
186
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
187
|
+
const shown = sorted.slice(0, 12).join(", ");
|
|
188
|
+
return sorted.length > 12 ? `${shown}, ...` : shown;
|
|
189
|
+
}
|
|
190
|
+
function indentedExcerpt(lines) {
|
|
191
|
+
return lines
|
|
192
|
+
.slice(0, 24)
|
|
193
|
+
.map((line) => ` ${line}`)
|
|
194
|
+
.join("\n");
|
|
195
|
+
}
|
|
196
|
+
function parseMergeConflictSections(output) {
|
|
197
|
+
const conflictHeaders = new Set([
|
|
198
|
+
"added in both",
|
|
199
|
+
"changed in both",
|
|
200
|
+
"removed in local",
|
|
201
|
+
"removed in remote",
|
|
202
|
+
]);
|
|
203
|
+
const sections = [];
|
|
204
|
+
let current;
|
|
205
|
+
for (const line of output.split("\n")) {
|
|
206
|
+
if (!line.trim())
|
|
207
|
+
continue;
|
|
208
|
+
if (!line.startsWith(" ") &&
|
|
209
|
+
!line.startsWith("+") &&
|
|
210
|
+
!line.startsWith("-") &&
|
|
211
|
+
!line.startsWith("@")) {
|
|
212
|
+
current = conflictHeaders.has(line)
|
|
213
|
+
? { lines: [line], paths: new Set() }
|
|
214
|
+
: undefined;
|
|
215
|
+
if (current)
|
|
216
|
+
sections.push(current);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!current)
|
|
220
|
+
continue;
|
|
221
|
+
current.lines.push(line);
|
|
222
|
+
const path = /^ (?:base|our|their)\s+\d+\s+[0-9a-f]+\s+(.+)$/.exec(line)?.[1];
|
|
223
|
+
if (path)
|
|
224
|
+
current.paths.add(path);
|
|
225
|
+
}
|
|
226
|
+
return sections.flatMap((section) => [...section.paths].map((path) => ({
|
|
227
|
+
excerpt: indentedExcerpt(section.lines),
|
|
228
|
+
path,
|
|
229
|
+
})));
|
|
230
|
+
}
|
|
231
|
+
export async function mergeConflictContextForDiff(input) {
|
|
232
|
+
const mergeBase = (await input.exec(`git merge-base ${shellQuote(input.baseSha)} ${shellQuote(input.headSha)}`, { cwd: input.worktreePath })).trim();
|
|
233
|
+
const output = await input.exec(`git merge-tree ${shellQuote(mergeBase)} ${shellQuote(input.headSha)} ${shellQuote(input.baseSha)}`, { cwd: input.worktreePath });
|
|
234
|
+
const conflicts = parseMergeConflictSections(output);
|
|
235
|
+
if (!conflicts.length)
|
|
236
|
+
return "";
|
|
237
|
+
return [
|
|
238
|
+
"The PR currently has unresolved merge conflicts with the base branch.",
|
|
239
|
+
"Treat unresolved conflicts as review findings and request changes when they make the PR unsafe or impossible to merge.",
|
|
240
|
+
"Use suggestedLine when it is present; it is a valid right-side PR diff line for an inline finding.",
|
|
241
|
+
...conflicts.map((conflict) => {
|
|
242
|
+
const suggestedLine = firstTargetLine(input.inlineCommentTargets, conflict.path);
|
|
243
|
+
const suggestedLineText = suggestedLine
|
|
244
|
+
? `suggestedLine: ${suggestedLine}`
|
|
245
|
+
: "suggestedLine: (no right-side PR diff line found)";
|
|
246
|
+
return `<conflict_file>\npath: ${conflict.path}\n${suggestedLineText}\nrightSideDiffLines: ${targetLineSummary(input.inlineCommentTargets, conflict.path)}\nmergeTreeExcerpt:\n${conflict.excerpt}\n</conflict_file>`;
|
|
247
|
+
}),
|
|
248
|
+
].join("\n");
|
|
249
|
+
}
|
|
166
250
|
function parsePostedFindingLocation(location) {
|
|
167
251
|
const range = /^(.*):(\d+)-(\d+)$/.exec(location);
|
|
168
252
|
if (range) {
|
|
@@ -709,6 +793,13 @@ export async function runReview(input) {
|
|
|
709
793
|
toSha: meta.headRefOid,
|
|
710
794
|
worktreePath,
|
|
711
795
|
});
|
|
796
|
+
const mergeConflictContext = await mergeConflictContextForDiff({
|
|
797
|
+
baseSha: meta.baseRefOid,
|
|
798
|
+
exec,
|
|
799
|
+
headSha: meta.headRefOid,
|
|
800
|
+
inlineCommentTargets: initialInlineCommentTargets,
|
|
801
|
+
worktreePath,
|
|
802
|
+
});
|
|
712
803
|
for (const reviewer of input.repository.agents.reviewers) {
|
|
713
804
|
const assignment = mode.assignments.get(reviewer.account);
|
|
714
805
|
if (assignment?.type !== "skip")
|
|
@@ -740,6 +831,9 @@ export async function runReview(input) {
|
|
|
740
831
|
toSha: meta.headRefOid,
|
|
741
832
|
worktreePath,
|
|
742
833
|
});
|
|
834
|
+
const rereviewInlineCommentTargets = mergeConflictContext
|
|
835
|
+
? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
|
|
836
|
+
: inlineCommentTargets;
|
|
743
837
|
const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
744
838
|
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
|
|
745
839
|
const prompt = await composeRereviewPrompt({
|
|
@@ -747,6 +841,7 @@ export async function runReview(input) {
|
|
|
747
841
|
ciFailureContext,
|
|
748
842
|
directory: input.directory,
|
|
749
843
|
headSha: meta.headRefOid,
|
|
844
|
+
mergeConflictContext,
|
|
750
845
|
pr: input.pr,
|
|
751
846
|
previousReview: previousReviewText(previous),
|
|
752
847
|
previousHeadSha: previous.commit.oid,
|
|
@@ -787,7 +882,7 @@ export async function runReview(input) {
|
|
|
787
882
|
},
|
|
788
883
|
options: reviewer.options,
|
|
789
884
|
parentSessionId: input.parentSessionId,
|
|
790
|
-
parse: (text) => parseRereviewOutputWithInlineTargets(text,
|
|
885
|
+
parse: (text) => parseRereviewOutputWithInlineTargets(text, rereviewInlineCommentTargets),
|
|
791
886
|
permission: reviewer.permission,
|
|
792
887
|
prompt,
|
|
793
888
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -819,6 +914,7 @@ export async function runReview(input) {
|
|
|
819
914
|
ciFailureContext,
|
|
820
915
|
directory: input.directory,
|
|
821
916
|
headSha: meta.headRefOid,
|
|
917
|
+
mergeConflictContext,
|
|
822
918
|
pr: input.pr,
|
|
823
919
|
repository: input.repository,
|
|
824
920
|
reviewContext,
|
package/dist/prompts/compose.js
CHANGED
|
@@ -35,6 +35,7 @@ function repositoryValues(repository) {
|
|
|
35
35
|
}
|
|
36
36
|
function reviewValues(input) {
|
|
37
37
|
const ciFailureContext = input.ciFailureContext?.trim() ?? "";
|
|
38
|
+
const mergeConflictContext = input.mergeConflictContext?.trim() ?? "";
|
|
38
39
|
return {
|
|
39
40
|
...repositoryValues(input.repository),
|
|
40
41
|
baseSha: input.baseSha,
|
|
@@ -44,6 +45,10 @@ function reviewValues(input) {
|
|
|
44
45
|
: "",
|
|
45
46
|
headSha: input.headSha,
|
|
46
47
|
jsonEncodedWorktreePath: JSON.stringify(input.worktreePath),
|
|
48
|
+
mergeConflictContext,
|
|
49
|
+
mergeConflictContextBlock: mergeConflictContext
|
|
50
|
+
? `<merge_conflict_context>\n${mergeConflictContext}\n</merge_conflict_context>`
|
|
51
|
+
: "",
|
|
47
52
|
pr: String(input.pr),
|
|
48
53
|
reviewContext: input.reviewContext ?? "",
|
|
49
54
|
worktreePath: input.worktreePath,
|
|
@@ -67,6 +72,17 @@ function editValues(input) {
|
|
|
67
72
|
worktreePath: input.worktreePath,
|
|
68
73
|
};
|
|
69
74
|
}
|
|
75
|
+
function mergeConflictValues(input) {
|
|
76
|
+
return {
|
|
77
|
+
...repositoryValues(input.repository),
|
|
78
|
+
baseBranch: input.baseBranch,
|
|
79
|
+
baseSha: input.baseSha,
|
|
80
|
+
conflictedFiles: input.conflictedFiles,
|
|
81
|
+
headSha: input.headSha,
|
|
82
|
+
pr: String(input.pr),
|
|
83
|
+
worktreePath: input.worktreePath,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
70
86
|
function triageValues(input) {
|
|
71
87
|
const categories = input.repository.triage?.categories ?? [];
|
|
72
88
|
const categoryOptions = categories
|
|
@@ -97,6 +113,12 @@ function previousReviewBlock(previousReview) {
|
|
|
97
113
|
function reviewContextBlock(reviewContext) {
|
|
98
114
|
return reviewContext?.trim() ? reviewContext.trim() : "";
|
|
99
115
|
}
|
|
116
|
+
function mergeConflictContextBlock(mergeConflictContext) {
|
|
117
|
+
const body = mergeConflictContext?.trim();
|
|
118
|
+
return body
|
|
119
|
+
? `<merge_conflict_context>\n${body}\n</merge_conflict_context>`
|
|
120
|
+
: "";
|
|
121
|
+
}
|
|
100
122
|
async function reviewGuidelinesBlock(input) {
|
|
101
123
|
const body = (await readOptionalPrompt(input.directory, input.path, input.values)).trim();
|
|
102
124
|
return body ? `<review_guidelines>\n${body}\n</review_guidelines>` : "";
|
|
@@ -136,6 +158,7 @@ export async function composeReviewPrompt(input) {
|
|
|
136
158
|
return [
|
|
137
159
|
task,
|
|
138
160
|
reviewContextBlock(input.reviewContext),
|
|
161
|
+
mergeConflictContextBlock(input.mergeConflictContext),
|
|
139
162
|
languageBlock(input.repository.language),
|
|
140
163
|
personaBlock(input.reviewer.persona),
|
|
141
164
|
await reviewGuidelinesBlock({
|
|
@@ -159,6 +182,7 @@ export async function composeRereviewPrompt(input) {
|
|
|
159
182
|
return [
|
|
160
183
|
task,
|
|
161
184
|
reviewContextBlock(input.reviewContext),
|
|
185
|
+
mergeConflictContextBlock(input.mergeConflictContext),
|
|
162
186
|
input.includeSessionContext === false
|
|
163
187
|
? ""
|
|
164
188
|
: languageBlock(input.repository.language),
|
|
@@ -200,6 +224,28 @@ export async function composeEditPrompt(input) {
|
|
|
200
224
|
.filter(Boolean)
|
|
201
225
|
.join("\n\n");
|
|
202
226
|
}
|
|
227
|
+
export async function composeMergeConflictPrompt(input) {
|
|
228
|
+
const values = mergeConflictValues(input);
|
|
229
|
+
const task = await taskBlock({
|
|
230
|
+
builtin: "merge/conflict",
|
|
231
|
+
directory: input.directory,
|
|
232
|
+
values,
|
|
233
|
+
});
|
|
234
|
+
const persona = input.repository.agents.editor?.persona;
|
|
235
|
+
return [
|
|
236
|
+
task,
|
|
237
|
+
languageBlock(input.repository.language),
|
|
238
|
+
personaBlock(persona),
|
|
239
|
+
await editGuidelinesBlock({
|
|
240
|
+
directory: input.directory,
|
|
241
|
+
path: input.repository.prompts.editGuidelines,
|
|
242
|
+
values,
|
|
243
|
+
}),
|
|
244
|
+
editOutputContract,
|
|
245
|
+
]
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.join("\n\n");
|
|
248
|
+
}
|
|
203
249
|
export async function composeFindingValidationPrompt(input) {
|
|
204
250
|
const values = { ...reviewValues(input), findings: input.findings };
|
|
205
251
|
const task = await taskBlock({
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Resolve merge conflicts for pull request #{pr} in {owner}/{repo}.
|
|
2
|
+
The PR worktree is {worktreePath}.
|
|
3
|
+
|
|
4
|
+
The latest base branch is {baseBranch} at {baseSha}.
|
|
5
|
+
The PR head before conflict recovery was {headSha}.
|
|
6
|
+
|
|
7
|
+
Conflicted files:
|
|
8
|
+
{conflictedFiles}
|
|
9
|
+
|
|
10
|
+
Resolve every merge conflict in the worktree. Preserve the intended PR behavior while incorporating the latest base branch changes. Stage all resolved files and create a commit. Do not push.
|
|
@@ -13,6 +13,8 @@ Every newFinding must target a valid right-side line in the PR diff.
|
|
|
13
13
|
If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
14
14
|
Do not omit line. Do not create file-level or body-only newFindings.
|
|
15
15
|
|
|
16
|
+
If `<merge_conflict_context>` is present, treat unresolved merge conflicts as review findings. Request changes when a conflict makes the PR unsafe or impossible to merge, and prefer the provided `suggestedLine` when it is present.
|
|
17
|
+
|
|
16
18
|
{ciFailureContextBlock}
|
|
17
19
|
Do not edit files or perform write operations.
|
|
18
20
|
|
|
@@ -14,4 +14,6 @@ Every finding must target a valid right-side line in the PR diff.
|
|
|
14
14
|
If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
15
15
|
Do not omit line. Do not create file-level or body-only findings.
|
|
16
16
|
|
|
17
|
+
If `<merge_conflict_context>` is present, treat unresolved merge conflicts as review findings. Request changes when a conflict makes the PR unsafe or impossible to merge, and prefer the provided `suggestedLine` when it is present.
|
|
18
|
+
|
|
17
19
|
{ciFailureContextBlock}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-magi",
|
|
3
|
-
"version": "0.0.0-dev-
|
|
3
|
+
"version": "0.0.0-dev-20260525064434",
|
|
4
4
|
"description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
|
package/schema.json
CHANGED
|
@@ -179,6 +179,15 @@
|
|
|
179
179
|
"close": { "type": "boolean" }
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
|
+
"mergeAutomation": {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"additionalProperties": false,
|
|
185
|
+
"properties": {
|
|
186
|
+
"merge": { "type": "boolean", "default": true },
|
|
187
|
+
"close": { "type": "boolean", "default": false },
|
|
188
|
+
"conflict": { "type": "boolean", "default": false }
|
|
189
|
+
}
|
|
190
|
+
},
|
|
182
191
|
"reviewChecks": {
|
|
183
192
|
"type": "object",
|
|
184
193
|
"additionalProperties": false,
|
|
@@ -339,7 +348,7 @@
|
|
|
339
348
|
"editor": { "$ref": "#/$defs/editor" },
|
|
340
349
|
"checks": { "$ref": "#/$defs/mergeChecks" },
|
|
341
350
|
"prompts": { "$ref": "#/$defs/mergePrompts" },
|
|
342
|
-
"automation": { "$ref": "#/$defs/
|
|
351
|
+
"automation": { "$ref": "#/$defs/mergeAutomation" },
|
|
343
352
|
"maxThreadResolutionCycles": {
|
|
344
353
|
"type": "integer",
|
|
345
354
|
"minimum": 0,
|