mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
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.
This commit is contained in:
154
apps/server/src/lib/worktree-metadata.ts
Normal file
154
apps/server/src/lib/worktree-metadata.ts
Normal file
@@ -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<WorktreeMetadata | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<WorktreePRInfo | null> {
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
return metadata?.pr || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all worktree metadata for a project
|
||||
*/
|
||||
export async function readAllWorktreeMetadata(
|
||||
projectPath: string
|
||||
): Promise<Map<string, WorktreeMetadata>> {
|
||||
const result = new Map<string, WorktreeMetadata>();
|
||||
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<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
try {
|
||||
await fs.rm(metadataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -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,
|
||||
|
||||
296
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
296
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
@@ -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<void> => {
|
||||
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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -170,12 +176,45 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && (
|
||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{!worktree.isMain && hasPR && worktree.pr && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<GitPullRequest className="w-3 h-3" />
|
||||
PR #{worktree.pr.number}
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
|
||||
{worktree.pr.state}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
// 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"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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 = (
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
prStateClasses
|
||||
)}
|
||||
style={{
|
||||
// Override any inherited button styles
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
title={prLabel}
|
||||
aria-label={prLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering worktree selection
|
||||
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");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
|
||||
#{worktree.pr.number}
|
||||
</span>
|
||||
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
|
||||
{prState}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center rounded-md", borderClasses)}>
|
||||
@@ -129,6 +225,7 @@ export function WorktreeTab({
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
<<<<<<< Updated upstream
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -149,6 +246,9 @@ export function WorktreeTab({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
=======
|
||||
{prBadge}
|
||||
>>>>>>> Stashed changes
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
worktree={worktree}
|
||||
@@ -192,6 +292,7 @@ export function WorktreeTab({
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
<<<<<<< Updated upstream
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -212,6 +313,9 @@ export function WorktreeTab({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
=======
|
||||
{prBadge}
|
||||
>>>>>>> Stashed changes
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -249,6 +353,7 @@ export function WorktreeTab({
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
48
apps/ui/src/types/electron.d.ts
vendored
48
apps/ui/src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user