Merge origin/main into feature/shared-packages

Resolved conflicts:
- list.ts: Keep @automaker/git-utils import, add worktree-metadata import
- feature-loader.ts: Use Feature type from @automaker/types
- automaker-paths.test.ts: Import from @automaker/platform
- kanban-card.tsx: Accept deletion (split into components/)
- subprocess.test.ts: Keep libs/platform location

Added missing exports to @automaker/platform:
- getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir

Added title and titleGenerating fields to @automaker/types Feature interface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 22:20:17 +01:00
108 changed files with 10834 additions and 3489 deletions

View File

@@ -14,9 +14,87 @@ import {
import { FeatureLoader } from "../../services/feature-loader.js";
const logger = createLogger("Worktree");
const execAsync = promisify(exec);
export const execAsync = promisify(exec);
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 =
"chore: automaker initial commit";

View File

@@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js";
import { createCreateHandler } from "./routes/create.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createCreatePRHandler } from "./routes/create-pr.js";
import { createPRInfoHandler } from "./routes/pr-info.js";
import { createCommitHandler } from "./routes/commit.js";
import { createPushHandler } from "./routes/push.js";
import { createPullHandler } from "./routes/pull.js";
@@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/pr-info", createPRInfoHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());

View File

@@ -3,53 +3,22 @@
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
// This is needed because Electron apps don't inherit the user's shell PATH
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);
const execEnv = {
...process.env,
PATH: extendedPath,
};
import {
getErrorMessage,
logError,
execAsync,
execEnv,
isValidBranchName,
isGhCliAvailable,
} from "../common.js";
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
worktreePath: string;
projectPath?: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
@@ -65,6 +34,10 @@ export function createCreatePRHandler() {
return;
}
// Use projectPath if provided, otherwise derive from worktreePath
// For worktrees, projectPath is needed to store metadata in the main project's .automaker folder
const effectiveProjectPath = projectPath || worktreePath;
// Get current branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
@@ -72,6 +45,15 @@ export function createCreatePRHandler() {
);
const branchName = branchOutput.trim();
// Validate branch name for security
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: "Invalid branch name contains unsafe characters",
});
return;
}
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
@@ -143,18 +125,8 @@ export function createCreatePRHandler() {
let browserUrl: string | null = null;
let ghCliAvailable = false;
// Check if gh CLI is available (cross-platform)
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
await execAsync(checkCommand, { env: execEnv });
ghCliAvailable = true;
} catch {
ghCliAvailable = false;
}
// Get repository URL for browser fallback
// Get repository URL and detect fork workflow FIRST
// This is needed for both the existing PR check and PR creation
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
@@ -180,7 +152,7 @@ export function createCreatePRHandler() {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") {
@@ -206,7 +178,7 @@ export function createCreatePRHandler() {
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
@@ -220,6 +192,9 @@ export function createCreatePRHandler() {
}
}
// Check if gh CLI is available (cross-platform)
ghCliAvailable = await isGhCliAvailable();
// Construct browser URL for PR creation
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
@@ -234,32 +209,136 @@ export function createCreatePRHandler() {
}
}
let prNumber: number | undefined;
let prAlreadyExisted = false;
if (ghCliAvailable) {
// First, check if a PR already exists for this branch using gh pr list
// This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : "";
console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
const { stdout: prOutput } = await execAsync(prCmd, {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
console.log(`[CreatePR] Running: ${listCmd}`);
const { stdout: existingPrOutput } = await execAsync(listCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
const existingPrs = JSON.parse(existingPrOutput);
if (Array.isArray(existingPrs) && existingPrs.length > 0) {
const existingPr = existingPrs[0];
// PR already exists - use it and store metadata
console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`);
prUrl = existingPr.url;
prNumber = existingPr.number;
prAlreadyExisted = true;
// Store the existing PR info in metadata
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`);
} else {
console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
}
} catch (listError) {
// gh pr list failed - log but continue to try creating
console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError);
}
// Only create a new PR if one doesn't already exist
if (!prUrl) {
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
console.log(`[CreatePR] Creating PR with command: ${prCmd}`);
const { stdout: prOutput } = await execAsync(prCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
console.log(`[CreatePR] PR created: ${prUrl}`);
// Extract PR number and store metadata for newly created PR
if (prUrl) {
const prMatch = prUrl.match(/\/pull\/(\d+)/);
prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined;
if (prNumber) {
try {
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: prNumber,
url: prUrl,
title,
state: draft ? "draft" : "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`);
} catch (metadataError) {
console.error("[CreatePR] Failed to store PR metadata:", metadataError);
}
}
}
} catch (ghError: unknown) {
// gh CLI failed - check if it's "already exists" error and try to fetch the PR
const err = ghError as { stderr?: string; message?: string };
const errorMessage = err.stderr || err.message || "PR creation failed";
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
// If error indicates PR already exists, try to fetch it
if (errorMessage.toLowerCase().includes("already exists")) {
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
try {
const { stdout: viewOutput } = await execAsync(
`gh pr view --json number,title,url,state`,
{ cwd: worktreePath, env: execEnv }
);
const existingPr = JSON.parse(viewOutput);
if (existingPr.url) {
prUrl = existingPr.url;
prNumber = existingPr.number;
prAlreadyExisted = true;
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || "open",
createdAt: new Date().toISOString(),
});
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
}
} catch (viewError) {
console.error("[CreatePR] Failed to fetch existing PR:", viewError);
prError = errorMessage;
}
} else {
prError = errorMessage;
}
}
}
} else {
prError = "gh_cli_not_available";
@@ -274,7 +353,9 @@ export function createCreatePRHandler() {
commitHash,
pushed: true,
prUrl,
prNumber,
prCreated: !!prUrl,
prAlreadyExisted,
prError: prError || undefined,
browserUrl: browserUrl || undefined,
ghCliAvailable,

View File

@@ -11,6 +11,7 @@ import { promisify } from "util";
import { existsSync } from "fs";
import { isGitRepo } from "@automaker/git-utils";
import { getErrorMessage, logError, normalizePath } from "../common.js";
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
const execAsync = promisify(exec);
@@ -22,6 +23,7 @@ interface WorktreeInfo {
hasWorktree: boolean; // Always true for items in this list
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
}
async function getCurrentBranch(cwd: string): Promise<string> {
@@ -107,6 +109,9 @@ export function createListHandler() {
}
}
// Read all worktree metadata to get PR info
const allMetadata = await readAllWorktreeMetadata(projectPath);
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
@@ -128,6 +133,14 @@ export function createListHandler() {
}
}
// Add PR info from metadata for each worktree
for (const worktree of worktrees) {
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
worktree.pr = metadata.pr;
}
}
res.json({
success: true,
worktrees,

View 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) });
}
};
}