From b8afb6c80441733b4d5025d09de3833476611d69 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 00:14:01 -0500 Subject: [PATCH 1/9] Changes from pull-request --- apps/app/src/components/views/board-view.tsx | 12 +- .../board-view/components/kanban-card.tsx | 22 ++++ .../board-view/dialogs/create-pr-dialog.tsx | 5 +- apps/app/src/store/app-store.ts | 1 + apps/app/tests/worktree-integration.spec.ts | 123 ++++++++++++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index c83c7b8d..fcfa699f 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -1154,7 +1154,17 @@ export function BoardView() { open={showCreatePRDialog} onOpenChange={setShowCreatePRDialog} worktree={selectedWorktreeForAction} - onCreated={() => { + onCreated={(prUrl) => { + // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL + if (prUrl && selectedWorktreeForAction?.branch) { + const branchName = selectedWorktreeForAction.branch; + hookFeatures + .filter((f) => f.branchName === branchName) + .forEach((feature) => { + updateFeature(feature.id, { prUrl }); + persistFeatureUpdate(feature.id, { prUrl }); + }); + } setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 564be40b..0cb7a366 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -52,6 +52,8 @@ import { MoreVertical, AlertCircle, GitBranch, + GitPullRequest, + ExternalLink, ChevronDown, ChevronUp, Brain, @@ -697,6 +699,26 @@ export const KanbanCard = memo(function KanbanCard({ )} + {/* PR URL Display */} + {feature.prUrl && ( +
+ e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors" + title={feature.prUrl} + data-testid={`pr-url-${feature.id}`} + > + + Pull Request + + +
+ )} + {/* Steps Preview */} {showSteps && feature.steps && feature.steps.length > 0 && (
diff --git a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx index dd5dd344..37f5aaba 100644 --- a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -30,7 +30,7 @@ interface CreatePRDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; - onCreated: () => void; + onCreated: (prUrl?: string) => void; } export function CreatePRDialog({ @@ -201,7 +201,8 @@ export function CreatePRDialog({ // Only call onCreated() if an actual operation completed // This prevents unnecessary refreshes when user cancels if (operationCompletedRef.current) { - onCreated(); + // Pass the PR URL if one was created + onCreated(prUrl || undefined); } onOpenChange(false); // State reset is handled by useEffect when open becomes false diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index bec00c75..e7598cb5 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -305,6 +305,7 @@ export interface Feature { planningMode?: PlanningMode; // Planning mode for this feature planSpec?: PlanSpec; // Generated spec/plan data requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation + prUrl?: string; // Pull request URL when a PR has been created for this feature } // Parsed task from spec (for spec and full planning modes) diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts index a8a77c40..89fda282 100644 --- a/apps/app/tests/worktree-integration.spec.ts +++ b/apps/app/tests/worktree-integration.spec.ts @@ -2610,4 +2610,127 @@ test.describe("Worktree Integration Tests", () => { // worktreePath should not exist in the feature data (worktrees are created at execution time) expect(featureData.worktreePath).toBeUndefined(); }); + + // ========================================================================== + // PR URL Tracking Tests + // ========================================================================== + + test("feature should support prUrl field for tracking pull request URLs", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a feature + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature for PR URL test", { + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Verify feature was created + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature for PR URL test"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + // Manually update the feature.json file to add prUrl (simulating what happens after PR creation) + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + featureData.prUrl = "https://github.com/test/repo/pull/123"; + fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2)); + + // Reload the page to pick up the change + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + await page.waitForTimeout(1000); + + // Verify the PR URL link is displayed on the card + const prUrlLink = page.locator(`[data-testid="pr-url-${featureData.id}"]`); + await expect(prUrlLink).toBeVisible({ timeout: 5000 }); + await expect(prUrlLink).toHaveText(/Pull Request/); + await expect(prUrlLink).toHaveAttribute( + "href", + "https://github.com/test/repo/pull/123" + ); + }); + + test("prUrl should persist when updating feature", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a feature + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature with PR URL persistence", { + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Find the feature file + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature with PR URL persistence"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + // Add prUrl to the feature + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + const originalPrUrl = "https://github.com/test/repo/pull/456"; + featureData.prUrl = originalPrUrl; + fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2)); + + // Reload the page + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + await page.waitForTimeout(1000); + + // Open edit dialog by double-clicking the feature card + const featureCard = page.getByText("Feature with PR URL persistence"); + await featureCard.dblclick(); + await page.waitForTimeout(500); + + // Wait for edit dialog to open + const editDialog = page.locator('[data-testid="edit-feature-dialog"]'); + await expect(editDialog).toBeVisible({ timeout: 5000 }); + + // Update the description + const descInput = page.locator( + '[data-testid="edit-feature-description"] textarea' + ); + await descInput.fill("Feature with PR URL persistence - updated"); + + // Save the feature + const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); + await saveButton.click(); + await page.waitForTimeout(1000); + + // Verify prUrl was preserved + featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.prUrl).toBe(originalPrUrl); + expect(featureData.description).toBe( + "Feature with PR URL persistence - updated" + ); + }); }); From 6a8f5c6d9cded8ece18984908b9ea11e7e960691 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 16:51:43 -0500 Subject: [PATCH 2/9] feat: enhance Kanban card functionality with Verify button - Added logic to display a Verify button for features in the "waiting_approval" status with a PR URL, replacing the Commit button. - Updated WorktreePanel and WorktreeTab components to include properties for tracking uncommitted changes and file counts. - Implemented tooltips to indicate the number of uncommitted files in the WorktreeTab. - Added integration tests to verify the correct display of the Verify and Commit buttons based on feature status and PR URL presence. --- .../board-view/components/kanban-card.tsx | 20 ++- .../components/worktree-tab.tsx | 70 +++++++++- .../worktree-panel/worktree-panel.tsx | 4 +- apps/app/tests/worktree-integration.spec.ts | 120 ++++++++++++++++++ 4 files changed, 209 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 0cb7a366..8602b375 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -1102,7 +1102,23 @@ export const KanbanCard = memo(function KanbanCard({ Refine )} - {onCommit && ( + {/* Show Verify button if PR was created (changes are committed), otherwise show Commit button */} + {feature.prUrl && onManualVerify ? ( + + ) : onCommit ? ( - )} + ) : null} )} {!isCurrentAutoTask && feature.status === "backlog" && ( diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 4f130db7..0c7adbbe 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -1,8 +1,14 @@ "use client"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Globe, Loader2 } from "lucide-react"; +import { RefreshCw, Globe, Loader2, CircleDot } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types"; import { BranchSwitchDropdown } from "./branch-switch-dropdown"; import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; @@ -10,6 +16,8 @@ import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; interface WorktreeTabProps { worktree: WorktreeInfo; cardCount?: number; // Number of unarchived cards for this branch + hasChanges?: boolean; // Whether the worktree has uncommitted changes + changedFilesCount?: number; // Number of files with uncommitted changes isSelected: boolean; isRunning: boolean; isActivating: boolean; @@ -46,6 +54,8 @@ interface WorktreeTabProps { export function WorktreeTab({ worktree, cardCount, + hasChanges, + changedFilesCount, isSelected, isRunning, isActivating, @@ -78,8 +88,24 @@ export function WorktreeTab({ onStopDevServer, onOpenDevServerUrl, }: WorktreeTabProps) { + // Determine border color based on state: + // - Running features: cyan border (high visibility, indicates active work) + // - Uncommitted changes: amber border (warning state, needs attention) + // - Both: cyan takes priority (running is more important to see) + const getBorderClasses = () => { + if (isRunning) { + return "ring-2 ring-cyan-500 ring-offset-1 ring-offset-background"; + } + if (hasChanges) { + return "ring-2 ring-amber-500 ring-offset-1 ring-offset-background"; + } + return ""; + }; + + const borderClasses = getBorderClasses(); + return ( -
+
{worktree.isMain ? ( <> )} + {hasChanges && ( + + + + + + {changedFilesCount ?? "!"} + + + +

{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}

+
+
+
+ )} )} diff --git a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 910b8793..e81f996e 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -110,7 +110,7 @@ export function WorktreePanel({ Branch: -
+
{worktrees.map((worktree) => { const cardCount = branchCardCounts?.[worktree.branch]; return ( @@ -118,6 +118,8 @@ export function WorktreePanel({ key={worktree.path} worktree={worktree} cardCount={cardCount} + hasChanges={worktree.hasChanges} + changedFilesCount={worktree.changedFilesCount} isSelected={isWorktreeSelected(worktree)} isRunning={hasRunningFeatures(worktree)} isActivating={isActivating} diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts index 89fda282..03a6d67d 100644 --- a/apps/app/tests/worktree-integration.spec.ts +++ b/apps/app/tests/worktree-integration.spec.ts @@ -2733,4 +2733,124 @@ test.describe("Worktree Integration Tests", () => { "Feature with PR URL persistence - updated" ); }); + + test("feature in waiting_approval with prUrl should show Verify button instead of Commit", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a feature + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature with PR for verify test", { + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Find the feature file + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature with PR for verify test"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + // Update the feature to waiting_approval status with a prUrl + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + featureData.status = "waiting_approval"; + featureData.prUrl = "https://github.com/test/repo/pull/789"; + fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2)); + + // Reload the page to pick up the changes + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + await page.waitForTimeout(1000); + + // Verify the feature card is in the waiting_approval column + const waitingApprovalColumn = page.locator( + '[data-testid="kanban-column-waiting_approval"]' + ); + const featureCard = waitingApprovalColumn.locator( + `[data-testid="kanban-card-${featureData.id}"]` + ); + await expect(featureCard).toBeVisible({ timeout: 5000 }); + + // Verify the Verify button is visible (not Commit button) + const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`); + await expect(verifyButton).toBeVisible({ timeout: 5000 }); + + // Verify the Commit button is NOT visible + const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`); + await expect(commitButton).not.toBeVisible({ timeout: 2000 }); + }); + + test("feature in waiting_approval without prUrl should show Commit button", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a feature + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature without PR for commit test", { + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Find the feature file + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature without PR for commit test"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + // Update the feature to waiting_approval status WITHOUT prUrl + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + featureData.status = "waiting_approval"; + // Explicitly do NOT set prUrl + fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2)); + + // Reload the page to pick up the changes + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + await page.waitForTimeout(1000); + + // Verify the feature card is in the waiting_approval column + const waitingApprovalColumn = page.locator( + '[data-testid="kanban-column-waiting_approval"]' + ); + const featureCard = waitingApprovalColumn.locator( + `[data-testid="kanban-card-${featureData.id}"]` + ); + await expect(featureCard).toBeVisible({ timeout: 5000 }); + + // Verify the Commit button is visible + const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`); + await expect(commitButton).toBeVisible({ timeout: 5000 }); + + // Verify the Verify button is NOT visible + const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`); + await expect(verifyButton).not.toBeVisible({ timeout: 2000 }); + }); }); From d4365de4b9bd8deb446b23d4e8a52fc0239ebafe Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 19:48:14 -0500 Subject: [PATCH 3/9] feat: enhance PR handling and UI integration for worktrees - Added a new route for fetching PR info, allowing users to retrieve details about existing pull requests associated with worktrees. - Updated the create PR handler to store metadata for existing PRs and handle cases where a PR already exists. - Enhanced the UI components to display PR information, including a new button to address PR comments directly from the worktree panel. - Improved the overall user experience by integrating PR state indicators and ensuring seamless interaction with the GitHub CLI for PR management. --- apps/server/src/lib/worktree-metadata.ts | 154 +++++++++ apps/server/src/routes/worktree/index.ts | 2 + .../src/routes/worktree/routes/create-pr.ts | 185 ++++++++--- .../server/src/routes/worktree/routes/list.ts | 13 + .../src/routes/worktree/routes/pr-info.ts | 296 ++++++++++++++++++ apps/ui/src/components/views/board-view.tsx | 96 ++++++ .../board-view/dialogs/create-pr-dialog.tsx | 33 +- .../components/worktree-actions-dropdown.tsx | 43 ++- .../components/worktree-tab.tsx | 105 +++++++ .../views/board-view/worktree-panel/types.ts | 35 +++ .../worktree-panel/worktree-panel.tsx | 2 + apps/ui/src/lib/electron.ts | 11 + apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/types/electron.d.ts | 48 +++ 14 files changed, 980 insertions(+), 45 deletions(-) create mode 100644 apps/server/src/lib/worktree-metadata.ts create mode 100644 apps/server/src/routes/worktree/routes/pr-info.ts diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts new file mode 100644 index 00000000..b8796fe4 --- /dev/null +++ b/apps/server/src/lib/worktree-metadata.ts @@ -0,0 +1,154 @@ +/** + * Worktree metadata storage utilities + * Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json + */ + +import * as fs from "fs/promises"; +import * as path from "path"; + +export interface WorktreePRInfo { + number: number; + url: string; + title: string; + state: string; + createdAt: string; +} + +export interface WorktreeMetadata { + branch: string; + createdAt: string; + pr?: WorktreePRInfo; +} + +/** + * Get the path to the worktree metadata directory + */ +function getWorktreeMetadataDir(projectPath: string, branch: string): string { + // Sanitize branch name for filesystem (replace / with -) + const safeBranch = branch.replace(/\//g, "-"); + return path.join(projectPath, ".automaker", "worktrees", safeBranch); +} + +/** + * Get the path to the worktree metadata file + */ +function getWorktreeMetadataPath(projectPath: string, branch: string): string { + return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json"); +} + +/** + * Read worktree metadata for a branch + */ +export async function readWorktreeMetadata( + projectPath: string, + branch: string +): Promise { + try { + const metadataPath = getWorktreeMetadataPath(projectPath, branch); + const content = await fs.readFile(metadataPath, "utf-8"); + return JSON.parse(content) as WorktreeMetadata; + } catch (error) { + // File doesn't exist or can't be read + return null; + } +} + +/** + * Write worktree metadata for a branch + */ +export async function writeWorktreeMetadata( + projectPath: string, + branch: string, + metadata: WorktreeMetadata +): Promise { + const metadataDir = getWorktreeMetadataDir(projectPath, branch); + const metadataPath = getWorktreeMetadataPath(projectPath, branch); + + // Ensure directory exists + await fs.mkdir(metadataDir, { recursive: true }); + + // Write metadata + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); +} + +/** + * Update PR info in worktree metadata + */ +export async function updateWorktreePRInfo( + projectPath: string, + branch: string, + prInfo: WorktreePRInfo +): Promise { + // Read existing metadata or create new + let metadata = await readWorktreeMetadata(projectPath, branch); + + if (!metadata) { + metadata = { + branch, + createdAt: new Date().toISOString(), + }; + } + + // Update PR info + metadata.pr = prInfo; + + // Write back + await writeWorktreeMetadata(projectPath, branch, metadata); +} + +/** + * Get PR info for a branch from metadata + */ +export async function getWorktreePRInfo( + projectPath: string, + branch: string +): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.pr || null; +} + +/** + * Read all worktree metadata for a project + */ +export async function readAllWorktreeMetadata( + projectPath: string +): Promise> { + const result = new Map(); + const worktreesDir = path.join(projectPath, ".automaker", "worktrees"); + + try { + const dirs = await fs.readdir(worktreesDir, { withFileTypes: true }); + + for (const dir of dirs) { + if (dir.isDirectory()) { + const metadataPath = path.join(worktreesDir, dir.name, "worktree.json"); + try { + const content = await fs.readFile(metadataPath, "utf-8"); + const metadata = JSON.parse(content) as WorktreeMetadata; + result.set(metadata.branch, metadata); + } catch { + // Skip if file doesn't exist or can't be read + } + } + } + } catch { + // Directory doesn't exist + } + + return result; +} + +/** + * Delete worktree metadata for a branch + */ +export async function deleteWorktreeMetadata( + projectPath: string, + branch: string +): Promise { + const metadataDir = getWorktreeMetadataDir(projectPath, branch); + try { + await fs.rm(metadataDir, { recursive: true, force: true }); + } catch { + // Ignore errors if directory doesn't exist + } +} diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index b6c182c8..304d0678 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js"; import { createCreateHandler } from "./routes/create.js"; import { createDeleteHandler } from "./routes/delete.js"; import { createCreatePRHandler } from "./routes/create-pr.js"; +import { createPRInfoHandler } from "./routes/pr-info.js"; import { createCommitHandler } from "./routes/commit.js"; import { createPushHandler } from "./routes/push.js"; import { createPullHandler } from "./routes/pull.js"; @@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router { router.post("/create", createCreateHandler()); router.post("/delete", createDeleteHandler()); router.post("/create-pr", createCreatePRHandler()); + router.post("/pr-info", createPRInfoHandler()); router.post("/commit", createCommitHandler()); router.post("/push", createPushHandler()); router.post("/pull", createPullHandler()); diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index 3a956b85..d55ef0c3 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -6,6 +6,7 @@ import type { Request, Response } from "express"; import { exec } from "child_process"; import { promisify } from "util"; import { getErrorMessage, logError } from "../common.js"; +import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; const execAsync = promisify(exec); @@ -48,8 +49,9 @@ const execEnv = { export function createCreatePRHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as { + const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as { worktreePath: string; + projectPath?: string; commitMessage?: string; prTitle?: string; prBody?: string; @@ -65,6 +67,10 @@ export function createCreatePRHandler() { return; } + // Use projectPath if provided, otherwise derive from worktreePath + // For worktrees, projectPath is needed to store metadata in the main project's .automaker folder + const effectiveProjectPath = projectPath || worktreePath; + // Get current branch name const { stdout: branchOutput } = await execAsync( "git rev-parse --abbrev-ref HEAD", @@ -143,18 +149,8 @@ export function createCreatePRHandler() { let browserUrl: string | null = null; let ghCliAvailable = false; - // Check if gh CLI is available (cross-platform) - try { - const checkCommand = process.platform === "win32" - ? "where gh" - : "command -v gh"; - await execAsync(checkCommand, { env: execEnv }); - ghCliAvailable = true; - } catch { - ghCliAvailable = false; - } - - // Get repository URL for browser fallback + // Get repository URL and detect fork workflow FIRST + // This is needed for both the existing PR check and PR creation let repoUrl: string | null = null; let upstreamRepo: string | null = null; let originOwner: string | null = null; @@ -180,7 +176,7 @@ export function createCreatePRHandler() { // Try HTTPS format: https://github.com/owner/repo.git match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); } - + if (match) { const [, remoteName, owner, repo] = match; if (remoteName === "upstream") { @@ -206,7 +202,7 @@ export function createCreatePRHandler() { env: execEnv, }); const url = originUrl.trim(); - + // Parse URL to extract owner/repo // Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); @@ -220,6 +216,17 @@ export function createCreatePRHandler() { } } + // Check if gh CLI is available (cross-platform) + try { + const checkCommand = process.platform === "win32" + ? "where gh" + : "command -v gh"; + await execAsync(checkCommand, { env: execEnv }); + ghCliAvailable = true; + } catch { + ghCliAvailable = false; + } + // Construct browser URL for PR creation if (repoUrl) { const encodedTitle = encodeURIComponent(title); @@ -234,32 +241,136 @@ export function createCreatePRHandler() { } } + let prNumber: number | undefined; + let prAlreadyExisted = false; + if (ghCliAvailable) { + // First, check if a PR already exists for this branch using gh pr list + // This is more reliable than gh pr view as it explicitly searches by branch name + // For forks, we need to use owner:branch format for the head parameter + const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; + const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : ""; + + console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); try { - // Build gh pr create command - let prCmd = `gh pr create --base "${base}"`; - - // If this is a fork (has upstream remote), specify the repo and head - if (upstreamRepo && originOwner) { - // For forks: --repo specifies where to create PR, --head specifies source - prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; - } else { - // Not a fork, just specify the head branch - prCmd += ` --head "${branchName}"`; - } - - prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; - prCmd = prCmd.trim(); - - const { stdout: prOutput } = await execAsync(prCmd, { + const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`; + console.log(`[CreatePR] Running: ${listCmd}`); + const { stdout: existingPrOutput } = await execAsync(listCmd, { cwd: worktreePath, env: execEnv, }); - prUrl = prOutput.trim(); - } catch (ghError: unknown) { - // gh CLI failed - const err = ghError as { stderr?: string; message?: string }; - prError = err.stderr || err.message || "PR creation failed"; + console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`); + + const existingPrs = JSON.parse(existingPrOutput); + + if (Array.isArray(existingPrs) && existingPrs.length > 0) { + const existingPr = existingPrs[0]; + // PR already exists - use it and store metadata + console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`); + prUrl = existingPr.url; + prNumber = existingPr.number; + prAlreadyExisted = true; + + // Store the existing PR info in metadata + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: existingPr.number, + url: existingPr.url, + title: existingPr.title || title, + state: existingPr.state || "open", + createdAt: new Date().toISOString(), + }); + console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`); + } else { + console.log(`[CreatePR] No existing PR found for branch ${branchName}`); + } + } catch (listError) { + // gh pr list failed - log but continue to try creating + console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError); + } + + // Only create a new PR if one doesn't already exist + if (!prUrl) { + try { + // Build gh pr create command + let prCmd = `gh pr create --base "${base}"`; + + // If this is a fork (has upstream remote), specify the repo and head + if (upstreamRepo && originOwner) { + // For forks: --repo specifies where to create PR, --head specifies source + prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; + } else { + // Not a fork, just specify the head branch + prCmd += ` --head "${branchName}"`; + } + + prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; + prCmd = prCmd.trim(); + + console.log(`[CreatePR] Creating PR with command: ${prCmd}`); + const { stdout: prOutput } = await execAsync(prCmd, { + cwd: worktreePath, + env: execEnv, + }); + prUrl = prOutput.trim(); + console.log(`[CreatePR] PR created: ${prUrl}`); + + // Extract PR number and store metadata for newly created PR + if (prUrl) { + const prMatch = prUrl.match(/\/pull\/(\d+)/); + prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined; + + if (prNumber) { + try { + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: prNumber, + url: prUrl, + title, + state: draft ? "draft" : "open", + createdAt: new Date().toISOString(), + }); + console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`); + } catch (metadataError) { + console.error("[CreatePR] Failed to store PR metadata:", metadataError); + } + } + } + } catch (ghError: unknown) { + // gh CLI failed - check if it's "already exists" error and try to fetch the PR + const err = ghError as { stderr?: string; message?: string }; + const errorMessage = err.stderr || err.message || "PR creation failed"; + console.log(`[CreatePR] gh pr create failed: ${errorMessage}`); + + // If error indicates PR already exists, try to fetch it + if (errorMessage.toLowerCase().includes("already exists")) { + console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`); + try { + const { stdout: viewOutput } = await execAsync( + `gh pr view --json number,title,url,state`, + { cwd: worktreePath, env: execEnv } + ); + const existingPr = JSON.parse(viewOutput); + if (existingPr.url) { + prUrl = existingPr.url; + prNumber = existingPr.number; + prAlreadyExisted = true; + + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: existingPr.number, + url: existingPr.url, + title: existingPr.title || title, + state: existingPr.state || "open", + createdAt: new Date().toISOString(), + }); + console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`); + } + } catch (viewError) { + console.error("[CreatePR] Failed to fetch existing PR:", viewError); + prError = errorMessage; + } + } else { + prError = errorMessage; + } + } } } else { prError = "gh_cli_not_available"; @@ -274,7 +385,9 @@ export function createCreatePRHandler() { commitHash, pushed: true, prUrl, + prNumber, prCreated: !!prUrl, + prAlreadyExisted, prError: prError || undefined, browserUrl: browserUrl || undefined, ghCliAvailable, diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index ef749e9c..5572fea4 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -10,6 +10,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import { existsSync } from "fs"; import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; +import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js"; const execAsync = promisify(exec); @@ -21,6 +22,7 @@ interface WorktreeInfo { hasWorktree: boolean; // Always true for items in this list hasChanges?: boolean; changedFilesCount?: number; + pr?: WorktreePRInfo; // PR info if a PR has been created for this branch } async function getCurrentBranch(cwd: string): Promise { @@ -106,6 +108,9 @@ export function createListHandler() { } } + // Read all worktree metadata to get PR info + const allMetadata = await readAllWorktreeMetadata(projectPath); + // If includeDetails is requested, fetch change status for each worktree if (includeDetails) { for (const worktree of worktrees) { @@ -127,6 +132,14 @@ export function createListHandler() { } } + // Add PR info from metadata for each worktree + for (const worktree of worktrees) { + const metadata = allMetadata.get(worktree.branch); + if (metadata?.pr) { + worktree.pr = metadata.pr; + } + } + res.json({ success: true, worktrees, diff --git a/apps/server/src/routes/worktree/routes/pr-info.ts b/apps/server/src/routes/worktree/routes/pr-info.ts new file mode 100644 index 00000000..aa270466 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/pr-info.ts @@ -0,0 +1,296 @@ +/** + * POST /pr-info endpoint - Get PR info and comments for a branch + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +// Extended PATH to include common tool installation locations +const pathSeparator = process.platform === "win32" ? ";" : ":"; +const additionalPaths: string[] = []; + +if (process.platform === "win32") { + if (process.env.LOCALAPPDATA) { + additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + } + if (process.env.PROGRAMFILES) { + additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + } + if (process.env["ProgramFiles(x86)"]) { + additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`); + } +} else { + additionalPaths.push( + "/opt/homebrew/bin", + "/usr/local/bin", + "/home/linuxbrew/.linuxbrew/bin", + `${process.env.HOME}/.local/bin`, + ); +} + +const extendedPath = [ + process.env.PATH, + ...additionalPaths.filter(Boolean), +].filter(Boolean).join(pathSeparator); + +const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +export interface PRComment { + id: number; + author: string; + body: string; + path?: string; + line?: number; + createdAt: string; + isReviewComment: boolean; +} + +export interface PRInfo { + number: number; + title: string; + url: string; + state: string; + author: string; + body: string; + comments: PRComment[]; + reviewComments: PRComment[]; +} + +export function createPRInfoHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, branchName } = req.body as { + worktreePath: string; + branchName: string; + }; + + if (!worktreePath || !branchName) { + res.status(400).json({ + success: false, + error: "worktreePath and branchName required", + }); + return; + } + + // Check if gh CLI is available + let ghCliAvailable = false; + try { + const checkCommand = process.platform === "win32" + ? "where gh" + : "command -v gh"; + await execAsync(checkCommand, { env: execEnv }); + ghCliAvailable = true; + } catch { + ghCliAvailable = false; + } + + if (!ghCliAvailable) { + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: false, + error: "gh CLI not available", + }, + }); + return; + } + + // Detect repository information (supports fork workflows) + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + let originRepo: string | null = null; + + try { + const { stdout: remotes } = await execAsync("git remote -v", { + cwd: worktreePath, + env: execEnv, + }); + + const lines = remotes.split(/\r?\n/); + for (const line of lines) { + let match = + line.match( + /^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/ + ) || + line.match( + /^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ) || + line.match( + /^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + + if (match) { + const [, remoteName, owner, repo] = match; + if (remoteName === "upstream") { + upstreamRepo = `${owner}/${repo}`; + } else if (remoteName === "origin") { + originOwner = owner; + originRepo = repo; + } + } + } + } catch { + // Ignore remote parsing errors + } + + if (!originOwner || !originRepo) { + try { + const { stdout: originUrl } = await execAsync( + "git config --get remote.origin.url", + { + cwd: worktreePath, + env: execEnv, + } + ); + const match = originUrl + .trim() + .match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); + if (match) { + if (!originOwner) { + originOwner = match[1]; + } + if (!originRepo) { + originRepo = match[2]; + } + } + } catch { + // Ignore fallback errors + } + } + + const targetRepo = + upstreamRepo || (originOwner && originRepo + ? `${originOwner}/${originRepo}` + : null); + const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : ""; + const headRef = + upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; + + // Get PR info for the branch using gh CLI + try { + // First, find the PR associated with this branch + const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`; + const { stdout: prListOutput } = await execAsync( + listCmd, + { cwd: worktreePath, env: execEnv } + ); + + const prList = JSON.parse(prListOutput); + + if (prList.length === 0) { + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: true, + }, + }); + return; + } + + const pr = prList[0]; + const prNumber = pr.number; + + // Get regular PR comments (issue comments) + let comments: PRComment[] = []; + try { + const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`; + const { stdout: commentsOutput } = await execAsync( + viewCmd, + { cwd: worktreePath, env: execEnv } + ); + const commentsData = JSON.parse(commentsOutput); + comments = (commentsData.comments || []).map((c: { + id: number; + author: { login: string }; + body: string; + createdAt: string; + }) => ({ + id: c.id, + author: c.author?.login || "unknown", + body: c.body, + createdAt: c.createdAt, + isReviewComment: false, + })); + } catch (error) { + console.warn("[PRInfo] Failed to fetch PR comments:", error); + } + + // Get review comments (inline code comments) + let reviewComments: PRComment[] = []; + try { + const reviewsEndpoint = targetRepo + ? `repos/${targetRepo}/pulls/${prNumber}/comments` + : `repos/{owner}/{repo}/pulls/${prNumber}/comments`; + const reviewsCmd = `gh api ${reviewsEndpoint}`; + const { stdout: reviewsOutput } = await execAsync( + reviewsCmd, + { cwd: worktreePath, env: execEnv } + ); + const reviewsData = JSON.parse(reviewsOutput); + reviewComments = reviewsData.map((c: { + id: number; + user: { login: string }; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + }) => ({ + id: c.id, + author: c.user?.login || "unknown", + body: c.body, + path: c.path, + line: c.line || c.original_line, + createdAt: c.created_at, + isReviewComment: true, + })); + } catch (error) { + console.warn("[PRInfo] Failed to fetch review comments:", error); + } + + const prInfo: PRInfo = { + number: prNumber, + title: pr.title, + url: pr.url, + state: pr.state, + author: pr.author?.login || "unknown", + body: pr.body || "", + comments, + reviewComments, + }; + + res.json({ + success: true, + result: { + hasPR: true, + ghCliAvailable: true, + prInfo, + }, + }); + } catch (error) { + // gh CLI failed - might not be authenticated or no remote + logError(error, "Failed to get PR info"); + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: true, + error: getErrorMessage(error), + }, + }); + } + } catch (error) { + logError(error, "PR info handler failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 5ab88811..527d826a 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -39,6 +39,7 @@ import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialo import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog"; import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog"; import { WorktreePanel } from "./board-view/worktree-panel"; +import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types"; import { COLUMNS } from "./board-view/constants"; import { useBoardFeatures, @@ -415,6 +416,95 @@ export function BoardView() { currentWorktreeBranch, }); + // Handler for addressing PR comments - creates a feature and starts it automatically + const handleAddressPRComments = useCallback( + async (worktree: WorktreeInfo, prInfo: PRInfo) => { + // If comments are empty, fetch them from GitHub + let fullPRInfo = prInfo; + if (prInfo.comments.length === 0 && prInfo.reviewComments.length === 0) { + try { + const api = getElectronAPI(); + if (api?.worktree?.getPRInfo) { + const result = await api.worktree.getPRInfo( + worktree.path, + worktree.branch + ); + if (result.success && result.result?.hasPR && result.result.prInfo) { + fullPRInfo = result.result.prInfo; + } + } + } catch (error) { + console.error("[Board] Failed to fetch PR comments:", error); + } + } + + // Format PR comments into a feature description + const allComments = [ + ...fullPRInfo.comments.map((c) => ({ + ...c, + type: "comment" as const, + })), + ...fullPRInfo.reviewComments.map((c) => ({ + ...c, + type: "review" as const, + })), + ].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + let description = `Address PR #${fullPRInfo.number} feedback: "${fullPRInfo.title}"\n\n`; + description += `PR URL: ${fullPRInfo.url}\n\n`; + + if (allComments.length === 0) { + description += `No comments found on this PR yet. Check the PR for any new feedback.\n`; + } else { + description += `## Feedback to address:\n\n`; + for (const comment of allComments) { + if (comment.type === "review" && comment.path) { + description += `### ${comment.path}${comment.line ? `:${comment.line}` : ""}\n`; + } + description += `**@${comment.author}:**\n${comment.body}\n\n`; + } + } + + // Create the feature + const featureData = { + category: "PR Review", + description: description.trim(), + steps: [], + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: "sonnet" as const, + thinkingLevel: "medium" as const, + branchName: worktree.branch, + priority: 1, // High priority for PR feedback + planningMode: "skip" as const, + requirePlanApproval: false, + }; + + await handleAddFeature(featureData); + + // Find the newly created feature and start it + // We need to wait a moment for the feature to be created + setTimeout(async () => { + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find( + (f) => + f.branchName === worktree.branch && + f.status === "backlog" && + f.description.includes(`PR #${fullPRInfo.number}`) + ); + + if (newFeature) { + await handleStartImplementation(newFeature); + } + }, 500); + }, + [handleAddFeature, handleStartImplementation, defaultSkipTests] + ); + // Client-side auto mode: periodically check for backlog items and move them to in-progress // Use a ref to track the latest auto mode state so async operations always check the current value const autoModeRunningRef = useRef(autoMode.isRunning); @@ -874,6 +964,7 @@ export function BoardView() { setSelectedWorktreeForAction(worktree); setShowCreateBranchDialog(true); }} + onAddressPRComments={handleAddressPRComments} onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} branchCardCounts={branchCardCounts} @@ -1153,6 +1244,7 @@ export function BoardView() { open={showCreatePRDialog} onOpenChange={setShowCreatePRDialog} worktree={selectedWorktreeForAction} +<<<<<<< Updated upstream onCreated={(prUrl) => { // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL if (prUrl && selectedWorktreeForAction?.branch) { @@ -1164,6 +1256,10 @@ export function BoardView() { persistFeatureUpdate(feature.id, { prUrl }); }); } +======= + projectPath={currentProject?.path || null} + onCreated={() => { +>>>>>>> Stashed changes setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 68f3e6ce..17d66374 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -29,13 +29,19 @@ interface CreatePRDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; +<<<<<<< Updated upstream onCreated: (prUrl?: string) => void; +======= + projectPath: string | null; + onCreated: () => void; +>>>>>>> Stashed changes } export function CreatePRDialog({ open, onOpenChange, worktree, + projectPath, onCreated, }: CreatePRDialogProps) { const [title, setTitle] = useState(""); @@ -96,6 +102,7 @@ export function CreatePRDialog({ return; } const result = await api.worktree.createPR(worktree.path, { + projectPath: projectPath || undefined, commitMessage: commitMessage || undefined, prTitle: title || worktree.branch, prBody: body || `Changes from branch ${worktree.branch}`, @@ -108,13 +115,25 @@ export function CreatePRDialog({ setPrUrl(result.result.prUrl); // Mark operation as completed for refresh on close operationCompletedRef.current = true; - toast.success("Pull request created!", { - description: `PR created from ${result.result.branch}`, - action: { - label: "View PR", - onClick: () => window.open(result.result!.prUrl!, "_blank"), - }, - }); + + // Show different message based on whether PR already existed + if (result.result.prAlreadyExisted) { + toast.success("Pull request found!", { + description: `PR already exists for ${result.result.branch}`, + action: { + label: "View PR", + onClick: () => window.open(result.result!.prUrl!, "_blank"), + }, + }); + } else { + toast.success("Pull request created!", { + description: `PR created from ${result.result.branch}`, + action: { + label: "View PR", + onClick: () => window.open(result.result!.prUrl!, "_blank"), + }, + }); + } // Don't call onCreated() here - keep dialog open to show success message // onCreated() will be called when user closes the dialog } else { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 3eae07ef..45cf15e7 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -19,9 +19,10 @@ import { Play, Square, Globe, + MessageSquare, } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { WorktreeInfo, DevServerInfo } from "../types"; +import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types"; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; @@ -40,6 +41,7 @@ interface WorktreeActionsDropdownProps { onOpenInEditor: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; + onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; @@ -63,11 +65,15 @@ export function WorktreeActionsDropdown({ onOpenInEditor, onCommit, onCreatePR, + onAddressPRComments, onDeleteWorktree, onStartDevServer, onStopDevServer, onOpenDevServerUrl, }: WorktreeActionsDropdownProps) { + // Check if there's a PR associated with this worktree from stored metadata + const hasPR = !!worktree.pr; + return ( @@ -170,12 +176,45 @@ export function WorktreeActionsDropdown({ )} {/* Show PR option for non-primary worktrees, or primary worktree with changes */} - {(!worktree.isMain || worktree.hasChanges) && ( + {(!worktree.isMain || worktree.hasChanges) && !hasPR && ( onCreatePR(worktree)} className="text-xs"> Create Pull Request )} + {/* Show PR info and Address Comments button if PR exists */} + {!worktree.isMain && hasPR && worktree.pr && ( + <> + + + PR #{worktree.pr.number} + + {worktree.pr.state} + + + { + // Convert stored PR info to the full PRInfo format for the handler + // The handler will fetch full comments from GitHub + const prInfo: PRInfo = { + number: worktree.pr!.number, + title: worktree.pr!.title, + url: worktree.pr!.url, + state: worktree.pr!.state, + author: "", // Will be fetched + body: "", // Will be fetched + comments: [], + reviewComments: [], + }; + onAddressPRComments(worktree, prInfo); + }} + className="text-xs text-blue-500 focus:text-blue-600" + > + + Address PR Comments + + + )} {!worktree.isMain && ( <> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 6454e4dd..492122ab 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; +<<<<<<< Updated upstream import { RefreshCw, Globe, Loader2, CircleDot } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -9,6 +10,11 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types"; +======= +import { RefreshCw, Globe, Loader2, GitPullRequest } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types"; +>>>>>>> Stashed changes import { BranchSwitchDropdown } from "./branch-switch-dropdown"; import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; @@ -44,6 +50,7 @@ interface WorktreeTabProps { onOpenInEditor: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; + onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; @@ -82,11 +89,13 @@ export function WorktreeTab({ onOpenInEditor, onCommit, onCreatePR, + onAddressPRComments, onDeleteWorktree, onStartDevServer, onStopDevServer, onOpenDevServerUrl, }: WorktreeTabProps) { +<<<<<<< Updated upstream // Determine border color based on state: // - Running features: cyan border (high visibility, indicates active work) // - Uncommitted changes: amber border (warning state, needs attention) @@ -102,6 +111,93 @@ export function WorktreeTab({ }; const borderClasses = getBorderClasses(); +======= + let prBadge: JSX.Element | null = null; + if (worktree.pr) { + const prState = worktree.pr.state?.toLowerCase() ?? "open"; + const prStateClasses = (() => { + // When selected (active tab), use high contrast solid background (paper-like) + if (isSelected) { + return "bg-background text-foreground border-transparent shadow-sm"; + } + + // When not selected, use the colored variants + switch (prState) { + case "open": + case "reopened": + return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25"; + case "draft": + return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25"; + case "merged": + return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25"; + case "closed": + return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25"; + default: + return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80"; + } + })(); + + const prTitle = worktree.pr.title || `Pull Request #${worktree.pr.number}`; + const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`; + + // Helper to get status icon color for the selected state + const getStatusColorClass = () => { + if (!isSelected) return ""; + switch (prState) { + case "open": + case "reopened": + return "text-emerald-600 dark:text-emerald-500"; + case "draft": + return "text-amber-600 dark:text-amber-500"; + case "merged": + return "text-purple-600 dark:text-purple-500"; + case "closed": + return "text-rose-600 dark:text-rose-500"; + default: + return "text-muted-foreground"; + } + }; + + prBadge = ( + + ); + } +>>>>>>> Stashed changes return (
@@ -129,6 +225,7 @@ export function WorktreeTab({ {cardCount} )} +<<<<<<< Updated upstream {hasChanges && ( @@ -149,6 +246,9 @@ export function WorktreeTab({ )} +======= + {prBadge} +>>>>>>> Stashed changes )} +<<<<<<< Updated upstream {hasChanges && ( @@ -212,6 +313,9 @@ export function WorktreeTab({ )} +======= + {prBadge} +>>>>>>> Stashed changes )} @@ -249,6 +353,7 @@ export function WorktreeTab({ onOpenInEditor={onOpenInEditor} onCommit={onCommit} onCreatePR={onCreatePR} + onAddressPRComments={onAddressPRComments} onDeleteWorktree={onDeleteWorktree} onStartDevServer={onStartDevServer} onStopDevServer={onStopDevServer} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index c1beaf5f..9829916f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -1,3 +1,11 @@ +export interface WorktreePRInfo { + number: number; + url: string; + title: string; + state: string; + createdAt: string; +} + export interface WorktreeInfo { path: string; branch: string; @@ -6,6 +14,7 @@ export interface WorktreeInfo { hasWorktree: boolean; hasChanges?: boolean; changedFilesCount?: number; + pr?: WorktreePRInfo; } export interface BranchInfo { @@ -25,6 +34,31 @@ export interface FeatureInfo { branchName?: string; } +export interface PRInfo { + number: number; + title: string; + url: string; + state: string; + author: string; + body: string; + comments: Array<{ + id: number; + author: string; + body: string; + createdAt: string; + isReviewComment: boolean; + }>; + reviewComments: Array<{ + id: number; + author: string; + body: string; + path?: string; + line?: number; + createdAt: string; + isReviewComment: boolean; + }>; +} + export interface WorktreePanelProps { projectPath: string; onCreateWorktree: () => void; @@ -32,6 +66,7 @@ export interface WorktreePanelProps { onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void; + onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; runningFeatureIds?: string[]; features?: FeatureInfo[]; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 96dfe2ca..a941f696 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -20,6 +20,7 @@ export function WorktreePanel({ onCommit, onCreatePR, onCreateBranch, + onAddressPRComments, onRemovedWorktrees, runningFeatureIds = [], features = [], @@ -146,6 +147,7 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onCommit={onCommit} onCreatePR={onCreatePR} + onAddressPRComments={onAddressPRComments} onDeleteWorktree={onDeleteWorktree} onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 2bdc67e0..4da00182 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1353,6 +1353,17 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + + getPRInfo: async (worktreePath: string, branchName: string) => { + console.log("[Mock] Getting PR info:", { worktreePath, branchName }); + return { + success: true, + result: { + hasPR: false, + ghCliAvailable: false, + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d5afe304..d881bf9f 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -672,6 +672,8 @@ export class HttpApiClient implements ElectronAPI { stopDevServer: (worktreePath: string) => this.post("/api/worktree/stop-dev", { worktreePath }), listDevServers: () => this.post("/api/worktree/list-dev-servers", {}), + getPRInfo: (worktreePath: string, branchName: string) => + this.post("/api/worktree/pr-info", { worktreePath, branchName }), }; // Git API diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 244b4c23..a92c6a76 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -667,6 +667,13 @@ export interface WorktreeAPI { hasWorktree: boolean; // Does this branch have an active worktree? hasChanges?: boolean; changedFilesCount?: number; + pr?: { + number: number; + url: string; + title: string; + state: string; + createdAt: string; + }; }>; removedWorktrees?: Array<{ path: string; @@ -737,6 +744,7 @@ export interface WorktreeAPI { createPR: ( worktreePath: string, options?: { + projectPath?: string; commitMessage?: string; prTitle?: string; prBody?: string; @@ -751,7 +759,9 @@ export interface WorktreeAPI { commitHash?: string; pushed: boolean; prUrl?: string; + prNumber?: number; prCreated: boolean; + prAlreadyExisted?: boolean; prError?: string; browserUrl?: string; ghCliAvailable?: boolean; @@ -894,6 +904,44 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Get PR info and comments for a branch + getPRInfo: ( + worktreePath: string, + branchName: string + ) => Promise<{ + success: boolean; + result?: { + hasPR: boolean; + ghCliAvailable: boolean; + prInfo?: { + number: number; + title: string; + url: string; + state: string; + author: string; + body: string; + comments: Array<{ + id: number; + author: string; + body: string; + createdAt: string; + isReviewComment: boolean; + }>; + reviewComments: Array<{ + id: number; + author: string; + body: string; + path?: string; + line?: number; + createdAt: string; + isReviewComment: boolean; + }>; + }; + error?: string; + }; + error?: string; + }>; } export interface GitAPI { From 6c25680115712cedde7fe49f903b16559850fb94 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 20:07:50 -0500 Subject: [PATCH 4/9] Changes from pull-request --- apps/ui/src/components/views/board-view.tsx | 32 +++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 527d826a..add0693c 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1,4 +1,3 @@ - import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { PointerSensor, @@ -272,13 +271,16 @@ export function BoardView() { // Calculate unarchived card counts per branch const branchCardCounts = useMemo(() => { - return hookFeatures.reduce((counts, feature) => { - if (feature.status !== "completed") { - const branch = feature.branchName ?? "main"; - counts[branch] = (counts[branch] || 0) + 1; - } - return counts; - }, {} as Record); + return hookFeatures.reduce( + (counts, feature) => { + if (feature.status !== "completed") { + const branch = feature.branchName ?? "main"; + counts[branch] = (counts[branch] || 0) + 1; + } + return counts; + }, + {} as Record + ); }, [hookFeatures]); // Custom collision detection that prioritizes columns over cards @@ -341,7 +343,7 @@ export function BoardView() { const worktrees = useMemo( () => currentProject - ? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES + ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES, [currentProject, worktreesByProject] ); @@ -429,7 +431,11 @@ export function BoardView() { worktree.path, worktree.branch ); - if (result.success && result.result?.hasPR && result.result.prInfo) { + if ( + result.success && + result.result?.hasPR && + result.result.prInfo + ) { fullPRInfo = result.result.prInfo; } } @@ -1244,7 +1250,7 @@ export function BoardView() { open={showCreatePRDialog} onOpenChange={setShowCreatePRDialog} worktree={selectedWorktreeForAction} -<<<<<<< Updated upstream + projectPath={currentProject?.path || null} onCreated={(prUrl) => { // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL if (prUrl && selectedWorktreeForAction?.branch) { @@ -1256,10 +1262,6 @@ export function BoardView() { persistFeatureUpdate(feature.id, { prUrl }); }); } -======= - projectPath={currentProject?.path || null} - onCreated={() => { ->>>>>>> Stashed changes setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} From ec7c2892c2113d27ae55e9c922975da5f5013c2b Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 20:39:38 -0500 Subject: [PATCH 5/9] fix: address PR #173 security and code quality feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Enhanced branch name sanitization for cross-platform filesystem safety (handles Windows-invalid chars, reserved names, path length limits) - Added branch name validation in pr-info.ts to prevent command injection - Sanitized prUrl in kanban-card to only allow http/https URLs Code quality improvements: - Fixed placeholder issue where {owner}/{repo} was passed literally to gh api - Replaced async forEach with Promise.all for proper async handling - Display PR number extracted from URL in kanban cards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/lib/worktree-metadata.ts | 30 ++++++- .../src/routes/worktree/routes/create-pr.ts | 27 ++++++ .../src/routes/worktree/routes/pr-info.ts | 79 +++++++++++------- apps/ui/src/components/views/board-view.tsx | 83 +++++-------------- .../board-view/components/kanban-card.tsx | 42 ++++++---- .../board-view/dialogs/create-pr-dialog.tsx | 6 +- .../components/worktree-tab.tsx | 18 +--- 7 files changed, 153 insertions(+), 132 deletions(-) diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index b8796fe4..199f61c9 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -20,12 +20,38 @@ export interface WorktreeMetadata { pr?: WorktreePRInfo; } +/** + * Sanitize branch name for cross-platform filesystem safety + */ +function sanitizeBranchName(branch: string): string { + // Replace characters that are invalid or problematic on various filesystems: + // - Forward and backslashes (path separators) + // - Windows invalid chars: : * ? " < > | + // - Other potentially problematic chars + let safeBranch = branch + .replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash + .replace(/\s+/g, "_") // Replace spaces with underscores + .replace(/\.+$/g, "") // Remove trailing dots (Windows issue) + .replace(/-+/g, "-") // Collapse multiple dashes + .replace(/^-|-$/g, ""); // Remove leading/trailing dashes + + // Truncate to safe length (leave room for path components) + safeBranch = safeBranch.substring(0, 200); + + // Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) + const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; + if (windowsReserved.test(safeBranch) || safeBranch.length === 0) { + safeBranch = `_${safeBranch || "branch"}`; + } + + return safeBranch; +} + /** * Get the path to the worktree metadata directory */ function getWorktreeMetadataDir(projectPath: string, branch: string): string { - // Sanitize branch name for filesystem (replace / with -) - const safeBranch = branch.replace(/\//g, "-"); + const safeBranch = sanitizeBranchName(branch); return path.join(projectPath, ".automaker", "worktrees", safeBranch); } diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index d55ef0c3..d2e22535 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -8,6 +8,24 @@ import { promisify } from "util"; import { getErrorMessage, logError } from "../common.js"; import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; +// Shell escaping utility to prevent command injection +function shellEscape(arg: string): string { + if (process.platform === "win32") { + // Windows CMD shell escaping + return `"${arg.replace(/"/g, '""')}"`; + } else { + // Unix shell escaping + return `'${arg.replace(/'/g, "'\\''")}'`; + } +} + +// Validate branch name to prevent command injection +function isValidBranchName(name: string): boolean { + // Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars + // Also reject shell metacharacters for safety + return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250; +} + const execAsync = promisify(exec); // Extended PATH to include common tool installation locations @@ -78,6 +96,15 @@ export function createCreatePRHandler() { ); const branchName = branchOutput.trim(); + // Validate branch name for security + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: "Invalid branch name contains unsafe characters", + }); + return; + } + // Check for uncommitted changes const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath, diff --git a/apps/server/src/routes/worktree/routes/pr-info.ts b/apps/server/src/routes/worktree/routes/pr-info.ts index aa270466..179266be 100644 --- a/apps/server/src/routes/worktree/routes/pr-info.ts +++ b/apps/server/src/routes/worktree/routes/pr-info.ts @@ -42,6 +42,15 @@ const execEnv = { PATH: extendedPath, }; +/** + * Validate branch name to prevent command injection. + * Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars. + * We also reject shell metacharacters for safety. + */ +function isValidBranchName(name: string): boolean { + return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250; +} + export interface PRComment { id: number; author: string; @@ -79,6 +88,15 @@ export function createPRInfoHandler() { return; } + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: "Invalid branch name contains unsafe characters", + }); + return; + } + // Check if gh CLI is available let ghCliAvailable = false; try { @@ -226,35 +244,38 @@ export function createPRInfoHandler() { // Get review comments (inline code comments) let reviewComments: PRComment[] = []; - try { - const reviewsEndpoint = targetRepo - ? `repos/${targetRepo}/pulls/${prNumber}/comments` - : `repos/{owner}/{repo}/pulls/${prNumber}/comments`; - const reviewsCmd = `gh api ${reviewsEndpoint}`; - const { stdout: reviewsOutput } = await execAsync( - reviewsCmd, - { cwd: worktreePath, env: execEnv } - ); - const reviewsData = JSON.parse(reviewsOutput); - reviewComments = reviewsData.map((c: { - id: number; - user: { login: string }; - body: string; - path: string; - line?: number; - original_line?: number; - created_at: string; - }) => ({ - id: c.id, - author: c.user?.login || "unknown", - body: c.body, - path: c.path, - line: c.line || c.original_line, - createdAt: c.created_at, - isReviewComment: true, - })); - } catch (error) { - console.warn("[PRInfo] Failed to fetch review comments:", error); + // Only fetch review comments if we have repository info + if (targetRepo) { + try { + const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`; + const reviewsCmd = `gh api ${reviewsEndpoint}`; + const { stdout: reviewsOutput } = await execAsync( + reviewsCmd, + { cwd: worktreePath, env: execEnv } + ); + const reviewsData = JSON.parse(reviewsOutput); + reviewComments = reviewsData.map((c: { + id: number; + user: { login: string }; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + }) => ({ + id: c.id, + author: c.user?.login || "unknown", + body: c.body, + path: c.path, + line: c.line || c.original_line, + createdAt: c.created_at, + isReviewComment: true, + })); + } catch (error) { + console.warn("[PRInfo] Failed to fetch review comments:", error); + } + } else { + console.warn("[PRInfo] Cannot fetch review comments: repository info not available"); } const prInfo: PRInfo = { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index add0693c..89540726 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -421,69 +421,21 @@ export function BoardView() { // Handler for addressing PR comments - creates a feature and starts it automatically const handleAddressPRComments = useCallback( async (worktree: WorktreeInfo, prInfo: PRInfo) => { - // If comments are empty, fetch them from GitHub - let fullPRInfo = prInfo; - if (prInfo.comments.length === 0 && prInfo.reviewComments.length === 0) { - try { - const api = getElectronAPI(); - if (api?.worktree?.getPRInfo) { - const result = await api.worktree.getPRInfo( - worktree.path, - worktree.branch - ); - if ( - result.success && - result.result?.hasPR && - result.result.prInfo - ) { - fullPRInfo = result.result.prInfo; - } - } - } catch (error) { - console.error("[Board] Failed to fetch PR comments:", error); - } - } - - // Format PR comments into a feature description - const allComments = [ - ...fullPRInfo.comments.map((c) => ({ - ...c, - type: "comment" as const, - })), - ...fullPRInfo.reviewComments.map((c) => ({ - ...c, - type: "review" as const, - })), - ].sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - - let description = `Address PR #${fullPRInfo.number} feedback: "${fullPRInfo.title}"\n\n`; - description += `PR URL: ${fullPRInfo.url}\n\n`; - - if (allComments.length === 0) { - description += `No comments found on this PR yet. Check the PR for any new feedback.\n`; - } else { - description += `## Feedback to address:\n\n`; - for (const comment of allComments) { - if (comment.type === "review" && comment.path) { - description += `### ${comment.path}${comment.line ? `:${comment.line}` : ""}\n`; - } - description += `**@${comment.author}:**\n${comment.body}\n\n`; - } - } + // Use a simple prompt that instructs the agent to read and address PR feedback + // The agent will fetch the PR comments directly, which is more reliable and up-to-date + const prNumber = prInfo.number; + const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`; // Create the feature const featureData = { category: "PR Review", - description: description.trim(), + description, steps: [], images: [], imagePaths: [], skipTests: defaultSkipTests, - model: "sonnet" as const, - thinkingLevel: "medium" as const, + model: "opus" as const, + thinkingLevel: "none" as const, branchName: worktree.branch, priority: 1, // High priority for PR feedback planningMode: "skip" as const, @@ -500,7 +452,7 @@ export function BoardView() { (f) => f.branchName === worktree.branch && f.status === "backlog" && - f.description.includes(`PR #${fullPRInfo.number}`) + f.description.includes(`PR #${prNumber}`) ); if (newFeature) { @@ -1255,12 +1207,19 @@ export function BoardView() { // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL if (prUrl && selectedWorktreeForAction?.branch) { const branchName = selectedWorktreeForAction.branch; - hookFeatures - .filter((f) => f.branchName === branchName) - .forEach((feature) => { - updateFeature(feature.id, { prUrl }); - persistFeatureUpdate(feature.id, { prUrl }); - }); + const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName); + + // Update local state synchronously + featuresToUpdate.forEach((feature) => { + updateFeature(feature.id, { prUrl }); + }); + + // Persist changes asynchronously and in parallel + Promise.all( + featuresToUpdate.map((feature) => + persistFeatureUpdate(feature.id, { prUrl }) + ) + ).catch(console.error); } setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card.tsx index bf2ad33d..96ccb618 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card.tsx @@ -699,24 +699,30 @@ export const KanbanCard = memo(function KanbanCard({ )} {/* PR URL Display */} - {feature.prUrl && ( - - )} + {typeof feature.prUrl === "string" && + /^https?:\/\//i.test(feature.prUrl) && (() => { + const prNumber = feature.prUrl.split('/').pop(); + return ( + + ); + })()} {/* Steps Preview */} {showSteps && feature.steps && feature.steps.length > 0 && ( diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 17d66374..71d7bdf1 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -29,12 +29,8 @@ interface CreatePRDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; -<<<<<<< Updated upstream - onCreated: (prUrl?: string) => void; -======= projectPath: string | null; - onCreated: () => void; ->>>>>>> Stashed changes + onCreated: (prUrl?: string) => void; } export function CreatePRDialog({ diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 492122ab..b3134191 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -1,7 +1,6 @@ import { Button } from "@/components/ui/button"; -<<<<<<< Updated upstream -import { RefreshCw, Globe, Loader2, CircleDot } from "lucide-react"; +import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tooltip, @@ -9,12 +8,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types"; -======= -import { RefreshCw, Globe, Loader2, GitPullRequest } from "lucide-react"; -import { cn } from "@/lib/utils"; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types"; ->>>>>>> Stashed changes import { BranchSwitchDropdown } from "./branch-switch-dropdown"; import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; @@ -95,7 +89,6 @@ export function WorktreeTab({ onStopDevServer, onOpenDevServerUrl, }: WorktreeTabProps) { -<<<<<<< Updated upstream // Determine border color based on state: // - Running features: cyan border (high visibility, indicates active work) // - Uncommitted changes: amber border (warning state, needs attention) @@ -111,7 +104,7 @@ export function WorktreeTab({ }; const borderClasses = getBorderClasses(); -======= + let prBadge: JSX.Element | null = null; if (worktree.pr) { const prState = worktree.pr.state?.toLowerCase() ?? "open"; @@ -197,7 +190,6 @@ export function WorktreeTab({ ); } ->>>>>>> Stashed changes return (
@@ -225,7 +217,6 @@ export function WorktreeTab({ {cardCount} )} -<<<<<<< Updated upstream {hasChanges && ( @@ -246,9 +237,7 @@ export function WorktreeTab({ )} -======= {prBadge} ->>>>>>> Stashed changes )} -<<<<<<< Updated upstream {hasChanges && ( @@ -313,9 +301,7 @@ export function WorktreeTab({ )} -======= {prBadge} ->>>>>>> Stashed changes )} From bb5f68c2f0abd3a0541348fbe1310159e2e17fc1 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 20:46:23 -0500 Subject: [PATCH 6/9] refactor: improve PR display and interaction in worktree components - Updated WorktreeActionsDropdown to use DropdownMenuItem for better interaction with PR links. - Enhanced WorktreeTab to include hover and active states for buttons, and improved accessibility with updated titles and aria-labels. - Ensured PR URLs are safely opened only if they exist, enhancing user experience and preventing errors. --- .../components/worktree-actions-dropdown.tsx | 13 +++++++++---- .../worktree-panel/components/worktree-tab.tsx | 16 ++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 45cf15e7..397f2c1b 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -185,13 +185,18 @@ export function WorktreeActionsDropdown({ {/* Show PR info and Address Comments button if PR exists */} {!worktree.isMain && hasPR && worktree.pr && ( <> - - + { + window.open(worktree.pr!.url, "_blank"); + }} + className="text-xs" + > + PR #{worktree.pr.number} - + {worktree.pr.state} - + { // Convert stored PR info to the full PRInfo format for the handler diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index b3134191..dfb27104 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -157,7 +157,7 @@ export function WorktreeTab({ className={cn( "ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors", "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background", - "appearance-none cursor-pointer", // Reset button appearance but keep cursor + "appearance-none cursor-pointer hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states prStateClasses )} style={{ @@ -165,24 +165,28 @@ export function WorktreeTab({ backgroundImage: "none", boxShadow: "none", }} - title={prLabel} - aria-label={prLabel} + title={`${prLabel} - Click to open`} + aria-label={`${prLabel} - Click to open pull request`} onClick={(e) => { e.stopPropagation(); // Prevent triggering worktree selection - window.open(worktree.pr.url, "_blank", "noopener,noreferrer"); + if (worktree.pr?.url) { + window.open(worktree.pr.url, "_blank", "noopener,noreferrer"); + } }} onKeyDown={(e) => { // Prevent event from bubbling to parent button e.stopPropagation(); if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - window.open(worktree.pr.url, "_blank", "noopener,noreferrer"); + if (worktree.pr?.url) { + window.open(worktree.pr.url, "_blank", "noopener,noreferrer"); + } } }} >