From 675f5add17ac87adbf1c86c603c4c5c7962ba0eb Mon Sep 17 00:00:00 2001 From: Casper Panduro Date: Mon, 16 Mar 2026 17:32:29 +0100 Subject: [PATCH 1/3] Add conflict resolution, PR comments, and CI workflow to storm continue - Add mergeBaseBranch() to detect and surface merge conflicts to the agent - Add fetchPRCommentsAndSessionId() to fetch regular PR comments (not just inline review comments) - Add {{ conflicts }} and {{ pr.comments }} template placeholders - Update default continue template with conflict resolution instructions - Add CI workflow to run typecheck and tests on PRs - Add processContinue tests for merge and conflict detection Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 21 ++++++++++++ src/__tests__/loop.test.ts | 66 +++++++++++++++++++++++++++++++++++--- src/commands/continue.ts | 16 ++++++--- src/core/github.ts | 51 ++++++++++++++++++++++------- src/core/loop.ts | 14 +++++++- src/core/pr.ts | 39 +++++++++++++++++++++- src/core/resolver.ts | 30 ++++++++++++++++- src/core/types.ts | 13 ++++++++ 8 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..55668de --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 test diff --git a/src/__tests__/loop.test.ts b/src/__tests__/loop.test.ts index f8b169c..6355c99 100644 --- a/src/__tests__/loop.test.ts +++ b/src/__tests__/loop.test.ts @@ -5,12 +5,15 @@ 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", @@ -18,7 +21,8 @@ mock.module("../core/pr.js", () => ({ createBranch: mockCreateBranch, commitAndPush: mockCommitAndPush, openPR: mockOpenPR, - checkoutExistingBranch: mock(async () => true), + checkoutExistingBranch: mockCheckoutExistingBranch, + mergeBaseBranch: mockMergeBaseBranch, })); mock.module("../core/agent.js", () => ({ @@ -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", () => ({ @@ -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, @@ -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); + }); +}); diff --git a/src/commands/continue.ts b/src/commands/continue.ts index bc73f54..fae267d 100644 --- a/src/commands/continue.ts +++ b/src/commands/continue.ts @@ -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"; @@ -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 { @@ -91,6 +92,7 @@ export async function continueCommand( reviews, linkedIssue: issue, sessionId, + comments: prComments, }; if (dryRun) { @@ -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 }} diff --git a/src/core/github.ts b/src/core/github.ts index 648331c..1fa2e11 100644 --- a/src/core/github.ts +++ b/src/core/github.ts @@ -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; @@ -315,26 +315,55 @@ export async function fetchPRSessionId( repoStr: string, prNumber: number ): Promise { + 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 = //; - for (const comment of comments) { - const match = comment.body?.match(pattern); - if (match) return match[1]; + const sessionPattern = //; + const stormPattern = /