Skip to content

Commit 3306285

Browse files
committed
feat(prReview): implement PR review features and conflict resolution
- Add new services for PR review, including configuration, dashboard, and conflict resolution. - Introduce layers for managing PR review logic and workflows. - Enhance WebSocket API to support PR review operations and notifications. - Implement error handling for PR review processes. - Update package dependencies to include 'yaml' for configuration management.
1 parent d03694c commit 3306285

22 files changed

Lines changed: 3244 additions & 68 deletions

apps/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"effect": "catalog:",
3131
"node-pty": "^1.1.0",
3232
"open": "^10.1.0",
33-
"ws": "^8.18.0"
33+
"ws": "^8.18.0",
34+
"yaml": "^2.8.1"
3435
},
3536
"devDependencies": {
3637
"@effect/language-service": "catalog:",

apps/server/src/prReview/Errors.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Schema } from "effect";
2+
import { GitCommandError, GitHubCliError } from "../git/Errors.ts";
3+
4+
export class PrReviewConfigError extends Schema.TaggedErrorClass<PrReviewConfigError>()(
5+
"PrReviewConfigError",
6+
{
7+
operation: Schema.String,
8+
detail: Schema.String,
9+
cause: Schema.optional(Schema.Defect),
10+
},
11+
) {
12+
override get message(): string {
13+
return `PR review config failed in ${this.operation}: ${this.detail}`;
14+
}
15+
}
16+
17+
export class PrReviewError extends Schema.TaggedErrorClass<PrReviewError>()("PrReviewError", {
18+
operation: Schema.String,
19+
detail: Schema.String,
20+
cause: Schema.optional(Schema.Defect),
21+
}) {
22+
override get message(): string {
23+
return `PR review failed in ${this.operation}: ${this.detail}`;
24+
}
25+
}
26+
27+
export type PrReviewServiceError =
28+
| PrReviewError
29+
| PrReviewConfigError
30+
| GitHubCliError
31+
| GitCommandError;
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import path from "node:path";
2+
import { promises as fsPromises } from "node:fs";
3+
4+
import { Effect, Layer } from "effect";
5+
import type { PrConflictCandidateResolution, PrReviewSummary } from "@okcode/contracts";
6+
import { GitCore } from "../../git/Services/GitCore.ts";
7+
import {
8+
MergeConflictResolver,
9+
type MergeConflictResolverShape,
10+
} from "../Services/MergeConflictResolver.ts";
11+
import { PrReviewError } from "../Errors.ts";
12+
13+
interface ParsedConflictBlock {
14+
before: string;
15+
ours: string;
16+
theirs: string;
17+
after: string;
18+
}
19+
20+
function parseFirstConflictBlock(contents: string): ParsedConflictBlock | null {
21+
const start = contents.indexOf("<<<<<<< ");
22+
if (start === -1) return null;
23+
const middle = contents.indexOf("\n=======\n", start);
24+
if (middle === -1) return null;
25+
const end = contents.indexOf("\n>>>>>>> ", middle);
26+
if (end === -1) return null;
27+
28+
const before = contents.slice(0, start);
29+
const ours = contents.slice(start, middle).split("\n").slice(1).join("\n") + "\n";
30+
const theirs = contents.slice(middle + "\n=======\n".length, end) + "\n";
31+
const afterStart = contents.indexOf("\n", end + 1);
32+
const after = afterStart === -1 ? "" : contents.slice(afterStart + 1);
33+
return { before, ours, theirs, after };
34+
}
35+
36+
function buildCandidate(input: {
37+
id: string;
38+
path: string;
39+
title: string;
40+
description: string;
41+
confidence: "safe" | "review";
42+
replacement: string;
43+
}): PrConflictCandidateResolution {
44+
return {
45+
id: input.id,
46+
path: input.path,
47+
title: input.title,
48+
description: input.description,
49+
confidence: input.confidence,
50+
previewPatch: input.replacement,
51+
};
52+
}
53+
54+
function buildCandidatesForFile(input: {
55+
relativePath: string;
56+
contents: string;
57+
}): PrConflictCandidateResolution[] {
58+
const parsed = parseFirstConflictBlock(input.contents);
59+
if (!parsed) return [];
60+
61+
const normalizedOurs = parsed.ours.trim();
62+
const normalizedTheirs = parsed.theirs.trim();
63+
if (normalizedOurs.length === 0 && normalizedTheirs.length === 0) {
64+
return [];
65+
}
66+
67+
const safeChoice =
68+
normalizedOurs.length === 0
69+
? "theirs"
70+
: normalizedTheirs.length === 0
71+
? "ours"
72+
: normalizedOurs === normalizedTheirs
73+
? "ours"
74+
: null;
75+
76+
const candidates: PrConflictCandidateResolution[] = [];
77+
if (safeChoice) {
78+
const replacement = safeChoice === "ours" ? parsed.ours : parsed.theirs;
79+
candidates.push(
80+
buildCandidate({
81+
id: `${input.relativePath}:${safeChoice}`,
82+
path: input.relativePath,
83+
title: `Take ${safeChoice}`,
84+
description:
85+
safeChoice === "ours"
86+
? "Safe resolution derived from the current local side."
87+
: "Safe resolution derived from the incoming side.",
88+
confidence: "safe",
89+
replacement,
90+
}),
91+
);
92+
return candidates;
93+
}
94+
95+
candidates.push(
96+
buildCandidate({
97+
id: `${input.relativePath}:ours`,
98+
path: input.relativePath,
99+
title: "Prefer current side",
100+
description: "Review-required candidate using the current local side.",
101+
confidence: "review",
102+
replacement: parsed.ours,
103+
}),
104+
buildCandidate({
105+
id: `${input.relativePath}:theirs`,
106+
path: input.relativePath,
107+
title: "Prefer incoming side",
108+
description: "Review-required candidate using the incoming side.",
109+
confidence: "review",
110+
replacement: parsed.theirs,
111+
}),
112+
);
113+
114+
return candidates;
115+
}
116+
117+
async function readCandidatesForConflicts(cwd: string, conflictedFiles: readonly string[]) {
118+
const candidates: PrConflictCandidateResolution[] = [];
119+
for (const relativePath of conflictedFiles) {
120+
try {
121+
const absolutePath = path.join(cwd, relativePath);
122+
const contents = await fsPromises.readFile(absolutePath, "utf8");
123+
candidates.push(...buildCandidatesForFile({ relativePath, contents }));
124+
} catch {
125+
// Ignore unreadable files; they remain unresolved and will be surfaced in summary text.
126+
}
127+
}
128+
return candidates;
129+
}
130+
131+
const makeMergeConflictResolver = Effect.gen(function* () {
132+
const gitCore = yield* GitCore;
133+
134+
const resolveAnalysis = (cwd: string, pullRequest: PrReviewSummary) =>
135+
Effect.tryPromise({
136+
try: async () => {
137+
const status = await Effect.runPromise(gitCore.statusDetails(cwd));
138+
if (status.hasConflicts) {
139+
const candidates = await readCandidatesForConflicts(cwd, status.conflictedFiles);
140+
return {
141+
status: "conflicted" as const,
142+
mergeableState: pullRequest.mergeable,
143+
summary:
144+
candidates.length > 0
145+
? `${status.conflictedFiles.length} conflicted file(s) detected locally. Review candidate resolutions before applying.`
146+
: `${status.conflictedFiles.length} conflicted file(s) detected locally. No deterministic candidate resolutions were found.`,
147+
candidates,
148+
};
149+
}
150+
151+
if (pullRequest.mergeable === "CONFLICTING" || pullRequest.mergeStateStatus === "DIRTY") {
152+
return {
153+
status: "conflicted" as const,
154+
mergeableState: pullRequest.mergeable,
155+
summary:
156+
"GitHub reports merge conflicts. Check out the PR locally to generate file-level candidate resolutions.",
157+
candidates: [],
158+
};
159+
}
160+
161+
return {
162+
status: "clean" as const,
163+
mergeableState: pullRequest.mergeable,
164+
summary: "No merge conflicts detected.",
165+
candidates: [],
166+
};
167+
},
168+
catch: (cause) =>
169+
new PrReviewError({
170+
operation: "analyzeConflicts",
171+
detail: `Failed to inspect merge conflicts in ${cwd}`,
172+
cause,
173+
}),
174+
});
175+
176+
const service: MergeConflictResolverShape = {
177+
analyze: ({ cwd, pullRequest }) => resolveAnalysis(cwd, pullRequest),
178+
apply: ({ cwd, pullRequest, candidateId }) =>
179+
resolveAnalysis(cwd, pullRequest).pipe(
180+
Effect.flatMap((analysis) => {
181+
const candidate = analysis.candidates.find((entry) => entry.id === candidateId);
182+
if (!candidate) {
183+
return Effect.fail(
184+
new PrReviewError({
185+
operation: "applyConflictResolution",
186+
detail: `Conflict candidate not found: ${candidateId}`,
187+
}),
188+
);
189+
}
190+
191+
return Effect.tryPromise({
192+
try: async () => {
193+
const absolutePath = path.join(cwd, candidate.path);
194+
const contents = await fsPromises.readFile(absolutePath, "utf8");
195+
const parsed = parseFirstConflictBlock(contents);
196+
if (!parsed) {
197+
throw new Error("Conflict markers were not found in the target file.");
198+
}
199+
const nextContents = `${parsed.before}${candidate.previewPatch}${parsed.after}`;
200+
await fsPromises.writeFile(absolutePath, nextContents, "utf8");
201+
return {
202+
candidateId,
203+
applied: true,
204+
summary: `Applied candidate resolution for ${candidate.path}. Review the diff before committing.`,
205+
};
206+
},
207+
catch: (cause) =>
208+
new PrReviewError({
209+
operation: "applyConflictResolution",
210+
detail: `Failed to apply conflict candidate ${candidateId}`,
211+
cause,
212+
}),
213+
});
214+
}),
215+
),
216+
};
217+
218+
return service;
219+
});
220+
221+
export const MergeConflictResolverLive = Layer.effect(
222+
MergeConflictResolver,
223+
makeMergeConflictResolver,
224+
);

0 commit comments

Comments
 (0)