From d4365de4b9bd8deb446b23d4e8a52fc0239ebafe Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 19:48:14 -0500 Subject: [PATCH] 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 {