Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions server/babysitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7699,6 +7699,97 @@ test("babysitPR records conflict repair failure when agent leaves conflict marke
delete process.env.CODEFACTORY_HOME;
});

test("babysitPR commits agent-resolved conflicts even when the agent does not stage them", async () => {
const storage = new MemStorage();
await storage.updateConfig({ autoUpdateDocs: false });
const pr = await storage.addPR({
number: 106,
title: "Verbose PR",
repo: "alex-morgan-o/lolodex",
branch: "feature/verbose",
author: "octocat",
url: "https://github.com/alex-morgan-o/lolodex/pull/106",
status: "watching",
feedbackItems: [],
accepted: 0,
rejected: 0,
flagged: 0,
testsPassed: null,
lintPassed: null,
lastChecked: null,
});

const worktreeRoot = await mkdtemp(path.join(os.tmpdir(), "codefactory-home-"));
process.env.CODEFACTORY_HOME = worktreeRoot;
const pullSummary = makePullSummary(pr, { mergeable: false });
const gitRunner = makeGitRunCommand({
localHeadSha: "merge123",
remoteHeadSha: "merge123",
});
// The agent resolves the conflict by editing the file but never stages it,
// so the unmerged index entries persist until PatchDeck stages them.
let staged = false;
let conflictAgentCalled = false;

const babysitter = new PRBabysitter(
storage,
{
buildOctokit: async () => ({}) as never,
fetchFeedbackItemsForPR: async () => [],
fetchPullSummary: async () => pullSummary,
listFailingStatuses: async () => [],
checkCISettled: async () => true,
listOpenPullsForRepo: async () => [],
postFollowUpForFeedbackItem: async () => undefined,
resolveReviewThread: async () => undefined,
resolveGitHubAuthToken: async () => "test-token",
addReactionToComment: async () => {},
postStatusReplyForFeedbackItem: async () => null,
updateStatusReply: async () => {},
},
{
resolveAgent: async () => "codex",
ciPollIntervalMs: 0,
evaluateFixNecessityWithAgent: async () => ({
needsFix: false,
reason: "No fix needed",
}),
applyFixesWithAgent: async ({ prompt }) => {
conflictAgentCalled = true;
assert.match(prompt, /merge conflicts/i);
return { code: 0, stdout: "resolved\n", stderr: "" };
},
runCommand: async (command: string, args: string[], opts?: Record<string, unknown>) => {
if (command === "git" && args[0] === "merge") {
return { code: 1, stdout: "", stderr: "CONFLICT" };
}
if (command === "git" && args[0] === "add") {
staged = true;
return { code: 0, stdout: "", stderr: "" };
}
if (command === "git" && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--diff-filter=U") {
return { code: 0, stdout: staged ? "" : "src/conflict.ts\n", stderr: "" };
}
return gitRunner(command, args, opts);
},
},
);

await babysitter.babysitPR(pr.id, "codex");

const updated = await storage.getPR(pr.id);
const runs = await storage.listAgentRuns({ prId: pr.id });
const logs = await storage.getLogs(pr.id);

assert.equal(conflictAgentCalled, true);
assert.equal(staged, true, "PatchDeck should stage agent-resolved conflict files");
assert.equal(updated?.status, "watching");
assert.equal(runs[0]?.status, "completed");
assert.ok(logs.some((log) => log.phase === "conflict" && log.message.includes("Merge conflicts resolved and committed")));

delete process.env.CODEFACTORY_HOME;
});

test("babysitPR skips conflict resolution when autoResolveMergeConflicts is disabled", async () => {
const storage = new MemStorage();
await storage.updateConfig({ autoResolveMergeConflicts: false, autoUpdateDocs: false });
Expand Down
18 changes: 18 additions & 0 deletions server/babysitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4944,6 +4944,24 @@ export class PRBabysitter {
metadata: { code: conflictResult.code },
});

// The agent resolves conflicts by editing worktree files but usually
// does not stage them. Stage everything before the unmerged-index
// check below, otherwise resolved files still appear as unmerged and
// the run is falsely declared failed. Any leftover conflict markers
// are caught by the marker check that follows.
const stageResolvedConflicts = await runLoggedCommand({
currentPrId: pr.id,
command: "git",
args: ["add", "-A"],
cwd: worktreePath,
timeoutMs: 30000,
phase: "conflict.agent",
successMessage: "Staged resolved merge conflict files",
});
if (stageResolvedConflicts.code !== 0) {
throw new Error(formatGitFailure("staging resolved merge conflicts", stageResolvedConflicts));
}

const unresolvedAfterAgent = await this.runtime.runCommand("git", ["diff", "--name-only", "--diff-filter=U"], {
cwd: worktreePath,
timeoutMs: 5000,
Expand Down
Loading