Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions apps/server/src/routes/worktree/routes/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath } from '../common.js';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';

Expand Down Expand Up @@ -121,6 +121,52 @@ async function scanWorktreesDirectory(
return discovered;
}

/**
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
*/
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();

try {
// Check if gh CLI is available
const ghAvailable = await isGhCliAvailable();
if (!ghAvailable) {
return prMap;
}

// Fetch open PRs from GitHub
const { stdout } = await execAsync(
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000',
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);

const prs = JSON.parse(stdout || '[]') as Array<{
number: number;
title: string;
url: string;
state: string;
headRefName: string;
createdAt: string;
}>;

for (const pr of prs) {
prMap.set(pr.headRefName, {
number: pr.number,
url: pr.url,
title: pr.title,
state: pr.state,
createdAt: pr.createdAt,
});
}
} catch (error) {
// Silently fail - PR detection is optional
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}

return prMap;
}

export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down Expand Up @@ -241,11 +287,23 @@ export function createListHandler() {
}
}

// Add PR info from metadata for each worktree
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();

for (const worktree of worktrees) {
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
// Use stored metadata (more complete info)
worktree.pr = metadata.pr;
} else if (includeDetails) {
// Fall back to GitHub PR detection only when includeDetails is requested
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
worktree.pr = githubPR;
}
}
}

Expand Down
36 changes: 20 additions & 16 deletions apps/ui/src/components/views/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,20 +328,6 @@ export function BoardView() {
fetchBranches();
}, [currentProject, worktreeRefreshKey]);

// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);

// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
Expand Down Expand Up @@ -426,6 +412,22 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';

// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
const primaryBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? primaryBranch;
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures, worktrees]);

// Helper function to add and select a worktree
const addAndSelectWorktree = useCallback(
(worktreeResult: { path: string; branch: string }) => {
Expand Down Expand Up @@ -724,10 +726,11 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);

// Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
const handleResolveConflicts = useCallback(
async (worktree: WorktreeInfo) => {
const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
const remoteBranch = `origin/${worktree.branch}`;
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;

// Create the feature
const featureData = {
Expand Down Expand Up @@ -1710,6 +1713,7 @@ export function BoardView() {
onOpenChange={setShowCreatePRDialog}
worktree={selectedWorktreeForAction}
projectPath={currentProject?.path || null}
defaultBaseBranch={selectedWorktreeBranch}
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ interface CreatePRDialogProps {
worktree: WorktreeInfo | null;
projectPath: string | null;
onCreated: (prUrl?: string) => void;
/** Default base branch for the PR (defaults to 'main' if not provided) */
defaultBaseBranch?: string;
}

export function CreatePRDialog({
Expand All @@ -38,10 +40,11 @@ export function CreatePRDialog({
worktree,
projectPath,
onCreated,
defaultBaseBranch = 'main',
}: CreatePRDialogProps) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [baseBranch, setBaseBranch] = useState('main');
const [baseBranch, setBaseBranch] = useState(defaultBaseBranch);
const [commitMessage, setCommitMessage] = useState('');
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -59,7 +62,7 @@ export function CreatePRDialog({
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
Expand All @@ -74,15 +77,15 @@ export function CreatePRDialog({
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
}
}, [open, worktree?.path]);
}, [open, worktree?.path, defaultBaseBranch]);

const handleCreate = async () => {
if (!worktree) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,27 +217,20 @@ export function WorktreeActionsDropdown({
)}
</DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
Expand Down Expand Up @@ -332,7 +325,7 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
)}
{/* Show PR info and Address Comments button if PR exists */}
{!worktree.isMain && hasPR && worktree.pr && (
{hasPR && worktree.pr && (
<>
<DropdownMenuItem
onClick={() => {
Expand Down