diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index 3f7ea60d..4742a5b0 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -5,18 +5,14 @@ import * as secureFs from './secure-fs.js'; import * as path from 'path'; +import type { PRState, WorktreePRInfo } from '@automaker/types'; + +// Re-export types for backwards compatibility +export type { PRState, WorktreePRInfo }; /** 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; diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index 1bde9448..87777c69 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -13,6 +13,7 @@ import { } from '../common.js'; import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; const logger = createLogger('CreatePR'); @@ -268,11 +269,12 @@ export function createCreatePRHandler() { prAlreadyExisted = true; // Store the existing PR info in metadata + // GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED await updateWorktreePRInfo(effectiveProjectPath, branchName, { number: existingPr.number, url: existingPr.url, title: existingPr.title || title, - state: existingPr.state || 'open', + state: validatePRState(existingPr.state), createdAt: new Date().toISOString(), }); logger.debug( @@ -319,11 +321,12 @@ export function createCreatePRHandler() { if (prNumber) { try { + // Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN' await updateWorktreePRInfo(effectiveProjectPath, branchName, { number: prNumber, url: prUrl, title, - state: draft ? 'draft' : 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }); logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`); @@ -352,11 +355,12 @@ export function createCreatePRHandler() { prNumber = existingPr.number; prAlreadyExisted = true; + // GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED await updateWorktreePRInfo(effectiveProjectPath, branchName, { number: existingPr.number, url: existingPr.url, title: existingPr.title || title, - state: existingPr.state || 'open', + state: validatePRState(existingPr.state), createdAt: new Date().toISOString(), }); logger.debug(`Fetched and stored existing PR: #${existingPr.number}`); diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 96782d64..e82e5c14 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -14,8 +14,13 @@ import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; -import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { + readAllWorktreeMetadata, + updateWorktreePRInfo, + type WorktreePRInfo, +} from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; import { checkGitHubRemote, type GitHubRemoteStatus, @@ -168,8 +173,11 @@ async function getGitHubRemoteStatus(projectPath: string): Promise(); 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; + const githubPR = githubPRs.get(worktree.branch); + + if (githubPR) { + // Prefer fresh GitHub data (it has the current state) + worktree.pr = githubPR; + + // Sync metadata with GitHub state when: + // 1. No metadata exists for this PR (PR created externally) + // 2. State has changed (e.g., merged/closed on GitHub) + const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state; + if (needsSync) { + // Fire and forget - don't block the response + updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => { + logger.warn( + `Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}` + ); + }); } + } else if (metadata?.pr) { + // Fall back to stored metadata (for PRs not in recent GitHub response) + worktree.pr = metadata.pr; } } diff --git a/apps/server/tests/unit/lib/worktree-metadata.test.ts b/apps/server/tests/unit/lib/worktree-metadata.test.ts index ab7967f3..2f84af88 100644 --- a/apps/server/tests/unit/lib/worktree-metadata.test.ts +++ b/apps/server/tests/unit/lib/worktree-metadata.test.ts @@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => { number: 123, url: 'https://github.com/owner/repo/pull/123', title: 'Test PR', - state: 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }, }; @@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => { number: 456, url: 'https://github.com/owner/repo/pull/456', title: 'Updated PR', - state: 'closed', + state: 'CLOSED', createdAt: new Date().toISOString(), }, }; @@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => { number: 789, url: 'https://github.com/owner/repo/pull/789', title: 'New PR', - state: 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }; @@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => { number: 999, url: 'https://github.com/owner/repo/pull/999', title: 'Updated PR', - state: 'merged', + state: 'MERGED', createdAt: new Date().toISOString(), }; @@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => { number: 111, url: 'https://github.com/owner/repo/pull/111', title: 'PR', - state: 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }; @@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => { number: 222, url: 'https://github.com/owner/repo/pull/222', title: 'Has PR', - state: 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }; @@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => { number: 333, url: 'https://github.com/owner/repo/pull/333', title: 'PR 3', - state: 'open', + state: 'OPEN', createdAt: new Date().toISOString(), }, }; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index d2040048..a9cdedca 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -1,10 +1,6 @@ -export interface WorktreePRInfo { - number: number; - url: string; - title: string; - state: string; - createdAt: string; -} +// Re-export shared types from @automaker/types +export type { PRState, WorktreePRInfo } from '@automaker/types'; +import type { PRState, WorktreePRInfo } from '@automaker/types'; export interface WorktreeInfo { path: string; @@ -43,7 +39,8 @@ export interface PRInfo { number: number; title: string; url: string; - state: string; + /** PR state: OPEN, MERGED, or CLOSED */ + state: PRState; author: string; body: string; comments: Array<{ diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a0145782..4a8c6af1 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -292,3 +292,7 @@ export type { EventReplayHookResult, } from './event-history.js'; export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js'; + +// Worktree and PR types +export type { PRState, WorktreePRInfo } from './worktree.js'; +export { PR_STATES, validatePRState } from './worktree.js'; diff --git a/libs/types/src/worktree.ts b/libs/types/src/worktree.ts new file mode 100644 index 00000000..b81a075d --- /dev/null +++ b/libs/types/src/worktree.ts @@ -0,0 +1,32 @@ +/** + * Worktree and PR-related types + * Shared across server and UI components + */ + +/** GitHub PR states as returned by the GitHub API (uppercase) */ +export type PRState = 'OPEN' | 'MERGED' | 'CLOSED'; + +/** Valid PR states for validation */ +export const PR_STATES: readonly PRState[] = ['OPEN', 'MERGED', 'CLOSED'] as const; + +/** + * Validates a PR state value from external APIs (e.g., GitHub CLI). + * Returns the validated state if it matches a known PRState, otherwise returns 'OPEN' as default. + * This is safer than type assertions as it handles unexpected values from external APIs. + * + * @param state - The state string to validate (can be any string) + * @returns A valid PRState value + */ +export function validatePRState(state: string | undefined | null): PRState { + return PR_STATES.find((s) => s === state) ?? 'OPEN'; +} + +/** PR information stored in worktree metadata */ +export interface WorktreePRInfo { + number: number; + url: string; + title: string; + /** PR state: OPEN, MERGED, or CLOSED */ + state: PRState; + createdAt: string; +}