Skip to content

Commit eb037b6

Browse files
authored
fix: merge conflicts flow (#33)
* docs: add readme * feat: add slack notifications * fix: ci * fix: ticket move after error * feat: add option to authenticate with claude token * fix: merge conflicts flow (#32)
1 parent aa1ea9f commit eb037b6

15 files changed

Lines changed: 365 additions & 102 deletions

File tree

e2e/env.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ const schema = z.object({
1212
COLUMN_AI_REVIEW: z.string().min(1),
1313
COLUMN_BACKLOG: z.string().min(1),
1414

15-
GITHUB_TOKEN: z.string().min(1),
16-
GITHUB_OWNER: z.string().min(1),
17-
GITHUB_REPO: z.string().min(1),
15+
E2E_GITHUB_TOKEN: z.string().min(1),
16+
E2E_GITHUB_OWNER: z.string().min(1),
17+
E2E_GITHUB_REPO: z.string().min(1),
1818

1919
CRON_SECRET: z.string().min(1),
2020

e2e/helpers/github.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { Octokit } from "@octokit/rest";
22
import { e2eEnv } from "../env.js";
33

4-
const octokit = new Octokit({ auth: e2eEnv.GITHUB_TOKEN });
5-
const ownerRepo = { owner: e2eEnv.GITHUB_OWNER, repo: e2eEnv.GITHUB_REPO };
4+
const octokit = new Octokit({ auth: e2eEnv.E2E_GITHUB_TOKEN });
5+
const ownerRepo = { owner: e2eEnv.E2E_GITHUB_OWNER, repo: e2eEnv.E2E_GITHUB_REPO };
66

77
export async function findPR(
88
branchName: string,
99
): Promise<{ number: number; url: string } | null> {
1010
const { data } = await octokit.pulls.list({
1111
...ownerRepo,
12-
head: `${e2eEnv.GITHUB_OWNER}:${branchName}`,
12+
head: `${e2eEnv.E2E_GITHUB_OWNER}:${branchName}`,
1313
state: "open",
1414
});
1515
if (data.length === 0) return null;

e2e/tier2/stop-hook-commit.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, it, expect, afterAll } from "vitest";
2+
import { e2eEnv } from "../env.js";
3+
4+
/**
5+
* Verifies the Stop hook forces Claude Code to commit uncommitted changes
6+
* before exiting in --print mode.
7+
*
8+
* Flow:
9+
* 1. Create a sandbox from the e2e repo
10+
* 2. Install Claude Code + configure the Stop hook
11+
* 3. Create an uncommitted file
12+
* 4. Run Claude Code with a simple prompt (--print mode)
13+
* 5. Assert git status is clean (hook forced a commit)
14+
*/
15+
describe("Stop hook commit guard", () => {
16+
let sandbox: any;
17+
18+
afterAll(async () => {
19+
if (sandbox) await sandbox.stop().catch(() => {});
20+
});
21+
22+
it("forces Claude Code to commit uncommitted changes before stopping", async () => {
23+
const { Sandbox } = await import("@vercel/sandbox");
24+
25+
sandbox = await Sandbox.create({
26+
source: {
27+
type: "git",
28+
url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`,
29+
username: "x-access-token",
30+
password: e2eEnv.E2E_GITHUB_TOKEN,
31+
revision: "main",
32+
depth: 1,
33+
},
34+
runtime: "node24",
35+
timeout: 300_000,
36+
env: {
37+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "",
38+
...(process.env.CLAUDE_CODE_OAUTH_TOKEN
39+
? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
40+
: {}),
41+
},
42+
});
43+
44+
// 1. Configure git identity
45+
await sandbox.runCommand("bash", [
46+
"-c",
47+
'git config user.name "test-bot" && git config user.email "test@test.com"',
48+
]);
49+
50+
// 2. Install Claude Code
51+
await sandbox.runCommand("npm", [
52+
"install",
53+
"-g",
54+
"@anthropic-ai/claude-code",
55+
]);
56+
57+
// 3. Set up the Stop hook (same as SandboxManager.provision)
58+
await sandbox.runCommand("bash", [
59+
"-c",
60+
[
61+
`mkdir -p ~/.claude`,
62+
`cat > ~/.claude/commit-guard.sh << 'SCRIPT'`,
63+
`#!/bin/bash`,
64+
`input=$(cat)`,
65+
`if echo "$input" | grep -q '"stop_hook_active":true'; then exit 0; fi`,
66+
`changes=$(git status --porcelain | grep -v '^.. \\.claude/' | grep -v '^?? \\.claude/' | grep -v 'requirements\\.md')`,
67+
`if [ -n "$changes" ]; then`,
68+
` echo '{"decision":"block","reason":"You have uncommitted changes. You MUST either commit all changes with a descriptive message or revert them before stopping."}' >&2`,
69+
` exit 2`,
70+
`fi`,
71+
`SCRIPT`,
72+
`chmod +x ~/.claude/commit-guard.sh`,
73+
`cat > ~/.claude/settings.json << 'JSON'`,
74+
`{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/commit-guard.sh"}]}]}}`,
75+
`JSON`,
76+
].join("\n"),
77+
]);
78+
79+
// 4. Skip onboarding (if using OAuth token)
80+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
81+
await sandbox.runCommand("bash", [
82+
"-c",
83+
`echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`,
84+
]);
85+
}
86+
87+
// 5. Create an uncommitted file (simulates agent work without committing)
88+
await sandbox.runCommand("bash", [
89+
"-c",
90+
'echo "test content" > test-uncommitted.txt',
91+
]);
92+
93+
// Verify the file is uncommitted
94+
const beforeStatus = await sandbox.runCommand("git", [
95+
"status",
96+
"--porcelain",
97+
]);
98+
const beforeOutput = (await beforeStatus.stdout()).trim();
99+
expect(beforeOutput).toContain("test-uncommitted.txt");
100+
101+
// 6. Run Claude Code — the prompt must instruct it to commit any uncommitted changes
102+
const result = await sandbox.runCommand("bash", [
103+
"-c",
104+
'echo "Commit all uncommitted changes with a descriptive commit message, then exit." | claude --print --dangerously-skip-permissions',
105+
]);
106+
const stdout = (await result.stdout()).trim();
107+
const stderr = (await result.stderr()).trim();
108+
109+
console.log("Claude stdout:", stdout);
110+
console.log("Claude stderr:", stderr);
111+
112+
// 7. Check git status — should be clean if the hook worked
113+
const afterStatus = await sandbox.runCommand("git", [
114+
"status",
115+
"--porcelain",
116+
]);
117+
const afterOutput = (await afterStatus.stdout()).trim();
118+
119+
expect(afterOutput).toBe("");
120+
});
121+
});

e2e/vitest.e2e.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ if (existsSync(envPath)) {
1111
const idx = trimmed.indexOf("=");
1212
if (idx === -1) continue;
1313
const key = trimmed.slice(0, idx).trim();
14-
const value = trimmed.slice(idx + 1).trim();
14+
let value = trimmed.slice(idx + 1).trim();
15+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
16+
value = value.slice(1, -1);
17+
}
1518
if (!(key in process.env)) process.env[key] = value;
1619
}
1720
}

env.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe("env", () => {
5959

6060
it("throws on missing required field", async () => {
6161
const partial = { ...VALID_ENV };
62-
delete (partial as any).ANTHROPIC_API_KEY;
62+
delete (partial as any).GITHUB_TOKEN;
6363
Object.assign(process.env, partial);
6464
await expect(async () => {
6565
await import("./env.js");

env.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export const env = createEnv({
2727
CHAT_SDK_BOT_NAME: z.string().default("blazebot"),
2828

2929
// Agent
30-
ANTHROPIC_API_KEY: z.string().min(1),
30+
ANTHROPIC_API_KEY: z.string().min(1).optional(),
31+
CLAUDE_CODE_OAUTH_TOKEN: z.string().min(1).optional(),
3132
CLAUDE_MODEL: z.string().default("claude-opus-4-6"),
3233
COMMIT_AUTHOR: z.string().default("ai-workflow-blazity"),
3334
COMMIT_EMAIL: z.string().default("ai-workflow@blazity.com"),

src/adapters/vcs/github.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Octokit } from "@octokit/rest";
2+
import { FatalError } from "workflow";
23
import type { VCSAdapter, PullRequest, PRComment } from "./types.js";
34

45
export interface GitHubConfig {
@@ -70,17 +71,30 @@ export class GitHubAdapter implements VCSAdapter {
7071
title: string,
7172
body: string,
7273
): Promise<PullRequest> {
73-
const { data } = await this.octokit.pulls.create({
74-
...this.ownerRepo,
75-
head: branch,
76-
base: this.config.baseBranch,
77-
title,
78-
body,
79-
});
80-
return { id: data.number, url: data.html_url, branch };
74+
try {
75+
const { data } = await this.octokit.pulls.create({
76+
...this.ownerRepo,
77+
head: branch,
78+
base: this.config.baseBranch,
79+
title,
80+
body,
81+
});
82+
return { id: data.number, url: data.html_url, branch };
83+
} catch (err: any) {
84+
// 422 (validation: PR already exists, branch missing) and 404 are non-retryable.
85+
// 401/403 (token expired, rate limit) are transient and should be retried.
86+
if (err.status === 422 || err.status === 404) {
87+
throw new FatalError(err.message);
88+
}
89+
throw err;
90+
}
8191
}
8292

83-
async push(branch: string, files: Array<{ path: string; content: string }>): Promise<void> {
93+
async push(
94+
branch: string,
95+
files: Array<{ path: string; content: string }>,
96+
options?: { mergeParentSha?: string },
97+
): Promise<void> {
8498
const { data: refData } = await this.octokit.git.getRef({
8599
...this.ownerRepo,
86100
ref: `heads/${branch}`,
@@ -114,11 +128,20 @@ export class GitHubAdapter implements VCSAdapter {
114128
tree: treeItems,
115129
});
116130

131+
// When mergeParentSha is set, create a merge commit with two parents.
132+
// This tells GitHub the branch histories have been reconciled, clearing
133+
// the "has conflicts" status on the PR.
134+
const parents = options?.mergeParentSha
135+
? [latestCommitSha, options.mergeParentSha]
136+
: [latestCommitSha];
137+
117138
const { data: newCommit } = await this.octokit.git.createCommit({
118139
...this.ownerRepo,
119-
message: "feat: agent implementation",
140+
message: options?.mergeParentSha
141+
? "merge: resolve conflicts with base branch"
142+
: "feat: agent implementation",
120143
tree: tree.sha,
121-
parents: [latestCommitSha],
144+
parents,
122145
});
123146

124147
await this.octokit.git.updateRef({
@@ -128,6 +151,14 @@ export class GitHubAdapter implements VCSAdapter {
128151
});
129152
}
130153

154+
async getBranchSha(branch: string): Promise<string> {
155+
const { data } = await this.octokit.git.getRef({
156+
...this.ownerRepo,
157+
ref: `heads/${branch}`,
158+
});
159+
return data.object.sha;
160+
}
161+
131162
async getPRComments(prId: number): Promise<PRComment[]> {
132163
const { data: reviewComments } =
133164
await this.octokit.pulls.listReviewComments({

src/adapters/vcs/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ export interface PRComment {
1313
export interface VCSAdapter {
1414
createBranch(name: string, base: string): Promise<void>;
1515
createPR(branch: string, title: string, body: string): Promise<PullRequest>;
16-
push(branch: string, files: Array<{ path: string; content: string }>): Promise<void>;
16+
push(
17+
branch: string,
18+
files: Array<{ path: string; content: string }>,
19+
options?: { mergeParentSha?: string },
20+
): Promise<void>;
1721
getPRComments(prId: number): Promise<PRComment[]>;
1822
getPRConflictStatus(prId: number): Promise<boolean>;
1923
findPR(branch: string): Promise<PullRequest | null>;
24+
getBranchSha(branch: string): Promise<string>;
2025
}

src/lib/dispatch.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function makeAdapters(
6565
getPRComments: vi.fn(),
6666
getPRConflictStatus: vi.fn(),
6767
findPR: overrides.findPR ?? vi.fn().mockResolvedValue(null),
68+
getBranchSha: vi.fn().mockResolvedValue("abc123"),
6869
},
6970
messaging: {
7071
notify: vi.fn(),

src/lib/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ You have access to **superpowers skills** installed globally. Use them to improv
146146
147147
0. **Restore session memory** — Check if \`blazebot/memory/[TASK_ID].md\` exists (where \`[TASK_ID]\` is the Ticket ID from above, e.g. \`AIW-123\`). If it exists, read it immediately. Use the progress, decisions, and file list to understand prior implementation context and any previous fix attempts.
148148
1. Read the review feedback carefully.
149-
2. If merge conflicts exist, merge the target branch and resolve conflicts first.
149+
2. If merge conflicts exist, the base branch has already been merged into your branch — the repo is in a \`MERGING\` state with conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) in the affected files. Do NOT run \`git merge\` again. Instead: edit each conflicted file to resolve the markers, then \`git add\` the resolved files, then run \`git merge --continue\` to complete the merge.
150150
3. Address each review comment — implement the requested changes.
151151
4. Run all tests to ensure nothing is broken.
152152
5. Self-review your changes.

0 commit comments

Comments
 (0)