|
| 1 | +import type { |
| 2 | + GitResolvedPullRequest, |
| 3 | + PrConflictAnalysis, |
| 4 | + PrConflictCandidateResolution, |
| 5 | +} from "@okcode/contracts"; |
| 6 | + |
| 7 | +export type MergeConflictFeedbackDisposition = "accept" | "review" | "escalate" | "blocked"; |
| 8 | + |
| 9 | +export interface MergeConflictCandidateGroup { |
| 10 | + path: string; |
| 11 | + candidates: PrConflictCandidateResolution[]; |
| 12 | + recommendedCandidate: PrConflictCandidateResolution | null; |
| 13 | +} |
| 14 | + |
| 15 | +export interface MergeConflictRecommendation { |
| 16 | + candidateId: string | null; |
| 17 | + tone: "neutral" | "success" | "warning"; |
| 18 | + title: string; |
| 19 | + detail: string; |
| 20 | +} |
| 21 | + |
| 22 | +function candidatePriority(candidate: PrConflictCandidateResolution): number { |
| 23 | + return candidate.confidence === "safe" ? 0 : 1; |
| 24 | +} |
| 25 | + |
| 26 | +export function sortConflictCandidates( |
| 27 | + candidates: readonly PrConflictCandidateResolution[], |
| 28 | +): PrConflictCandidateResolution[] { |
| 29 | + return [...candidates].toSorted((left, right) => { |
| 30 | + const priorityDiff = candidatePriority(left) - candidatePriority(right); |
| 31 | + if (priorityDiff !== 0) return priorityDiff; |
| 32 | + return left.title.localeCompare(right.title); |
| 33 | + }); |
| 34 | +} |
| 35 | + |
| 36 | +export function pickRecommendedConflictCandidate( |
| 37 | + analysis: Pick<PrConflictAnalysis, "candidates"> | null | undefined, |
| 38 | +): PrConflictCandidateResolution | null { |
| 39 | + const sorted = sortConflictCandidates(analysis?.candidates ?? []); |
| 40 | + return sorted[0] ?? null; |
| 41 | +} |
| 42 | + |
| 43 | +export function groupConflictCandidatesByFile( |
| 44 | + candidates: readonly PrConflictCandidateResolution[], |
| 45 | +): MergeConflictCandidateGroup[] { |
| 46 | + const groups = new Map<string, PrConflictCandidateResolution[]>(); |
| 47 | + for (const candidate of sortConflictCandidates(candidates)) { |
| 48 | + const nextGroup = groups.get(candidate.path) ?? []; |
| 49 | + nextGroup.push(candidate); |
| 50 | + groups.set(candidate.path, nextGroup); |
| 51 | + } |
| 52 | + |
| 53 | + return [...groups.entries()] |
| 54 | + .toSorted(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath)) |
| 55 | + .map(([path, groupedCandidates]) => ({ |
| 56 | + path, |
| 57 | + candidates: groupedCandidates, |
| 58 | + recommendedCandidate: groupedCandidates[0] ?? null, |
| 59 | + })); |
| 60 | +} |
| 61 | + |
| 62 | +export function buildConflictRecommendation(input: { |
| 63 | + analysis: PrConflictAnalysis | undefined; |
| 64 | + hasPreparedWorkspace: boolean; |
| 65 | +}): MergeConflictRecommendation { |
| 66 | + if (!input.analysis || input.analysis.status === "unavailable") { |
| 67 | + return { |
| 68 | + candidateId: null, |
| 69 | + tone: "neutral", |
| 70 | + title: "Resolve a pull request link to start.", |
| 71 | + detail: |
| 72 | + "Paste a GitHub pull request URL to inspect mergeability, pull candidate resolutions, and stage a human-readable handoff note.", |
| 73 | + }; |
| 74 | + } |
| 75 | + |
| 76 | + if (input.analysis.status === "clean") { |
| 77 | + return { |
| 78 | + candidateId: null, |
| 79 | + tone: "success", |
| 80 | + title: "No merge conflicts are active.", |
| 81 | + detail: input.analysis.summary, |
| 82 | + }; |
| 83 | + } |
| 84 | + |
| 85 | + const recommendedCandidate = pickRecommendedConflictCandidate(input.analysis); |
| 86 | + if (recommendedCandidate?.confidence === "safe") { |
| 87 | + return { |
| 88 | + candidateId: recommendedCandidate.id, |
| 89 | + tone: "success", |
| 90 | + title: "Recommended resolution is ready.", |
| 91 | + detail: |
| 92 | + "OK Code found a deterministic candidate. Review the patch, capture the operator note, and then apply the recommendation.", |
| 93 | + }; |
| 94 | + } |
| 95 | + |
| 96 | + if (recommendedCandidate) { |
| 97 | + return { |
| 98 | + candidateId: recommendedCandidate.id, |
| 99 | + tone: "warning", |
| 100 | + title: "Review-required options are available.", |
| 101 | + detail: |
| 102 | + "OK Code found possible resolutions, but none are deterministic. Compare both sides, leave a readable decision note, and only then apply one.", |
| 103 | + }; |
| 104 | + } |
| 105 | + |
| 106 | + if (input.hasPreparedWorkspace) { |
| 107 | + return { |
| 108 | + candidateId: null, |
| 109 | + tone: "warning", |
| 110 | + title: "Manual merge work is still required.", |
| 111 | + detail: |
| 112 | + "The workspace is prepared, but no candidate patch was safe to generate. Resolve the markers manually and keep the handoff note explicit.", |
| 113 | + }; |
| 114 | + } |
| 115 | + |
| 116 | + return { |
| 117 | + candidateId: null, |
| 118 | + tone: "warning", |
| 119 | + title: "Prepare a local workspace to continue.", |
| 120 | + detail: |
| 121 | + "GitHub reports merge conflicts, but file-level candidates need a checked-out pull request branch or worktree before OK Code can inspect markers locally.", |
| 122 | + }; |
| 123 | +} |
| 124 | + |
| 125 | +function feedbackDispositionSentence(disposition: MergeConflictFeedbackDisposition): string { |
| 126 | + switch (disposition) { |
| 127 | + case "accept": |
| 128 | + return "Accept the proposed resolution after reviewing the resulting diff."; |
| 129 | + case "review": |
| 130 | + return "Keep this in review until a human confirms the chosen side."; |
| 131 | + case "escalate": |
| 132 | + return "Escalate this conflict to the PR author or code owner for direction."; |
| 133 | + case "blocked": |
| 134 | + return "Treat this conflict as blocked until the workspace or intent is clarified."; |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +export function buildConflictFeedbackPreview(input: { |
| 139 | + disposition: MergeConflictFeedbackDisposition; |
| 140 | + note: string; |
| 141 | + pullRequest: GitResolvedPullRequest | null; |
| 142 | + selectedCandidate: PrConflictCandidateResolution | null; |
| 143 | + workspaceLabel: string; |
| 144 | +}): string { |
| 145 | + const lines = [ |
| 146 | + input.pullRequest |
| 147 | + ? `Merge conflict brief for PR #${input.pullRequest.number}: ${input.pullRequest.title}` |
| 148 | + : "Merge conflict brief", |
| 149 | + `Workspace: ${input.workspaceLabel}`, |
| 150 | + input.selectedCandidate |
| 151 | + ? `Candidate: ${input.selectedCandidate.title} on ${input.selectedCandidate.path} (${input.selectedCandidate.confidence} confidence).` |
| 152 | + : "Candidate: No deterministic candidate is selected yet.", |
| 153 | + input.selectedCandidate |
| 154 | + ? `Rationale: ${input.selectedCandidate.description}` |
| 155 | + : "Rationale: Keep the branch prepared and inspect the conflict manually.", |
| 156 | + `Disposition: ${feedbackDispositionSentence(input.disposition)}`, |
| 157 | + ]; |
| 158 | + |
| 159 | + const trimmedNote = input.note.trim(); |
| 160 | + if (trimmedNote.length > 0) { |
| 161 | + lines.push(`Operator note: ${trimmedNote}`); |
| 162 | + } |
| 163 | + |
| 164 | + return lines.join("\n"); |
| 165 | +} |
0 commit comments