mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
183
apps/server/src/lib/worktree-metadata.ts
Normal file
183
apps/server/src/lib/worktree-metadata.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
/** Maximum length for sanitized branch names in filesystem paths */
|
||||||
|
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||||
|
|
||||||
|
export interface WorktreePRInfo {
|
||||||
|
number: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeMetadata {
|
||||||
|
branch: string;
|
||||||
|
createdAt: string;
|
||||||
|
pr?: WorktreePRInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize branch name for cross-platform filesystem safety
|
||||||
|
*/
|
||||||
|
function sanitizeBranchName(branch: string): string {
|
||||||
|
// Replace characters that are invalid or problematic on various filesystems:
|
||||||
|
// - Forward and backslashes (path separators)
|
||||||
|
// - Windows invalid chars: : * ? " < > |
|
||||||
|
// - Other potentially problematic chars
|
||||||
|
let safeBranch = branch
|
||||||
|
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
|
||||||
|
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||||
|
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
|
||||||
|
.replace(/-+/g, "-") // Collapse multiple dashes
|
||||||
|
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
||||||
|
|
||||||
|
// Truncate to safe length (leave room for path components)
|
||||||
|
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
|
||||||
|
|
||||||
|
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||||
|
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||||
|
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
|
||||||
|
safeBranch = `_${safeBranch || "branch"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeBranch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the worktree metadata directory
|
||||||
|
*/
|
||||||
|
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
|
||||||
|
const safeBranch = sanitizeBranchName(branch);
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,87 @@ import {
|
|||||||
import { FeatureLoader } from "../../services/feature-loader.js";
|
import { FeatureLoader } from "../../services/feature-loader.js";
|
||||||
|
|
||||||
const logger = createLogger("Worktree");
|
const logger = createLogger("Worktree");
|
||||||
const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Maximum allowed length for git branch names */
|
||||||
|
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extended PATH configuration for Electron apps
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||||
|
const additionalPaths: string[] = [];
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Windows paths
|
||||||
|
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 {
|
||||||
|
// Unix/Mac paths
|
||||||
|
additionalPaths.push(
|
||||||
|
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||||
|
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||||
|
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||||
|
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedPath = [
|
||||||
|
process.env.PATH,
|
||||||
|
...additionalPaths.filter(Boolean),
|
||||||
|
].filter(Boolean).join(pathSeparator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variables with extended PATH for executing shell commands.
|
||||||
|
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||||
|
* common tool installation locations.
|
||||||
|
*/
|
||||||
|
export const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate branch name to prevent command injection.
|
||||||
|
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
||||||
|
* We also reject shell metacharacters for safety.
|
||||||
|
*/
|
||||||
|
export function isValidBranchName(name: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if gh CLI is available on the system
|
||||||
|
*/
|
||||||
|
export async function isGhCliAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const checkCommand = process.platform === "win32"
|
||||||
|
? "where gh"
|
||||||
|
: "command -v gh";
|
||||||
|
await execAsync(checkCommand, { env: execEnv });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
||||||
"chore: automaker initial commit";
|
"chore: automaker initial commit";
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js";
|
|||||||
import { createCreateHandler } from "./routes/create.js";
|
import { createCreateHandler } from "./routes/create.js";
|
||||||
import { createDeleteHandler } from "./routes/delete.js";
|
import { createDeleteHandler } from "./routes/delete.js";
|
||||||
import { createCreatePRHandler } from "./routes/create-pr.js";
|
import { createCreatePRHandler } from "./routes/create-pr.js";
|
||||||
|
import { createPRInfoHandler } from "./routes/pr-info.js";
|
||||||
import { createCommitHandler } from "./routes/commit.js";
|
import { createCommitHandler } from "./routes/commit.js";
|
||||||
import { createPushHandler } from "./routes/push.js";
|
import { createPushHandler } from "./routes/push.js";
|
||||||
import { createPullHandler } from "./routes/pull.js";
|
import { createPullHandler } from "./routes/pull.js";
|
||||||
@@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post("/create", createCreateHandler());
|
router.post("/create", createCreateHandler());
|
||||||
router.post("/delete", createDeleteHandler());
|
router.post("/delete", createDeleteHandler());
|
||||||
router.post("/create-pr", createCreatePRHandler());
|
router.post("/create-pr", createCreatePRHandler());
|
||||||
|
router.post("/pr-info", createPRInfoHandler());
|
||||||
router.post("/commit", createCommitHandler());
|
router.post("/commit", createCommitHandler());
|
||||||
router.post("/push", createPushHandler());
|
router.post("/push", createPushHandler());
|
||||||
router.post("/pull", createPullHandler());
|
router.post("/pull", createPullHandler());
|
||||||
|
|||||||
@@ -3,53 +3,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { exec } from "child_process";
|
import {
|
||||||
import { promisify } from "util";
|
getErrorMessage,
|
||||||
import { getErrorMessage, logError } from "../common.js";
|
logError,
|
||||||
|
execAsync,
|
||||||
const execAsync = promisify(exec);
|
execEnv,
|
||||||
|
isValidBranchName,
|
||||||
// Extended PATH to include common tool installation locations
|
isGhCliAvailable,
|
||||||
// This is needed because Electron apps don't inherit the user's shell PATH
|
} from "../common.js";
|
||||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||||
const additionalPaths: string[] = [];
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// Windows paths
|
|
||||||
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 {
|
|
||||||
// Unix/Mac paths
|
|
||||||
additionalPaths.push(
|
|
||||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
|
||||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
|
||||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
|
||||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extendedPath = [
|
|
||||||
process.env.PATH,
|
|
||||||
...additionalPaths.filter(Boolean),
|
|
||||||
].filter(Boolean).join(pathSeparator);
|
|
||||||
|
|
||||||
const execEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: extendedPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createCreatePRHandler() {
|
export function createCreatePRHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
projectPath?: string;
|
||||||
commitMessage?: string;
|
commitMessage?: string;
|
||||||
prTitle?: string;
|
prTitle?: string;
|
||||||
prBody?: string;
|
prBody?: string;
|
||||||
@@ -65,6 +34,10 @@ export function createCreatePRHandler() {
|
|||||||
return;
|
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
|
// Get current branch name
|
||||||
const { stdout: branchOutput } = await execAsync(
|
const { stdout: branchOutput } = await execAsync(
|
||||||
"git rev-parse --abbrev-ref HEAD",
|
"git rev-parse --abbrev-ref HEAD",
|
||||||
@@ -72,6 +45,15 @@ export function createCreatePRHandler() {
|
|||||||
);
|
);
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
|
// Validate branch name for security
|
||||||
|
if (!isValidBranchName(branchName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid branch name contains unsafe characters",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
@@ -143,18 +125,8 @@ export function createCreatePRHandler() {
|
|||||||
let browserUrl: string | null = null;
|
let browserUrl: string | null = null;
|
||||||
let ghCliAvailable = false;
|
let ghCliAvailable = false;
|
||||||
|
|
||||||
// Check if gh CLI is available (cross-platform)
|
// Get repository URL and detect fork workflow FIRST
|
||||||
try {
|
// This is needed for both the existing PR check and PR creation
|
||||||
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
|
|
||||||
let repoUrl: string | null = null;
|
let repoUrl: string | null = null;
|
||||||
let upstreamRepo: string | null = null;
|
let upstreamRepo: string | null = null;
|
||||||
let originOwner: string | null = null;
|
let originOwner: string | null = null;
|
||||||
@@ -220,6 +192,9 @@ export function createCreatePRHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if gh CLI is available (cross-platform)
|
||||||
|
ghCliAvailable = await isGhCliAvailable();
|
||||||
|
|
||||||
// Construct browser URL for PR creation
|
// Construct browser URL for PR creation
|
||||||
if (repoUrl) {
|
if (repoUrl) {
|
||||||
const encodedTitle = encodeURIComponent(title);
|
const encodedTitle = encodeURIComponent(title);
|
||||||
@@ -234,32 +209,136 @@ export function createCreatePRHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prNumber: number | undefined;
|
||||||
|
let prAlreadyExisted = false;
|
||||||
|
|
||||||
if (ghCliAvailable) {
|
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 {
|
try {
|
||||||
// Build gh pr create command
|
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
||||||
let prCmd = `gh pr create --base "${base}"`;
|
console.log(`[CreatePR] Running: ${listCmd}`);
|
||||||
|
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
||||||
// 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, {
|
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
prUrl = prOutput.trim();
|
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
|
||||||
} catch (ghError: unknown) {
|
|
||||||
// gh CLI failed
|
const existingPrs = JSON.parse(existingPrOutput);
|
||||||
const err = ghError as { stderr?: string; message?: string };
|
|
||||||
prError = err.stderr || err.message || "PR creation failed";
|
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 {
|
} else {
|
||||||
prError = "gh_cli_not_available";
|
prError = "gh_cli_not_available";
|
||||||
@@ -274,7 +353,9 @@ export function createCreatePRHandler() {
|
|||||||
commitHash,
|
commitHash,
|
||||||
pushed: true,
|
pushed: true,
|
||||||
prUrl,
|
prUrl,
|
||||||
|
prNumber,
|
||||||
prCreated: !!prUrl,
|
prCreated: !!prUrl,
|
||||||
|
prAlreadyExisted,
|
||||||
prError: prError || undefined,
|
prError: prError || undefined,
|
||||||
browserUrl: browserUrl || undefined,
|
browserUrl: browserUrl || undefined,
|
||||||
ghCliAvailable,
|
ghCliAvailable,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { exec } from "child_process";
|
|||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
|
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
|
||||||
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface WorktreeInfo {
|
|||||||
hasWorktree: boolean; // Always true for items in this list
|
hasWorktree: boolean; // Always true for items in this list
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
|
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
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 is requested, fetch change status for each worktree
|
||||||
if (includeDetails) {
|
if (includeDetails) {
|
||||||
for (const worktree of worktrees) {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
worktrees,
|
worktrees,
|
||||||
|
|||||||
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* POST /pr-info endpoint - Get PR info and comments for a branch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import {
|
||||||
|
getErrorMessage,
|
||||||
|
logError,
|
||||||
|
execAsync,
|
||||||
|
execEnv,
|
||||||
|
isValidBranchName,
|
||||||
|
isGhCliAvailable,
|
||||||
|
} from "../common.js";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate branch name to prevent command injection
|
||||||
|
if (!isValidBranchName(branchName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid branch name contains unsafe characters",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if gh CLI is available
|
||||||
|
const ghCliAvailable = await isGhCliAvailable();
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
// Only fetch review comments if we have repository info
|
||||||
|
if (targetRepo) {
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
|
||||||
|
const reviewsCmd = `gh api ${reviewsEndpoint}`;
|
||||||
|
const { stdout: reviewsOutput } = await execAsync(
|
||||||
|
reviewsCmd,
|
||||||
|
{ cwd: worktreePath, env: execEnv }
|
||||||
|
);
|
||||||
|
const reviewsData = JSON.parse(reviewsOutput);
|
||||||
|
reviewComments = reviewsData.map((c: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string };
|
||||||
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
original_line?: number;
|
||||||
|
created_at: string;
|
||||||
|
}) => ({
|
||||||
|
id: c.id,
|
||||||
|
author: c.user?.login || "unknown",
|
||||||
|
body: c.body,
|
||||||
|
path: c.path,
|
||||||
|
line: c.line || c.original_line,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
isReviewComment: true,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[PRInfo] Failed to fetch review comments:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[PRInfo] Cannot fetch review comments: repository info not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prInfo: PRInfo = {
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,47 @@ describe("fs-utils.ts", () => {
|
|||||||
// Should not throw
|
// Should not throw
|
||||||
await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined();
|
await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle ELOOP error gracefully when checking path", async () => {
|
||||||
|
// Mock lstat to throw ELOOP error
|
||||||
|
const originalLstat = fs.lstat;
|
||||||
|
const mkdirSafePath = path.join(testDir, "eloop-path");
|
||||||
|
|
||||||
|
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||||
|
|
||||||
|
// Should not throw, should return gracefully
|
||||||
|
await expect(mkdirSafe(mkdirSafePath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle EEXIST error gracefully when creating directory", async () => {
|
||||||
|
const newDir = path.join(testDir, "race-condition-dir");
|
||||||
|
|
||||||
|
// Mock lstat to return ENOENT (path doesn't exist)
|
||||||
|
// Then mock mkdir to throw EEXIST (race condition)
|
||||||
|
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||||
|
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "EEXIST" });
|
||||||
|
|
||||||
|
// Should not throw, should return gracefully
|
||||||
|
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle ELOOP error gracefully when creating directory", async () => {
|
||||||
|
const newDir = path.join(testDir, "eloop-create-dir");
|
||||||
|
|
||||||
|
// Mock lstat to return ENOENT (path doesn't exist)
|
||||||
|
// Then mock mkdir to throw ELOOP
|
||||||
|
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||||
|
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "ELOOP" });
|
||||||
|
|
||||||
|
// Should not throw, should return gracefully
|
||||||
|
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("existsSafe", () => {
|
describe("existsSafe", () => {
|
||||||
@@ -109,5 +150,24 @@ describe("fs-utils.ts", () => {
|
|||||||
const exists = await existsSafe(symlinkPath);
|
const exists = await existsSafe(symlinkPath);
|
||||||
expect(exists).toBe(true);
|
expect(exists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return true for ELOOP error (symlink loop)", async () => {
|
||||||
|
// Mock lstat to throw ELOOP error
|
||||||
|
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||||
|
|
||||||
|
const exists = await existsSafe("/some/path/with/loop");
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for other errors", async () => {
|
||||||
|
// Mock lstat to throw a non-ENOENT, non-ELOOP error
|
||||||
|
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "EACCES" });
|
||||||
|
|
||||||
|
await expect(existsSafe("/some/path")).rejects.toMatchObject({ code: "EACCES" });
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
368
apps/server/tests/unit/lib/worktree-metadata.test.ts
Normal file
368
apps/server/tests/unit/lib/worktree-metadata.test.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
readWorktreeMetadata,
|
||||||
|
writeWorktreeMetadata,
|
||||||
|
updateWorktreePRInfo,
|
||||||
|
getWorktreePRInfo,
|
||||||
|
readAllWorktreeMetadata,
|
||||||
|
deleteWorktreeMetadata,
|
||||||
|
type WorktreeMetadata,
|
||||||
|
type WorktreePRInfo,
|
||||||
|
} from "@/lib/worktree-metadata.js";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
describe("worktree-metadata.ts", () => {
|
||||||
|
let testProjectPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testProjectPath = path.join(os.tmpdir(), `worktree-metadata-test-${Date.now()}`);
|
||||||
|
await fs.mkdir(testProjectPath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await fs.rm(testProjectPath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeBranchName", () => {
|
||||||
|
// Test through readWorktreeMetadata and writeWorktreeMetadata
|
||||||
|
it("should sanitize branch names with invalid characters", async () => {
|
||||||
|
const branch = "feature/test-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sanitize branch names with Windows invalid characters", async () => {
|
||||||
|
const branch = "feature:test*branch?";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sanitize Windows reserved names", async () => {
|
||||||
|
const branch = "CON";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readWorktreeMetadata", () => {
|
||||||
|
it("should return null when metadata file doesn't exist", async () => {
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, "nonexistent-branch");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read existing metadata", async () => {
|
||||||
|
const branch = "test-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read metadata with PR info", async () => {
|
||||||
|
const branch = "pr-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
pr: {
|
||||||
|
number: 123,
|
||||||
|
url: "https://github.com/owner/repo/pull/123",
|
||||||
|
title: "Test PR",
|
||||||
|
state: "open",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeWorktreeMetadata", () => {
|
||||||
|
it("should create metadata directory if it doesn't exist", async () => {
|
||||||
|
const branch = "new-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should overwrite existing metadata", async () => {
|
||||||
|
const branch = "existing-branch";
|
||||||
|
const metadata1: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const metadata2: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
pr: {
|
||||||
|
number: 456,
|
||||||
|
url: "https://github.com/owner/repo/pull/456",
|
||||||
|
title: "Updated PR",
|
||||||
|
state: "closed",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata1);
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata2);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(metadata2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorktreePRInfo", () => {
|
||||||
|
it("should create new metadata if it doesn't exist", async () => {
|
||||||
|
const branch = "new-pr-branch";
|
||||||
|
const prInfo: WorktreePRInfo = {
|
||||||
|
number: 789,
|
||||||
|
url: "https://github.com/owner/repo/pull/789",
|
||||||
|
title: "New PR",
|
||||||
|
state: "open",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.branch).toBe(branch);
|
||||||
|
expect(result?.pr).toEqual(prInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update existing metadata with PR info", async () => {
|
||||||
|
const branch = "existing-pr-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
|
||||||
|
const prInfo: WorktreePRInfo = {
|
||||||
|
number: 999,
|
||||||
|
url: "https://github.com/owner/repo/pull/999",
|
||||||
|
title: "Updated PR",
|
||||||
|
state: "merged",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result?.pr).toEqual(prInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve existing metadata when updating PR info", async () => {
|
||||||
|
const branch = "preserve-branch";
|
||||||
|
const originalCreatedAt = new Date().toISOString();
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: originalCreatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
|
||||||
|
const prInfo: WorktreePRInfo = {
|
||||||
|
number: 111,
|
||||||
|
url: "https://github.com/owner/repo/pull/111",
|
||||||
|
title: "PR",
|
||||||
|
state: "open",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||||
|
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result?.createdAt).toBe(originalCreatedAt);
|
||||||
|
expect(result?.pr).toEqual(prInfo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWorktreePRInfo", () => {
|
||||||
|
it("should return null when metadata doesn't exist", async () => {
|
||||||
|
const result = await getWorktreePRInfo(testProjectPath, "nonexistent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when metadata exists but has no PR info", async () => {
|
||||||
|
const branch = "no-pr-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
const result = await getWorktreePRInfo(testProjectPath, branch);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return PR info when it exists", async () => {
|
||||||
|
const branch = "has-pr-branch";
|
||||||
|
const prInfo: WorktreePRInfo = {
|
||||||
|
number: 222,
|
||||||
|
url: "https://github.com/owner/repo/pull/222",
|
||||||
|
title: "Has PR",
|
||||||
|
state: "open",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||||
|
const result = await getWorktreePRInfo(testProjectPath, branch);
|
||||||
|
expect(result).toEqual(prInfo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readAllWorktreeMetadata", () => {
|
||||||
|
it("should return empty map when worktrees directory doesn't exist", async () => {
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty map when worktrees directory is empty", async () => {
|
||||||
|
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||||
|
await fs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read all worktree metadata", async () => {
|
||||||
|
const branch1 = "branch-1";
|
||||||
|
const branch2 = "branch-2";
|
||||||
|
const metadata1: WorktreeMetadata = {
|
||||||
|
branch: branch1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const metadata2: WorktreeMetadata = {
|
||||||
|
branch: branch2,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
pr: {
|
||||||
|
number: 333,
|
||||||
|
url: "https://github.com/owner/repo/pull/333",
|
||||||
|
title: "PR 3",
|
||||||
|
state: "open",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch1, metadata1);
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch2, metadata2);
|
||||||
|
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect(result.get(branch1)).toEqual(metadata1);
|
||||||
|
expect(result.get(branch2)).toEqual(metadata2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip directories without worktree.json", async () => {
|
||||||
|
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||||
|
const emptyDir = path.join(worktreesDir, "empty-dir");
|
||||||
|
await fs.mkdir(emptyDir, { recursive: true });
|
||||||
|
|
||||||
|
const branch = "valid-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get(branch)).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip files in worktrees directory", async () => {
|
||||||
|
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||||
|
await fs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
const filePath = path.join(worktreesDir, "not-a-dir.txt");
|
||||||
|
await fs.writeFile(filePath, "content");
|
||||||
|
|
||||||
|
const branch = "valid-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get(branch)).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip directories with malformed JSON", async () => {
|
||||||
|
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||||
|
const badDir = path.join(worktreesDir, "bad-dir");
|
||||||
|
await fs.mkdir(badDir, { recursive: true });
|
||||||
|
const badJsonPath = path.join(badDir, "worktree.json");
|
||||||
|
await fs.writeFile(badJsonPath, "not valid json");
|
||||||
|
|
||||||
|
const branch = "valid-branch";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
|
||||||
|
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get(branch)).toEqual(metadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteWorktreeMetadata", () => {
|
||||||
|
it("should delete worktree metadata directory", async () => {
|
||||||
|
const branch = "to-delete";
|
||||||
|
const metadata: WorktreeMetadata = {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||||
|
let result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
await deleteWorktreeMetadata(testProjectPath, branch);
|
||||||
|
result = await readWorktreeMetadata(testProjectPath, branch);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle deletion when metadata doesn't exist", async () => {
|
||||||
|
// Should not throw
|
||||||
|
await expect(
|
||||||
|
deleteWorktreeMetadata(testProjectPath, "nonexistent")
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
@@ -39,6 +38,7 @@ import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialo
|
|||||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
||||||
import { WorktreePanel } from "./board-view/worktree-panel";
|
import { WorktreePanel } from "./board-view/worktree-panel";
|
||||||
|
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
|
||||||
import { COLUMNS } from "./board-view/constants";
|
import { COLUMNS } from "./board-view/constants";
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
@@ -58,6 +58,9 @@ const EMPTY_WORKTREES: ReturnType<
|
|||||||
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
|
/** Delay before starting a newly created feature to allow state to settle */
|
||||||
|
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
|
||||||
|
|
||||||
export function BoardView() {
|
export function BoardView() {
|
||||||
const {
|
const {
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -271,13 +274,16 @@ export function BoardView() {
|
|||||||
|
|
||||||
// Calculate unarchived card counts per branch
|
// Calculate unarchived card counts per branch
|
||||||
const branchCardCounts = useMemo(() => {
|
const branchCardCounts = useMemo(() => {
|
||||||
return hookFeatures.reduce((counts, feature) => {
|
return hookFeatures.reduce(
|
||||||
if (feature.status !== "completed") {
|
(counts, feature) => {
|
||||||
const branch = feature.branchName ?? "main";
|
if (feature.status !== "completed") {
|
||||||
counts[branch] = (counts[branch] || 0) + 1;
|
const branch = feature.branchName ?? "main";
|
||||||
}
|
counts[branch] = (counts[branch] || 0) + 1;
|
||||||
return counts;
|
}
|
||||||
}, {} as Record<string, number>);
|
return counts;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
}, [hookFeatures]);
|
}, [hookFeatures]);
|
||||||
|
|
||||||
// Custom collision detection that prioritizes columns over cards
|
// Custom collision detection that prioritizes columns over cards
|
||||||
@@ -340,7 +346,7 @@ export function BoardView() {
|
|||||||
const worktrees = useMemo(
|
const worktrees = useMemo(
|
||||||
() =>
|
() =>
|
||||||
currentProject
|
currentProject
|
||||||
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
|
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
||||||
: EMPTY_WORKTREES,
|
: EMPTY_WORKTREES,
|
||||||
[currentProject, worktreesByProject]
|
[currentProject, worktreesByProject]
|
||||||
);
|
);
|
||||||
@@ -415,6 +421,51 @@ export function BoardView() {
|
|||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||||
|
const handleAddressPRComments = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
|
// Use a simple prompt that instructs the agent to read and address PR feedback
|
||||||
|
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
||||||
|
const prNumber = prInfo.number;
|
||||||
|
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
||||||
|
|
||||||
|
// Create the feature
|
||||||
|
const featureData = {
|
||||||
|
category: "PR Review",
|
||||||
|
description,
|
||||||
|
steps: [],
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: "opus" as const,
|
||||||
|
thinkingLevel: "none" 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 #${prNumber}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newFeature) {
|
||||||
|
await handleStartImplementation(newFeature);
|
||||||
|
}
|
||||||
|
}, FEATURE_CREATION_SETTLE_DELAY_MS);
|
||||||
|
},
|
||||||
|
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
// 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
|
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||||
@@ -874,6 +925,7 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(worktree);
|
setSelectedWorktreeForAction(worktree);
|
||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
|
onAddressPRComments={handleAddressPRComments}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
@@ -1153,7 +1205,25 @@ export function BoardView() {
|
|||||||
open={showCreatePRDialog}
|
open={showCreatePRDialog}
|
||||||
onOpenChange={setShowCreatePRDialog}
|
onOpenChange={setShowCreatePRDialog}
|
||||||
worktree={selectedWorktreeForAction}
|
worktree={selectedWorktreeForAction}
|
||||||
onCreated={() => {
|
projectPath={currentProject?.path || null}
|
||||||
|
onCreated={(prUrl) => {
|
||||||
|
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
|
||||||
|
if (prUrl && selectedWorktreeForAction?.branch) {
|
||||||
|
const branchName = selectedWorktreeForAction.branch;
|
||||||
|
const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName);
|
||||||
|
|
||||||
|
// Update local state synchronously
|
||||||
|
featuresToUpdate.forEach((feature) => {
|
||||||
|
updateFeature(feature.id, { prUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist changes asynchronously and in parallel
|
||||||
|
Promise.all(
|
||||||
|
featuresToUpdate.map((feature) =>
|
||||||
|
persistFeatureUpdate(feature.id, { prUrl })
|
||||||
|
)
|
||||||
|
).catch(console.error);
|
||||||
|
}
|
||||||
setWorktreeRefreshKey((k) => k + 1);
|
setWorktreeRefreshKey((k) => k + 1);
|
||||||
setSelectedWorktreeForAction(null);
|
setSelectedWorktreeForAction(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitPullRequest,
|
||||||
|
ExternalLink,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Brain,
|
Brain,
|
||||||
@@ -696,6 +698,32 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PR URL Display */}
|
||||||
|
{typeof feature.prUrl === "string" &&
|
||||||
|
/^https?:\/\//i.test(feature.prUrl) && (() => {
|
||||||
|
const prNumber = feature.prUrl.split('/').pop();
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<a
|
||||||
|
href={feature.prUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
|
||||||
|
title={feature.prUrl}
|
||||||
|
data-testid={`pr-url-${feature.id}`}
|
||||||
|
>
|
||||||
|
<GitPullRequest className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate max-w-[150px]">
|
||||||
|
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Steps Preview */}
|
{/* Steps Preview */}
|
||||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||||
<div className="mb-3 space-y-1.5">
|
<div className="mb-3 space-y-1.5">
|
||||||
@@ -1079,7 +1107,23 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<span className="truncate">Refine</span>
|
<span className="truncate">Refine</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCommit && (
|
{/* Show Verify button if PR was created (changes are committed), otherwise show Commit button */}
|
||||||
|
{feature.prUrl && onManualVerify ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManualVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`verify-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
) : onCommit ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1094,7 +1138,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<GitCommit className="w-3 h-3 mr-1" />
|
<GitCommit className="w-3 h-3 mr-1" />
|
||||||
Commit
|
Commit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ interface CreatePRDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
onCreated: () => void;
|
projectPath: string | null;
|
||||||
|
onCreated: (prUrl?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreatePRDialog({
|
export function CreatePRDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
worktree,
|
worktree,
|
||||||
|
projectPath,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: CreatePRDialogProps) {
|
}: CreatePRDialogProps) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -96,6 +98,7 @@ export function CreatePRDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.createPR(worktree.path, {
|
const result = await api.worktree.createPR(worktree.path, {
|
||||||
|
projectPath: projectPath || undefined,
|
||||||
commitMessage: commitMessage || undefined,
|
commitMessage: commitMessage || undefined,
|
||||||
prTitle: title || worktree.branch,
|
prTitle: title || worktree.branch,
|
||||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||||
@@ -108,13 +111,25 @@ export function CreatePRDialog({
|
|||||||
setPrUrl(result.result.prUrl);
|
setPrUrl(result.result.prUrl);
|
||||||
// Mark operation as completed for refresh on close
|
// Mark operation as completed for refresh on close
|
||||||
operationCompletedRef.current = true;
|
operationCompletedRef.current = true;
|
||||||
toast.success("Pull request created!", {
|
|
||||||
description: `PR created from ${result.result.branch}`,
|
// Show different message based on whether PR already existed
|
||||||
action: {
|
if (result.result.prAlreadyExisted) {
|
||||||
label: "View PR",
|
toast.success("Pull request found!", {
|
||||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
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
|
// Don't call onCreated() here - keep dialog open to show success message
|
||||||
// onCreated() will be called when user closes the dialog
|
// onCreated() will be called when user closes the dialog
|
||||||
} else {
|
} else {
|
||||||
@@ -200,7 +215,8 @@ export function CreatePRDialog({
|
|||||||
// Only call onCreated() if an actual operation completed
|
// Only call onCreated() if an actual operation completed
|
||||||
// This prevents unnecessary refreshes when user cancels
|
// This prevents unnecessary refreshes when user cancels
|
||||||
if (operationCompletedRef.current) {
|
if (operationCompletedRef.current) {
|
||||||
onCreated();
|
// Pass the PR URL if one was created
|
||||||
|
onCreated(prUrl || undefined);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// State reset is handled by useEffect when open becomes false
|
// State reset is handled by useEffect when open becomes false
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
Globe,
|
Globe,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { WorktreeInfo, DevServerInfo } from "../types";
|
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -40,6 +41,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -63,11 +65,15 @@ export function WorktreeActionsDropdown({
|
|||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
|
onAddressPRComments,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
|
// Check if there's a PR associated with this worktree from stored metadata
|
||||||
|
const hasPR = !!worktree.pr;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -170,12 +176,50 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
{/* 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">
|
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
Create Pull Request
|
Create Pull Request
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{/* Show PR info and Address Comments button if PR exists */}
|
||||||
|
{!worktree.isMain && hasPR && worktree.pr && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
window.open(worktree.pr!.url, "_blank");
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
|
PR #{worktree.pr.number}
|
||||||
|
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||||
|
{worktree.pr.state}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<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 && (
|
{!worktree.isMain && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
|
||||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||||
|
|
||||||
interface WorktreeTabProps {
|
interface WorktreeTabProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
cardCount?: number; // Number of unarchived cards for this branch
|
cardCount?: number; // Number of unarchived cards for this branch
|
||||||
|
hasChanges?: boolean; // Whether the worktree has uncommitted changes
|
||||||
|
changedFilesCount?: number; // Number of files with uncommitted changes
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
@@ -36,6 +44,7 @@ interface WorktreeTabProps {
|
|||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -45,6 +54,8 @@ interface WorktreeTabProps {
|
|||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
worktree,
|
worktree,
|
||||||
cardCount,
|
cardCount,
|
||||||
|
hasChanges,
|
||||||
|
changedFilesCount,
|
||||||
isSelected,
|
isSelected,
|
||||||
isRunning,
|
isRunning,
|
||||||
isActivating,
|
isActivating,
|
||||||
@@ -72,13 +83,119 @@ export function WorktreeTab({
|
|||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
|
onAddressPRComments,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
|
// Determine border color based on state:
|
||||||
|
// - Running features: cyan border (high visibility, indicates active work)
|
||||||
|
// - Uncommitted changes: amber border (warning state, needs attention)
|
||||||
|
// - Both: cyan takes priority (running is more important to see)
|
||||||
|
const getBorderClasses = () => {
|
||||||
|
if (isRunning) {
|
||||||
|
return "ring-2 ring-cyan-500 ring-offset-1 ring-offset-background";
|
||||||
|
}
|
||||||
|
if (hasChanges) {
|
||||||
|
return "ring-2 ring-amber-500 ring-offset-1 ring-offset-background";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderClasses = getBorderClasses();
|
||||||
|
|
||||||
|
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 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 hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states
|
||||||
|
prStateClasses
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
// Override any inherited button styles
|
||||||
|
backgroundImage: "none",
|
||||||
|
boxShadow: "none",
|
||||||
|
}}
|
||||||
|
title={`${prLabel} - Click to open`}
|
||||||
|
aria-label={`${prLabel} - Click to open pull request`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering worktree selection
|
||||||
|
if (worktree.pr?.url) {
|
||||||
|
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Prevent event from bubbling to parent button
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (worktree.pr?.url) {
|
||||||
|
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" : ""}>
|
||||||
|
PR #{worktree.pr.number}
|
||||||
|
</span>
|
||||||
|
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
|
||||||
|
{prState}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className={cn("flex items-center rounded-md", borderClasses)}>
|
||||||
{worktree.isMain ? (
|
{worktree.isMain ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -103,6 +220,27 @@ export function WorktreeTab({
|
|||||||
{cardCount}
|
{cardCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||||
|
isSelected
|
||||||
|
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||||
|
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||||
|
)}>
|
||||||
|
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||||
|
{changedFilesCount ?? "!"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{prBadge}
|
||||||
</Button>
|
</Button>
|
||||||
<BranchSwitchDropdown
|
<BranchSwitchDropdown
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
@@ -146,6 +284,27 @@ export function WorktreeTab({
|
|||||||
{cardCount}
|
{cardCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||||
|
isSelected
|
||||||
|
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||||
|
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||||
|
)}>
|
||||||
|
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||||
|
{changedFilesCount ?? "!"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{prBadge}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -183,6 +342,7 @@ export function WorktreeTab({
|
|||||||
onOpenInEditor={onOpenInEditor}
|
onOpenInEditor={onOpenInEditor}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
|
onAddressPRComments={onAddressPRComments}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={onStartDevServer}
|
onStartDevServer={onStartDevServer}
|
||||||
onStopDevServer={onStopDevServer}
|
onStopDevServer={onStopDevServer}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export interface WorktreePRInfo {
|
||||||
|
number: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorktreeInfo {
|
export interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -6,6 +14,7 @@ export interface WorktreeInfo {
|
|||||||
hasWorktree: boolean;
|
hasWorktree: boolean;
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
|
pr?: WorktreePRInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BranchInfo {
|
export interface BranchInfo {
|
||||||
@@ -25,6 +34,31 @@ export interface FeatureInfo {
|
|||||||
branchName?: string;
|
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 {
|
export interface WorktreePanelProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCreateWorktree: () => void;
|
onCreateWorktree: () => void;
|
||||||
@@ -32,6 +66,7 @@ export interface WorktreePanelProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
runningFeatureIds?: string[];
|
runningFeatureIds?: string[];
|
||||||
features?: FeatureInfo[];
|
features?: FeatureInfo[];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function WorktreePanel({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
|
onAddressPRComments,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
@@ -109,7 +110,7 @@ export function WorktreePanel({
|
|||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{worktrees.map((worktree) => {
|
{worktrees.map((worktree) => {
|
||||||
const cardCount = branchCardCounts?.[worktree.branch];
|
const cardCount = branchCardCounts?.[worktree.branch];
|
||||||
return (
|
return (
|
||||||
@@ -117,6 +118,8 @@ export function WorktreePanel({
|
|||||||
key={worktree.path}
|
key={worktree.path}
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
cardCount={cardCount}
|
cardCount={cardCount}
|
||||||
|
hasChanges={worktree.hasChanges}
|
||||||
|
changedFilesCount={worktree.changedFilesCount}
|
||||||
isSelected={isWorktreeSelected(worktree)}
|
isSelected={isWorktreeSelected(worktree)}
|
||||||
isRunning={hasRunningFeatures(worktree)}
|
isRunning={hasRunningFeatures(worktree)}
|
||||||
isActivating={isActivating}
|
isActivating={isActivating}
|
||||||
@@ -144,6 +147,7 @@ export function WorktreePanel({
|
|||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
|
onAddressPRComments={onAddressPRComments}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
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) =>
|
stopDevServer: (worktreePath: string) =>
|
||||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
this.post("/api/worktree/stop-dev", { worktreePath }),
|
||||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
||||||
|
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||||
|
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Git API
|
// Git API
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export interface Feature {
|
|||||||
planningMode?: PlanningMode; // Planning mode for this feature
|
planningMode?: PlanningMode; // Planning mode for this feature
|
||||||
planSpec?: PlanSpec; // Generated spec/plan data
|
planSpec?: PlanSpec; // Generated spec/plan data
|
||||||
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
||||||
|
prUrl?: string; // Pull request URL when a PR has been created for this feature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
|
|||||||
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?
|
hasWorktree: boolean; // Does this branch have an active worktree?
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
|
pr?: {
|
||||||
|
number: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
removedWorktrees?: Array<{
|
removedWorktrees?: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
@@ -737,6 +744,7 @@ export interface WorktreeAPI {
|
|||||||
createPR: (
|
createPR: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
projectPath?: string;
|
||||||
commitMessage?: string;
|
commitMessage?: string;
|
||||||
prTitle?: string;
|
prTitle?: string;
|
||||||
prBody?: string;
|
prBody?: string;
|
||||||
@@ -751,7 +759,9 @@ export interface WorktreeAPI {
|
|||||||
commitHash?: string;
|
commitHash?: string;
|
||||||
pushed: boolean;
|
pushed: boolean;
|
||||||
prUrl?: string;
|
prUrl?: string;
|
||||||
|
prNumber?: number;
|
||||||
prCreated: boolean;
|
prCreated: boolean;
|
||||||
|
prAlreadyExisted?: boolean;
|
||||||
prError?: string;
|
prError?: string;
|
||||||
browserUrl?: string;
|
browserUrl?: string;
|
||||||
ghCliAvailable?: boolean;
|
ghCliAvailable?: boolean;
|
||||||
@@ -894,6 +904,44 @@ export interface WorktreeAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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 {
|
export interface GitAPI {
|
||||||
|
|||||||
@@ -2611,4 +2611,248 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
// worktreePath should not exist in the feature data (worktrees are created at execution time)
|
// worktreePath should not exist in the feature data (worktrees are created at execution time)
|
||||||
expect(featureData.worktreePath).toBeUndefined();
|
expect(featureData.worktreePath).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PR URL Tracking Tests
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
test("feature should support prUrl field for tracking pull request URLs", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Create a feature
|
||||||
|
await clickAddFeature(page);
|
||||||
|
await fillAddFeatureDialog(page, "Feature for PR URL test", {
|
||||||
|
category: "Testing",
|
||||||
|
});
|
||||||
|
await confirmAddFeature(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify feature was created
|
||||||
|
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||||
|
const featureDirs = fs.readdirSync(featuresDir);
|
||||||
|
const featureDir = featureDirs.find((dir) => {
|
||||||
|
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
||||||
|
if (fs.existsSync(featureFilePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
return data.description === "Feature for PR URL test";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(featureDir).toBeDefined();
|
||||||
|
|
||||||
|
// Manually update the feature.json file to add prUrl (simulating what happens after PR creation)
|
||||||
|
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||||
|
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
featureData.prUrl = "https://github.com/test/repo/pull/123";
|
||||||
|
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
|
||||||
|
|
||||||
|
// Reload the page to pick up the change
|
||||||
|
await page.reload();
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify the PR URL link is displayed on the card
|
||||||
|
const prUrlLink = page.locator(`[data-testid="pr-url-${featureData.id}"]`);
|
||||||
|
await expect(prUrlLink).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(prUrlLink).toHaveText(/Pull Request/);
|
||||||
|
await expect(prUrlLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://github.com/test/repo/pull/123"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prUrl should persist when updating feature", async ({ page }) => {
|
||||||
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Create a feature
|
||||||
|
await clickAddFeature(page);
|
||||||
|
await fillAddFeatureDialog(page, "Feature with PR URL persistence", {
|
||||||
|
category: "Testing",
|
||||||
|
});
|
||||||
|
await confirmAddFeature(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Find the feature file
|
||||||
|
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||||
|
const featureDirs = fs.readdirSync(featuresDir);
|
||||||
|
const featureDir = featureDirs.find((dir) => {
|
||||||
|
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
||||||
|
if (fs.existsSync(featureFilePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
return data.description === "Feature with PR URL persistence";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(featureDir).toBeDefined();
|
||||||
|
|
||||||
|
// Add prUrl to the feature
|
||||||
|
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||||
|
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
const originalPrUrl = "https://github.com/test/repo/pull/456";
|
||||||
|
featureData.prUrl = originalPrUrl;
|
||||||
|
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Open edit dialog by double-clicking the feature card
|
||||||
|
const featureCard = page.getByText("Feature with PR URL persistence");
|
||||||
|
await featureCard.dblclick();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Wait for edit dialog to open
|
||||||
|
const editDialog = page.locator('[data-testid="edit-feature-dialog"]');
|
||||||
|
await expect(editDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Update the description - wait for the textarea to be visible
|
||||||
|
const descInput = page.locator(
|
||||||
|
'[data-testid="feature-description-input"]'
|
||||||
|
);
|
||||||
|
await expect(descInput).toBeVisible({ timeout: 5000 });
|
||||||
|
await descInput.fill("Feature with PR URL persistence - updated");
|
||||||
|
|
||||||
|
// Save the feature
|
||||||
|
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
|
||||||
|
await saveButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify prUrl was preserved
|
||||||
|
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
expect(featureData.prUrl).toBe(originalPrUrl);
|
||||||
|
expect(featureData.description).toBe(
|
||||||
|
"Feature with PR URL persistence - updated"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feature in waiting_approval with prUrl should show Verify button instead of Commit", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Create a feature
|
||||||
|
await clickAddFeature(page);
|
||||||
|
await fillAddFeatureDialog(page, "Feature with PR for verify test", {
|
||||||
|
category: "Testing",
|
||||||
|
});
|
||||||
|
await confirmAddFeature(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Find the feature file
|
||||||
|
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||||
|
const featureDirs = fs.readdirSync(featuresDir);
|
||||||
|
const featureDir = featureDirs.find((dir) => {
|
||||||
|
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
||||||
|
if (fs.existsSync(featureFilePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
return data.description === "Feature with PR for verify test";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(featureDir).toBeDefined();
|
||||||
|
|
||||||
|
// Update the feature to waiting_approval status with a prUrl
|
||||||
|
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||||
|
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
featureData.status = "waiting_approval";
|
||||||
|
featureData.prUrl = "https://github.com/test/repo/pull/789";
|
||||||
|
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
|
||||||
|
|
||||||
|
// Reload the page to pick up the changes
|
||||||
|
await page.reload();
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify the feature card is in the waiting_approval column
|
||||||
|
const waitingApprovalColumn = page.locator(
|
||||||
|
'[data-testid="kanban-column-waiting_approval"]'
|
||||||
|
);
|
||||||
|
const featureCard = waitingApprovalColumn.locator(
|
||||||
|
`[data-testid="kanban-card-${featureData.id}"]`
|
||||||
|
);
|
||||||
|
await expect(featureCard).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the Verify button is visible (not Commit button)
|
||||||
|
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
|
||||||
|
await expect(verifyButton).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the Commit button is NOT visible
|
||||||
|
const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`);
|
||||||
|
await expect(commitButton).not.toBeVisible({ timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feature in waiting_approval without prUrl should show Commit button", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
|
await page.goto("/");
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
// Create a feature
|
||||||
|
await clickAddFeature(page);
|
||||||
|
await fillAddFeatureDialog(page, "Feature without PR for commit test", {
|
||||||
|
category: "Testing",
|
||||||
|
});
|
||||||
|
await confirmAddFeature(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Find the feature file
|
||||||
|
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||||
|
const featureDirs = fs.readdirSync(featuresDir);
|
||||||
|
const featureDir = featureDirs.find((dir) => {
|
||||||
|
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
||||||
|
if (fs.existsSync(featureFilePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
return data.description === "Feature without PR for commit test";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(featureDir).toBeDefined();
|
||||||
|
|
||||||
|
// Update the feature to waiting_approval status WITHOUT prUrl
|
||||||
|
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
|
||||||
|
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||||
|
featureData.status = "waiting_approval";
|
||||||
|
// Explicitly do NOT set prUrl
|
||||||
|
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
|
||||||
|
|
||||||
|
// Reload the page to pick up the changes
|
||||||
|
await page.reload();
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
await waitForBoardView(page);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify the feature card is in the waiting_approval column
|
||||||
|
const waitingApprovalColumn = page.locator(
|
||||||
|
'[data-testid="kanban-column-waiting_approval"]'
|
||||||
|
);
|
||||||
|
const featureCard = waitingApprovalColumn.locator(
|
||||||
|
`[data-testid="kanban-card-${featureData.id}"]`
|
||||||
|
);
|
||||||
|
await expect(featureCard).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the Commit button is visible
|
||||||
|
const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`);
|
||||||
|
await expect(commitButton).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the Verify button is NOT visible
|
||||||
|
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
|
||||||
|
await expect(verifyButton).not.toBeVisible({ timeout: 2000 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user