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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- run: bun run typecheck

- run: bun run test
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"scripts": {
"start": "bun run index.ts",
"test": "bun test",
"test": "bash -c 'for f in $(find src -name \"*.test.ts\"); do bun test \"$f\" || exit 1; done'",
"typecheck": "bun tsc --noEmit"
},
"dependencies": {
Expand Down
66 changes: 62 additions & 4 deletions src/__tests__/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ const mockCheckoutBase = mock(async () => true);
const mockCreateBranch = mock(async () => true);
const mockCommitAndPush = mock(async () => true);
const mockOpenPR = mock(async () => "https://github.com/owner/repo/pull/1");
const mockCheckoutExistingBranch = mock(async () => true);
const mockMergeBaseBranch = mock(async () => ({ merged: true }));
const mockSpawnAgent = mock(async () => ({ done: false, exitCode: 0 }));
const mockRunChecks = mock(async () => ({ allPassed: true, failureSummary: "", results: [] }));
const mockLoadContexts = mock(async () => new Map());
const mockLoadInstructions = mock(async () => new Map());
const mockDiscoverWorkflow = mock(async () => ({ body: "test workflow", name: "workflow" }));
const mockResolveTemplate = mock(() => "resolved prompt");
const mockResolveContinueTemplate = mock(() => "resolved continue prompt");

mock.module("../core/pr.js", () => ({
branchName: () => "storm/issue-1-test",
checkoutBase: mockCheckoutBase,
createBranch: mockCreateBranch,
commitAndPush: mockCommitAndPush,
openPR: mockOpenPR,
checkoutExistingBranch: mock(async () => true),
checkoutExistingBranch: mockCheckoutExistingBranch,
mergeBaseBranch: mockMergeBaseBranch,
}));

mock.module("../core/agent.js", () => ({
Expand All @@ -44,7 +48,7 @@ mock.module("../primitives/discovery.js", () => ({

mock.module("../core/resolver.js", () => ({
resolveTemplate: mockResolveTemplate,
resolveContinueTemplate: mock(() => "resolved continue prompt"),
resolveContinueTemplate: mockResolveContinueTemplate,
}));

mock.module("../core/output.js", () => ({
Expand All @@ -64,8 +68,8 @@ mock.module("../core/github.js", () => ({
commentOnIssue: mock(async () => {}),
}));

import { processIssue } from "../core/loop.js";
import type { GitHubIssue, StormConfig } from "../core/types.js";
import { processIssue, processContinue } from "../core/loop.js";
import type { GitHubIssue, StormConfig, PRReviewContext } from "../core/types.js";

const issue: GitHubIssue = {
number: 1,
Expand Down Expand Up @@ -151,3 +155,57 @@ describe("processIssue — final checks gate", () => {
expect(calls[1]![1]!.checkFailures).toBe("TypeScript errors found");
});
});

const prContext: PRReviewContext = {
prNumber: 1,
prTitle: "Test PR",
prBody: "Closes #1",
prBranch: "storm/issue-1-test",
baseBranch: "main",
diffSummary: "+ some changes",
reviews: [],
linkedIssue: issue,
};

describe("processContinue", () => {
beforeEach(() => {
mockSpawnAgent.mockReset();
mockRunChecks.mockReset();
mockResolveContinueTemplate.mockReset();
mockCheckoutExistingBranch.mockReset();
mockMergeBaseBranch.mockReset();
mockCommitAndPush.mockReset();
mockCheckoutExistingBranch.mockImplementation(async () => true);
mockMergeBaseBranch.mockImplementation(async () => ({ merged: true }));
mockCommitAndPush.mockImplementation(async () => true);
mockResolveContinueTemplate.mockImplementation(() => "continue prompt");
mockSpawnAgent.mockImplementation(async () => ({ done: true, exitCode: 0, usage: null, sessionId: null }));
mockRunChecks.mockImplementation(async () => ({ allPassed: true, failureSummary: "", results: [] }));
});

it("calls mergeBaseBranch after checkout", async () => {
await processContinue(prContext, config, "/tmp");
expect(mockMergeBaseBranch).toHaveBeenCalledTimes(1);
expect(mockMergeBaseBranch).toHaveBeenCalledWith("main", "/tmp");
});

it("succeeds when merge is clean", async () => {
const result = await processContinue(prContext, config, "/tmp");
expect(result.success).toBe(true);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
});

it("continues when merge has conflicts", async () => {
mockMergeBaseBranch.mockImplementation(async () => ({
merged: false,
conflicts: {
conflictedFiles: ["src/index.ts"],
conflictDetails: "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main",
},
}));

const result = await processContinue(prContext, config, "/tmp");
expect(result.success).toBe(true);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
});
});
16 changes: 12 additions & 4 deletions src/commands/continue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
fetchIssue,
fetchPullRequest,
fetchPRReviews,
fetchPRSessionId,
fetchPRCommentsAndSessionId,
} from "../core/github.js";
import { processContinue } from "../core/loop.js";
import { resolveContinueTemplate } from "../core/resolver.js";
Expand Down Expand Up @@ -58,15 +58,16 @@ export async function continueCommand(
}
const issueNumber = parseInt(issueMatch[1], 10);

// Fetch issue, reviews, and session ID in parallel
const [issue, reviews, sessionId] = await Promise.all([
// Fetch issue, reviews, comments, and session ID in parallel
const [issue, reviews, { comments: prComments, sessionId }] = await Promise.all([
fetchIssue(config.github.repo, issueNumber),
fetchPRReviews(config.github.repo, prNumber),
fetchPRSessionId(config.github.repo, prNumber),
fetchPRCommentsAndSessionId(config.github.repo, prNumber),
]);

log.info(`Linked issue: #${issueNumber} — ${issue.title}`);
log.info(`Reviews: ${reviews.length} review(s)`);
log.info(`PR comments: ${prComments.length} comment(s)`);
if (sessionId) {
log.info(`Session ID: ${sessionId} (will resume)`);
} else {
Expand All @@ -91,6 +92,7 @@ export async function continueCommand(
reviews,
linkedIssue: issue,
sessionId,
comments: prComments,
};

if (dryRun) {
Expand Down Expand Up @@ -122,11 +124,17 @@ You are continuing work on a pull request. A reviewer has left feedback that nee
## Current Diff
{{ pr.diff }}

{{ conflicts }}

## PR Comments
{{ pr.comments }}

## Reviewer Feedback
{{ pr.reviews }}

## Task
Address the reviewer feedback above. Make the requested changes while maintaining code quality.
If there are merge conflicts, resolve them by choosing the correct code and removing all conflict markers.
When done, output %%STORM_DONE%% on its own line.

{{ checks.failures }}
Expand Down
51 changes: 40 additions & 11 deletions src/core/github.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Octokit } from "@octokit/rest";
import { execSync } from "child_process";
import type { GitHubIssue, GeneratedIssue, PRReview, PRReviewComment } from "./types.js";
import type { GitHubIssue, GeneratedIssue, PRReview, PRReviewComment, PRComment } from "./types.js";
import { log } from "./output.js";

let cachedToken: string | undefined;
Expand Down Expand Up @@ -315,26 +315,55 @@ export async function fetchPRSessionId(
repoStr: string,
prNumber: number
): Promise<string | undefined> {
const { sessionId } = await fetchPRCommentsAndSessionId(repoStr, prNumber);
return sessionId;
}

export async function fetchPRCommentsAndSessionId(
repoStr: string,
prNumber: number
): Promise<{ comments: PRComment[]; sessionId?: string }> {
const octokit = getOctokit();
const { owner, repo } = parseRepo(repoStr);

const comments = await octokit.paginate(octokit.issues.listComments, {
const rawComments = await octokit.paginate(octokit.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});

const pattern = /<!-- storm:session_id=([a-f0-9-]+) -->/;
for (const comment of comments) {
const match = comment.body?.match(pattern);
if (match) return match[1];
const sessionPattern = /<!-- storm:session_id=([a-f0-9-]+) -->/;
const stormPattern = /<!-- storm:session_id=|^## Storm/;
let sessionId: string | undefined;

const prComments: PRComment[] = [];

for (const comment of rawComments) {
const body = comment.body ?? "";

// Extract session ID
const sessionMatch = body.match(sessionPattern);
if (sessionMatch) {
sessionId = sessionMatch[1];
}

// Filter out storm's own comments
if (stormPattern.test(body)) continue;

prComments.push({
author: comment.user?.login ?? "unknown",
body,
createdAt: comment.created_at,
});
}

// Also check PR body
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
const bodyMatch = pr.body?.match(pattern);
if (bodyMatch) return bodyMatch[1];
// Also check PR body for session ID
if (!sessionId) {
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
const bodyMatch = pr.body?.match(sessionPattern);
if (bodyMatch) sessionId = bodyMatch[1];
}

return undefined;
return { comments: prComments, sessionId };
}
14 changes: 13 additions & 1 deletion src/core/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { resolveTemplate, resolveContinueTemplate } from "./resolver.js";
import { spawnAgent } from "./agent.js";
import { runChecks } from "./checks.js";
import { commentOnIssue } from "./github.js";
import { branchName, createBranch, checkoutBase, commitAndPush, openPR, checkoutExistingBranch } from "./pr.js";
import { branchName, createBranch, checkoutBase, commitAndPush, openPR, checkoutExistingBranch, mergeBaseBranch } from "./pr.js";

let stopRequested = false;

Expand Down Expand Up @@ -297,11 +297,17 @@ You are continuing work on a pull request. A reviewer has left feedback that nee
## Current Diff
{{ pr.diff }}

{{ conflicts }}

## PR Comments
{{ pr.comments }}

## Reviewer Feedback
{{ pr.reviews }}

## Task
Address the reviewer feedback above. Make the requested changes while maintaining code quality.
If there are merge conflicts, resolve them by choosing the correct code and removing all conflict markers.
When done, output %%STORM_DONE%% on its own line.

{{ checks.failures }}
Expand All @@ -324,6 +330,12 @@ export async function processContinue(
return { success: false };
}

// Merge base branch to detect conflicts
const mergeResult = await mergeBaseBranch(pr.baseBranch, cwd);
if (mergeResult.conflicts) {
pr.conflicts = mergeResult.conflicts;
}

let checkFailures = "";
let lastSessionId: string | undefined;
const totalUsage: AgentUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
Expand Down
39 changes: 38 additions & 1 deletion src/core/pr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { join } from "path";
import { runCommand, runCommandArgs } from "../primitives/runner.js";
import { createPullRequest, commentOnIssue } from "./github.js";
import type { GitHubIssue, StormConfig, AgentUsage } from "./types.js";
import type { GitHubIssue, StormConfig, AgentUsage, ConflictInfo } from "./types.js";
import { log, formatDuration } from "./output.js";
import { CONFIG_DIR, PR_DESCRIPTION_FILE } from "./constants.js";

Expand Down Expand Up @@ -115,6 +115,43 @@ export async function checkoutExistingBranch(
return true;
}

export async function mergeBaseBranch(
baseBranch: string,
cwd: string
): Promise<{ merged: boolean; conflicts?: ConflictInfo }> {
const fetch = await runCommandArgs(["git", "fetch", "origin", baseBranch], { cwd });
if (fetch.exitCode !== 0) {
log.warn(`Failed to fetch ${baseBranch}: ${fetch.stderr}`);
return { merged: false };
}

const merge = await runCommandArgs(["git", "merge", `origin/${baseBranch}`, "--no-edit"], { cwd });
if (merge.exitCode === 0) {
log.info(`Merged origin/${baseBranch} cleanly`);
return { merged: true };
}

// Check if the failure is due to merge conflicts
const conflictFiles = await runCommandArgs(["git", "diff", "--name-only", "--diff-filter=U"], { cwd });
const conflictedFiles = conflictFiles.stdout.trim().split("\n").filter(Boolean);

if (conflictedFiles.length === 0) {
log.error(`Merge failed (not a conflict): ${merge.stderr}`);
return { merged: false };
}

const conflictDiff = await runCommand("git diff", { cwd });
log.warn(`Merge conflicts detected in ${conflictedFiles.length} file(s): ${conflictedFiles.join(", ")}`);

return {
merged: false,
conflicts: {
conflictedFiles,
conflictDetails: conflictDiff.stdout,
},
};
}

async function buildPRDescription(
issue: GitHubIssue,
branch: string,
Expand Down
Loading
Loading