diff --git a/cmd/work.go b/cmd/work.go index 2ec382b2..b0aca360 100644 --- a/cmd/work.go +++ b/cmd/work.go @@ -12,12 +12,8 @@ import ( "github.com/newhook/co/internal/control" "github.com/newhook/co/internal/db" "github.com/newhook/co/internal/git" - "github.com/newhook/co/internal/mise" - "github.com/newhook/co/internal/names" "github.com/newhook/co/internal/project" - cosignal "github.com/newhook/co/internal/signal" "github.com/newhook/co/internal/work" - "github.com/newhook/co/internal/worktree" "github.com/spf13/cobra" ) @@ -214,7 +210,6 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { mainRepoPath := proj.MainRepoPath() gitOps := git.NewOperations() - wtOps := worktree.NewOperations() beadID := args[0] // Expand the bead (handles epics and transitive deps) @@ -245,7 +240,6 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { // Determine branch name var branchName string var useExistingBranch bool - var branchExistsOnRemote bool if flagFromBranch != "" { // Use an existing branch @@ -260,7 +254,6 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { if !existsLocal && !existsRemote { return fmt.Errorf("branch %s does not exist locally or on remote", branchName) } - branchExistsOnRemote = existsRemote } else if flagBranchName != "" { // Use provided branch name branchName = flagBranchName @@ -281,101 +274,25 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { } } - // Generate work ID - workID, err := proj.DB.GenerateWorkID(ctx, branchName, proj.Config.Project.Name) + // Create work asynchronously (control plane handles worktree creation, git push, orchestrator spawn) + result, err := work.CreateWorkAsyncWithOptions(ctx, proj, work.CreateWorkAsyncOptions{ + BranchName: branchName, + BaseBranch: baseBranch, + RootIssueID: beadID, + Auto: flagAutoRun, + UseExistingBranch: useExistingBranch, + BeadIDs: expandedIssueIDs, + }) if err != nil { - return fmt.Errorf("failed to generate work ID: %w", err) + return fmt.Errorf("failed to create work: %w", err) } - fmt.Printf("Work ID: %s\n", workID) - // Block signals during critical worktree creation - cosignal.BlockSignals() - defer cosignal.UnblockSignals() - - // Create work subdirectory - workDir := filepath.Join(proj.Root, workID) - if err := os.Mkdir(workDir, 0755); err != nil { - return fmt.Errorf("failed to create work directory: %w", err) + fmt.Printf("\nCreated work: %s\n", result.WorkID) + if result.WorkerName != "" { + fmt.Printf("Worker: %s\n", result.WorkerName) } - - // Create git worktree inside work directory - worktreePath := filepath.Join(workDir, "tree") - - if useExistingBranch { - // For existing branches, fetch the branch first - if err := gitOps.FetchBranch(ctx, mainRepoPath, branchName); err != nil { - // Ignore fetch errors - branch might only exist locally - fmt.Printf("Note: Could not fetch branch %s from origin (may only exist locally)\n", branchName) - } - - // Create worktree from existing branch - if err := wtOps.CreateFromExisting(ctx, mainRepoPath, worktreePath, branchName); err != nil { - os.RemoveAll(workDir) - return err - } - - // Only push if branch doesn't exist on remote yet - if !branchExistsOnRemote { - if err := gitOps.PushSetUpstream(ctx, branchName, worktreePath); err != nil { - _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) - os.RemoveAll(workDir) - return err - } - } - } else { - // Fetch latest from origin for the base branch - if err := gitOps.FetchBranch(ctx, mainRepoPath, baseBranch); err != nil { - os.RemoveAll(workDir) - return fmt.Errorf("failed to fetch base branch: %w", err) - } - - // Create worktree with new branch based on origin/ - if err := wtOps.Create(ctx, mainRepoPath, worktreePath, branchName, "origin/"+baseBranch); err != nil { - os.RemoveAll(workDir) - return err - } - - // Push branch and set upstream - if err := gitOps.PushSetUpstream(ctx, branchName, worktreePath); err != nil { - _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) - os.RemoveAll(workDir) - return err - } - } - - // Initialize mise in worktree if needed - if err := mise.Initialize(worktreePath); err != nil { - fmt.Printf("Warning: mise initialization failed: %v\n", err) - } - - // Get a human-readable name for this worker - workerName, err := names.GetNextAvailableName(ctx, proj.DB.DB) - if err != nil { - fmt.Printf("Warning: failed to get worker name: %v\n", err) - } - - // Create work record in database with the root issue ID (the original bead that was expanded) - if err := proj.DB.CreateWork(ctx, workID, workerName, worktreePath, branchName, baseBranch, beadID, flagAutoRun); err != nil { - _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) - os.RemoveAll(workDir) - return fmt.Errorf("failed to create work record: %w", err) - } - - // Add beads to work_beads - if err := work.AddBeadsToWorkInternal(ctx, proj, workID, expandedIssueIDs); err != nil { - _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) - os.RemoveAll(workDir) - return fmt.Errorf("failed to add beads to work: %w", err) - } - - fmt.Printf("\nCreated work: %s\n", workID) - if workerName != "" { - fmt.Printf("Worker: %s\n", workerName) - } - fmt.Printf("Directory: %s\n", workDir) - fmt.Printf("Worktree: %s\n", worktreePath) - fmt.Printf("Branch: %s\n", branchName) - fmt.Printf("Base Branch: %s\n", baseBranch) + fmt.Printf("Branch: %s\n", result.BranchName) + fmt.Printf("Base Branch: %s\n", result.BaseBranch) // Display beads fmt.Printf("\nBeads (%d):\n", len(groupIssues)) @@ -392,45 +309,17 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { printSessionCreatedNotification(sessionResult.SessionName) } - // If --auto, run the full automated workflow - if flagAutoRun { - fmt.Println("\nRunning automated workflow...") - result, err := work.RunWorkAuto(ctx, proj, workID, os.Stdout) - if err != nil { - return fmt.Errorf("failed to run automated workflow: %w", err) - } - if result.OrchestratorSpawned { - fmt.Println("Orchestrator spawned in zellij tab.") - } - // Ensure control plane is running (handles scheduled tasks like PR feedback polling) - // Note: InitializeSession spawns control plane for new sessions, but we call - // EnsureControlPlane for existing sessions that might have a dead control plane - if err := control.EnsureControlPlane(ctx, proj); err != nil { - fmt.Printf("Warning: failed to ensure control plane: %v\n", err) - } - fmt.Println("Switch to the zellij session to monitor progress.") - return nil - } - - // Spawn the orchestrator for this work - fmt.Println("\nSpawning orchestrator...") - if err := claude.SpawnWorkOrchestrator(ctx, workID, proj.Config.Project.Name, worktreePath, workerName, os.Stdout); err != nil { - fmt.Printf("Warning: failed to spawn orchestrator: %v\n", err) - fmt.Println("You can start it manually with: co run") - } else { - fmt.Println("Orchestrator is running in zellij tab.") - } - - // Ensure control plane is running (handles scheduled tasks like PR feedback polling) - // Note: InitializeSession spawns control plane for new sessions, but we call - // EnsureControlPlane for existing sessions that might have a dead control plane + // Ensure control plane is running (handles worktree creation, orchestrator spawning, etc.) if err := control.EnsureControlPlane(ctx, proj); err != nil { fmt.Printf("Warning: failed to ensure control plane: %v\n", err) } - fmt.Printf("\nNext steps:\n") - fmt.Printf(" cd %s\n", workID) - fmt.Printf(" co run # Execute tasks\n") + if flagAutoRun { + fmt.Println("\nAutomated workflow will start once the control plane creates the worktree.") + } + + fmt.Printf("\nThe control plane will create the worktree and start the orchestrator.\n") + fmt.Printf("Switch to the zellij session to monitor progress.\n") return nil } diff --git a/cmd/work_import_pr.go b/cmd/work_import_pr.go new file mode 100644 index 00000000..668b202d --- /dev/null +++ b/cmd/work_import_pr.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "fmt" + + "github.com/newhook/co/internal/control" + "github.com/newhook/co/internal/github" + "github.com/newhook/co/internal/project" + "github.com/newhook/co/internal/work" + "github.com/spf13/cobra" +) + +var workImportPRCmd = &cobra.Command{ + Use: "import-pr ", + Short: "Import a PR into a work unit", + Long: `Create a work unit from an existing GitHub pull request. + +This command fetches the PR's branch, creates a worktree, and sets up the work +for further development or review. The PR's branch becomes the work's feature branch. + +A bead is automatically created from the PR metadata to track the work in the beads system. + +Examples: + co work import-pr https://github.com/owner/repo/pull/123 + co work import-pr https://github.com/owner/repo/pull/123 --branch custom-branch-name`, + Args: cobra.ExactArgs(1), + RunE: runWorkImportPR, +} + +var flagImportPRBranch string + +func init() { + workImportPRCmd.Flags().StringVar(&flagImportPRBranch, "branch", "", "override the local branch name (default: use PR's branch name)") + workCmd.AddCommand(workImportPRCmd) +} + +func runWorkImportPR(cmd *cobra.Command, args []string) error { + ctx := GetContext() + + // Find project + proj, err := project.Find(ctx, "") + if err != nil { + return err + } + defer proj.Close() + + prURL := args[0] + + // Create GitHub client and PR importer + ghClient := github.NewClient() + importer := work.NewPRImporter(ghClient) + + // Fetch PR metadata first (user needs to see PR info) + fmt.Printf("Fetching PR metadata from %s...\n", prURL) + metadata, err := importer.FetchPRMetadata(ctx, prURL, "") + if err != nil { + return fmt.Errorf("failed to fetch PR metadata: %w", err) + } + + fmt.Printf("PR #%d: %s\n", metadata.Number, metadata.Title) + fmt.Printf("Author: %s\n", metadata.Author) + fmt.Printf("State: %s\n", metadata.State) + fmt.Printf("Branch: %s -> %s\n", metadata.HeadRefName, metadata.BaseRefName) + + // Check if PR is still open + if metadata.State != "OPEN" { + fmt.Printf("Warning: PR is %s\n", metadata.State) + } + + // Determine branch name + branchName := flagImportPRBranch + if branchName == "" { + branchName = metadata.HeadRefName + } + + // Create a bead from PR metadata (user needs feedback on bead creation) + fmt.Printf("\nCreating bead from PR metadata...\n") + beadResult, err := importer.CreateBeadFromPR(ctx, metadata, &work.CreateBeadOptions{ + BeadsDir: proj.BeadsPath(), + SkipIfExists: true, + }) + if err != nil { + return fmt.Errorf("failed to create bead: %w", err) + } + if beadResult.Created { + fmt.Printf("Created bead: %s\n", beadResult.BeadID) + } else { + fmt.Printf("Bead already exists: %s (%s)\n", beadResult.BeadID, beadResult.SkipReason) + } + rootIssueID := beadResult.BeadID + + // Schedule PR import via control plane (handles worktree, git, mise) + fmt.Printf("\nScheduling PR import...\n") + result, err := work.ImportPRAsync(ctx, proj, work.ImportPRAsyncOptions{ + PRURL: prURL, + BranchName: branchName, + RootIssueID: rootIssueID, + }) + if err != nil { + return fmt.Errorf("failed to schedule PR import: %w", err) + } + + fmt.Printf("\nCreated work: %s\n", result.WorkID) + if result.WorkerName != "" { + fmt.Printf("Worker: %s\n", result.WorkerName) + } + fmt.Printf("Branch: %s\n", result.BranchName) + fmt.Printf("PR URL: %s\n", prURL) + fmt.Printf("\nWorktree setup is in progress via control plane.\n") + + // Initialize zellij session and spawn control plane if new session + sessionResult, err := control.InitializeSession(ctx, proj) + if err != nil { + fmt.Printf("Warning: failed to initialize zellij session: %v\n", err) + } else if sessionResult.SessionCreated { + // Display notification for new session + printSessionCreatedNotification(sessionResult.SessionName) + } + + // Ensure control plane is running to process the import task + if err := control.EnsureControlPlane(ctx, proj); err != nil { + fmt.Printf("Warning: failed to ensure control plane: %v\n", err) + } + + fmt.Printf("\nNext steps:\n") + fmt.Printf(" cd %s\n", result.WorkID) + fmt.Printf(" co run # Execute tasks (after worktree is ready)\n") + + return nil +} diff --git a/internal/control/handler_destroy_worktree.go b/internal/control/handler_destroy_worktree.go index 540e9dd8..fa045a3a 100644 --- a/internal/control/handler_destroy_worktree.go +++ b/internal/control/handler_destroy_worktree.go @@ -2,17 +2,12 @@ package control import ( "context" - "fmt" "io" - "os" - "path/filepath" - "github.com/newhook/co/internal/beads" - "github.com/newhook/co/internal/claude" "github.com/newhook/co/internal/db" "github.com/newhook/co/internal/logging" "github.com/newhook/co/internal/project" - "github.com/newhook/co/internal/worktree" + "github.com/newhook/co/internal/work" ) // HandleDestroyWorktreeTask handles a scheduled worktree destruction task @@ -23,56 +18,20 @@ func HandleDestroyWorktreeTask(ctx context.Context, proj *project.Project, task "work_id", workID, "attempt", task.AttemptCount+1) - // Get work details - work, err := proj.DB.GetWork(ctx, workID) + // Check if work still exists + workRecord, err := proj.DB.GetWork(ctx, workID) if err != nil { - return fmt.Errorf("failed to get work: %w", err) + return err } - if work == nil { + if workRecord == nil { // Work was already deleted - nothing to do logging.Info("Work not found, task will be marked completed", "work_id", workID) return nil } - // Close the root issue if it exists - if work.RootIssueID != "" { - logging.Info("Closing root issue", "work_id", workID, "root_issue_id", work.RootIssueID) - if err := beads.Close(ctx, work.RootIssueID, proj.BeadsPath()); err != nil { - // Warn but continue - issue might already be closed or deleted - logging.Warn("failed to close root issue", "error", err, "root_issue_id", work.RootIssueID) - } - } - - // Terminate any running zellij tabs (orchestrator, task, console, and claude tabs) for this work - // Only if configured to do so (defaults to true) - if proj.Config.Zellij.ShouldKillTabsOnDestroy() { - if err := claude.TerminateWorkTabs(ctx, workID, proj.Config.Project.Name, io.Discard); err != nil { - logging.Warn("failed to terminate work tabs", "error", err, "work_id", workID) - // Continue with destruction even if tab termination fails - } - } - - // Remove git worktree if it exists - // Note: We continue even if this fails, because the worktree might not exist in git's records - // but the directory might still exist. The os.RemoveAll below will clean up the directory. - if work.WorktreePath != "" { - logging.Info("Removing git worktree", "work_id", workID, "path", work.WorktreePath) - if err := worktree.NewOperations().RemoveForce(ctx, proj.MainRepoPath(), work.WorktreePath); err != nil { - logging.Warn("failed to remove git worktree (continuing with directory removal)", "error", err, "work_id", workID) - } - } - - // Remove work directory - workDir := filepath.Join(proj.Root, workID) - logging.Info("Removing work directory", "work_id", workID, "path", workDir) - if err := os.RemoveAll(workDir); err != nil { - // This is a retriable error - return fmt.Errorf("failed to remove work directory: %w", err) - } - - // Delete work from database (also deletes associated tasks and relationships) - if err := proj.DB.DeleteWork(ctx, workID); err != nil { - return fmt.Errorf("failed to delete work from database: %w", err) + // Delegate to the shared DestroyWork function + if err := work.DestroyWork(ctx, proj, workID, io.Discard); err != nil { + return err } logging.Info("Worktree destroyed successfully", "work_id", workID) diff --git a/internal/control/handler_import_pr.go b/internal/control/handler_import_pr.go new file mode 100644 index 00000000..63921104 --- /dev/null +++ b/internal/control/handler_import_pr.go @@ -0,0 +1,127 @@ +package control + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/newhook/co/internal/db" + "github.com/newhook/co/internal/git" + "github.com/newhook/co/internal/github" + "github.com/newhook/co/internal/logging" + "github.com/newhook/co/internal/mise" + "github.com/newhook/co/internal/project" + "github.com/newhook/co/internal/work" + "github.com/newhook/co/internal/worktree" +) + +// HandleImportPRTask handles a scheduled PR import task. +// This sets up a worktree from an existing GitHub PR. +func HandleImportPRTask(ctx context.Context, proj *project.Project, task *db.ScheduledTask) error { + workID := task.WorkID + prURL := task.Metadata["pr_url"] + branchName := task.Metadata["branch"] + + logging.Info("Importing PR into work", + "work_id", workID, + "pr_url", prURL, + "branch", branchName, + "attempt", task.AttemptCount+1) + + // Get work details + workRecord, err := proj.DB.GetWork(ctx, workID) + if err != nil { + return fmt.Errorf("failed to get work: %w", err) + } + if workRecord == nil { + // Work was deleted - nothing to do + logging.Info("Work not found, task will be marked completed", "work_id", workID) + return nil + } + + // If worktree path is already set and exists, skip creation + if workRecord.WorktreePath != "" && worktree.NewOperations().ExistsPath(workRecord.WorktreePath) { + logging.Info("Worktree already exists, skipping creation", "work_id", workID, "path", workRecord.WorktreePath) + return nil + } + + mainRepoPath := proj.MainRepoPath() + gitOps := git.NewOperations() + wtOps := worktree.NewOperations() + + // Create work subdirectory + workDir := filepath.Join(proj.Root, workID) + if err := os.Mkdir(workDir, 0750); err != nil && !os.IsExist(err) { + return fmt.Errorf("failed to create work directory: %w", err) + } + + // Set up worktree from PR using the PR importer + importer := work.NewPRImporter(github.NewClient()) + _, worktreePath, err := importer.SetupWorktreeFromPR(ctx, mainRepoPath, prURL, "", workDir, branchName) + if err != nil { + _ = os.RemoveAll(workDir) + return fmt.Errorf("failed to set up worktree from PR: %w", err) + } + + // Set up upstream tracking + if err := gitOps.PushSetUpstream(ctx, branchName, worktreePath); err != nil { + _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) + _ = os.RemoveAll(workDir) + return fmt.Errorf("failed to set upstream: %w", err) + } + + // Initialize mise if configured (output discarded) + if err := mise.InitializeWithOutput(worktreePath, io.Discard); err != nil { + logging.Warn("mise initialization failed", "error", err) + // Non-fatal, continue + } + + // Update work with worktree path + if err := proj.DB.UpdateWorkWorktreePath(ctx, workID, worktreePath); err != nil { + return fmt.Errorf("failed to update work worktree path: %w", err) + } + + // Add root issue to work_beads if set and not already added + // (ImportPRAsync now adds beads immediately, so this is a fallback) + if workRecord.RootIssueID != "" { + workBeads, err := proj.DB.GetWorkBeads(ctx, workID) + if err != nil { + logging.Warn("failed to get work beads", "error", err) + } else { + beadExists := false + for _, wb := range workBeads { + if wb.BeadID == workRecord.RootIssueID { + beadExists = true + break + } + } + if !beadExists { + if err := work.AddBeadsToWorkInternal(ctx, proj, workID, []string{workRecord.RootIssueID}); err != nil { + logging.Warn("failed to add bead to work", "error", err, "bead_id", workRecord.RootIssueID) + } + } + } + } + + // Set PR URL on the work and schedule feedback polling + prFeedbackInterval := proj.Config.Scheduler.GetPRFeedbackInterval() + commentResolutionInterval := proj.Config.Scheduler.GetCommentResolutionInterval() + if err := proj.DB.SetWorkPRURLAndScheduleFeedback(ctx, workID, prURL, prFeedbackInterval, commentResolutionInterval); err != nil { + logging.Warn("failed to set PR URL on work", "error", err) + } + + logging.Info("PR imported successfully", "work_id", workID, "worktree", worktreePath) + + // Schedule orchestrator spawn task (but it won't auto-start since auto=false) + _, err = proj.DB.ScheduleTask(ctx, workID, db.TaskTypeSpawnOrchestrator, time.Now(), map[string]string{ + "worker_name": workRecord.Name, + }) + if err != nil { + logging.Warn("failed to schedule orchestrator spawn", "error", err, "work_id", workID) + } + + return nil +} diff --git a/internal/control/loop.go b/internal/control/loop.go index 5a9c8fab..8d7c89e8 100644 --- a/internal/control/loop.go +++ b/internal/control/loop.go @@ -85,6 +85,7 @@ type TaskHandler func(ctx context.Context, proj *project.Project, task *db.Sched // taskHandlers maps task types to their handler functions. var taskHandlers = map[string]TaskHandler{ db.TaskTypeCreateWorktree: HandleCreateWorktreeTask, + db.TaskTypeImportPR: HandleImportPRTask, db.TaskTypeSpawnOrchestrator: HandleSpawnOrchestratorTask, db.TaskTypePRFeedback: HandlePRFeedbackTask, db.TaskTypeCommentResolution: HandleCommentResolutionTask, diff --git a/internal/control/spawn.go b/internal/control/spawn.go index 7b79b8f2..a3093ae2 100644 --- a/internal/control/spawn.go +++ b/internal/control/spawn.go @@ -38,35 +38,12 @@ func SpawnControlPlane(ctx context.Context, proj *project.Project) error { return nil } - // Build the control plane command with project root for identification - controlPlaneCommand := fmt.Sprintf("co control --root %s", projectRoot) - - // Create a new tab - logging.Debug("SpawnControlPlane creating tab", "tabName", TabName) - if err := zc.CreateTab(ctx, sessionName, TabName, projectRoot); err != nil { - logging.Error("SpawnControlPlane CreateTab failed", "error", err) - return fmt.Errorf("failed to create tab: %w", err) - } - logging.Debug("SpawnControlPlane CreateTab completed") - - // Switch to the tab if we're inside the session - // Skip if not attached - go-to-tab-name blocks on detached sessions - // The newly created tab is already focused after creation - if zellij.IsInsideTargetSession(sessionName) { - logging.Debug("SpawnControlPlane switching to tab (inside session)") - if err := zc.SwitchToTab(ctx, sessionName, TabName); err != nil { - logging.Error("SpawnControlPlane SwitchToTab failed", "error", err) - return fmt.Errorf("switching to tab failed: %w", err) - } - logging.Debug("SpawnControlPlane SwitchToTab completed") - } else { - logging.Debug("SpawnControlPlane skipping SwitchToTab (not inside session)") - } - - logging.Debug("SpawnControlPlane executing command", "command", controlPlaneCommand) - if err := zc.ExecuteCommand(ctx, sessionName, controlPlaneCommand); err != nil { - logging.Error("SpawnControlPlane ExecuteCommand failed", "error", err) - return fmt.Errorf("failed to execute control plane command: %w", err) + // Create control plane tab with command using layout + // This avoids race conditions from creating a tab then executing a command + logging.Debug("SpawnControlPlane creating tab with command", "tabName", TabName) + if err := zc.CreateTabWithCommand(ctx, sessionName, TabName, projectRoot, "co", []string{"control", "--root", projectRoot}, "control"); err != nil { + logging.Error("SpawnControlPlane CreateTabWithCommand failed", "error", err) + return fmt.Errorf("failed to create control plane tab: %w", err) } logging.Debug("SpawnControlPlane completed successfully") diff --git a/internal/db/scheduler.go b/internal/db/scheduler.go index 6b98206c..47320642 100644 --- a/internal/db/scheduler.go +++ b/internal/db/scheduler.go @@ -36,6 +36,7 @@ const ( TaskTypeGitHubComment = "github_comment" TaskTypeGitHubResolveThread = "github_resolve_thread" TaskTypeCreateWorktree = "create_worktree" + TaskTypeImportPR = "import_pr" TaskTypeSpawnOrchestrator = "spawn_orchestrator" TaskTypeDestroyWorktree = "destroy_worktree" ) diff --git a/internal/git/git.go b/internal/git/git.go index 87ce51f0..346c5741 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -18,6 +18,9 @@ type Operations interface { Clone(ctx context.Context, source, dest string) error // FetchBranch fetches a specific branch from origin. FetchBranch(ctx context.Context, repoPath, branch string) error + // FetchPRRef fetches a PR's head ref and creates/updates a local branch. + // This handles both same-repo PRs and fork PRs via GitHub's pull//head refs. + FetchPRRef(ctx context.Context, repoPath string, prNumber int, localBranch string) error // BranchExists checks if a branch exists locally or remotely. BranchExists(ctx context.Context, repoPath, branchName string) bool // ValidateExistingBranch checks if a branch exists locally, remotely, or both. @@ -80,6 +83,21 @@ func (c *CLIOperations) FetchBranch(ctx context.Context, repoPath, branch string return nil } +// FetchPRRef implements Operations.FetchPRRef. +// This fetches a PR's head ref using GitHub's special refs/pull//head ref +// and creates or updates a local branch pointing to it. +func (c *CLIOperations) FetchPRRef(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + // Fetch the PR's head ref from origin + // GitHub makes PR branches available at refs/pull//head + refSpec := fmt.Sprintf("refs/pull/%d/head:%s", prNumber, localBranch) + cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec) + cmd.Dir = repoPath + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to fetch PR #%d: %w\n%s", prNumber, err, output) + } + return nil +} + // BranchExists implements Operations.BranchExists. func (c *CLIOperations) BranchExists(ctx context.Context, repoPath, branchName string) bool { // Check local branches diff --git a/internal/github/client.go b/internal/github/client.go index c8f8ca4c..baff969f 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -18,6 +18,8 @@ import ( type ClientInterface interface { // GetPRStatus fetches comprehensive PR status information. GetPRStatus(ctx context.Context, prURL string) (*PRStatus, error) + // GetPRMetadata fetches metadata for a PR suitable for import. + GetPRMetadata(ctx context.Context, prURLOrNumber string, repo string) (*PRMetadata, error) // PostPRComment posts a comment on a PR issue. PostPRComment(ctx context.Context, prURL string, body string) error // PostReplyToComment posts a reply to a specific comment on a PR. @@ -123,6 +125,26 @@ type Step struct { Number int `json:"number"` } +// PRMetadata contains comprehensive PR metadata for import operations. +type PRMetadata struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` // OPEN, CLOSED, MERGED + HeadRefName string `json:"headRefName"` // Branch name + BaseRefName string `json:"baseRefName"` // Target branch (e.g., main) + HeadRefOid string `json:"headRefOid"` // Head commit SHA + Author string `json:"author"` + Labels []string `json:"labels"` + IsDraft bool `json:"isDraft"` + Merged bool `json:"merged"` + MergedAt time.Time `json:"mergedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Repo string `json:"repo"` // owner/repo format +} + // GetPRStatus fetches comprehensive PR status information. func (c *Client) GetPRStatus(ctx context.Context, prURL string) (*PRStatus, error) { logging.Info("fetching PR status", "prURL", prURL) @@ -182,6 +204,112 @@ func (c *Client) GetPRStatus(ctx context.Context, prURL string) (*PRStatus, erro return status, nil } +// GetPRMetadata fetches comprehensive PR metadata for import operations. +// prURLOrNumber can be a full PR URL or just the PR number. +// If prURLOrNumber is a URL, repo is ignored. +// If prURLOrNumber is a number, repo must be provided in owner/repo format. +func (c *Client) GetPRMetadata(ctx context.Context, prURLOrNumber string, repo string) (*PRMetadata, error) { + logging.Info("fetching PR metadata", "prURLOrNumber", prURLOrNumber, "repo", repo) + + var prNumber, repoName string + var err error + + // Check if it's a URL or a number + if strings.HasPrefix(prURLOrNumber, "https://") || strings.HasPrefix(prURLOrNumber, "http://") { + prNumber, repoName, err = parsePRURL(prURLOrNumber) + if err != nil { + logging.Error("invalid PR URL", "error", err, "prURL", prURLOrNumber) + return nil, fmt.Errorf("invalid PR URL: %w", err) + } + } else { + // Assume it's a PR number + prNumber = prURLOrNumber + repoName = repo + if repoName == "" { + return nil, fmt.Errorf("repo must be provided when using PR number instead of URL") + } + } + + logging.Debug("parsed PR reference", "prNumber", prNumber, "repo", repoName) + + // Fetch PR metadata using gh CLI + cmd := exec.CommandContext(ctx, "gh", "pr", "view", prNumber, + "--repo", repoName, + "--json", "number,url,title,body,state,headRefName,baseRefName,headRefOid,author,labels,isDraft,mergedAt,createdAt,updatedAt") + + output, err := cmd.Output() + if err != nil { + logging.Error("gh pr view failed", "error", err, "repo", repoName, "prNumber", prNumber) + return nil, fmt.Errorf("failed to fetch PR metadata: %w", err) + } + + logging.Debug("gh pr view response", "output", string(output)) + + var prInfo struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + HeadRefOid string `json:"headRefOid"` + Author struct { + Login string `json:"login"` + } `json:"author"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + IsDraft bool `json:"isDraft"` + MergedAt *time.Time `json:"mergedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + } + + if err := json.Unmarshal(output, &prInfo); err != nil { + logging.Error("failed to parse PR metadata", "error", err, "output", string(output)) + return nil, fmt.Errorf("failed to parse PR metadata: %w", err) + } + + metadata := &PRMetadata{ + Number: prInfo.Number, + URL: prInfo.URL, + Title: prInfo.Title, + Body: prInfo.Body, + State: prInfo.State, + HeadRefName: prInfo.HeadRefName, + BaseRefName: prInfo.BaseRefName, + HeadRefOid: prInfo.HeadRefOid, + Author: prInfo.Author.Login, + IsDraft: prInfo.IsDraft, + CreatedAt: prInfo.CreatedAt, + UpdatedAt: prInfo.UpdatedAt, + Repo: repoName, + } + + // Extract label names + for _, label := range prInfo.Labels { + metadata.Labels = append(metadata.Labels, label.Name) + } + + // Determine merged status + if prInfo.MergedAt != nil { + metadata.Merged = true + metadata.MergedAt = *prInfo.MergedAt + } + + logging.Info("successfully fetched PR metadata", + "prNumber", metadata.Number, + "title", metadata.Title, + "state", metadata.State, + "branch", metadata.HeadRefName, + "baseBranch", metadata.BaseRefName, + "author", metadata.Author, + "labels", len(metadata.Labels)) + + return metadata, nil +} + // fetchPRInfo fetches basic PR information. func (c *Client) fetchPRInfo(ctx context.Context, repo, prNumber string, status *PRStatus) error { logging.Debug("fetching PR info", "repo", repo, "prNumber", prNumber) diff --git a/internal/tui/tui_panel_pr_import.go b/internal/tui/tui_panel_pr_import.go new file mode 100644 index 00000000..99f447c0 --- /dev/null +++ b/internal/tui/tui_panel_pr_import.go @@ -0,0 +1,333 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/newhook/co/internal/github" +) + +// PRImportAction represents an action result from the panel +type PRImportAction int + +const ( + PRImportActionNone PRImportAction = iota + PRImportActionCancel + PRImportActionSubmit + PRImportActionPreview +) + +// PRImportResult contains form values when submitted +type PRImportResult struct { + PRURL string +} + +// PRImportPanel renders the PR import form. +type PRImportPanel struct { + // Dimensions + width int + height int + + // Focus state + focused bool + + // Form state + input textinput.Model + focusIdx int + importing bool + + // Preview state + previewing bool + prMetadata *github.PRMetadata + previewErr error + + // Mouse state + hoveredButton string +} + +// NewPRImportPanel creates a new PRImportPanel +func NewPRImportPanel() *PRImportPanel { + input := textinput.New() + input.Placeholder = "https://github.com/owner/repo/pull/123" + input.CharLimit = 500 + input.Width = 60 + + return &PRImportPanel{ + width: 60, + height: 20, + input: input, + } +} + +// Init initializes the panel and returns any initial command +func (p *PRImportPanel) Init() tea.Cmd { + return textinput.Blink +} + +// Reset resets the form to initial state +func (p *PRImportPanel) Reset() { + p.input.Reset() + p.input.Focus() + p.focusIdx = 0 + p.importing = false + p.previewing = false + p.prMetadata = nil + p.previewErr = nil +} + +// Update handles key events and returns an action +func (p *PRImportPanel) Update(msg tea.KeyMsg) (tea.Cmd, PRImportAction) { + // Check escape/cancel keys + if msg.Type == tea.KeyEsc || msg.String() == "esc" { + p.input.Blur() + return nil, PRImportActionCancel + } + + // Tab cycles between elements: input(0) -> Import(1) -> Cancel(2) + if msg.Type == tea.KeyTab || msg.String() == "tab" { + // Leave text input focus before switching + if p.focusIdx == 0 { + p.input.Blur() + } + + p.focusIdx = (p.focusIdx + 1) % 3 + + // Enter new focus + if p.focusIdx == 0 { + p.input.Focus() + } + return nil, PRImportActionNone + } + + // Shift+Tab goes backwards + if msg.Type == tea.KeyShiftTab { + // Leave text input focus before switching + if p.focusIdx == 0 { + p.input.Blur() + } + + p.focusIdx-- + if p.focusIdx < 0 { + p.focusIdx = 2 + } + + // Enter new focus + if p.focusIdx == 0 { + p.input.Focus() + } + return nil, PRImportActionNone + } + + // Enter submits from input or activates buttons + if msg.String() == "enter" { + prURL := strings.TrimSpace(p.input.Value()) + if prURL == "" { + return nil, PRImportActionNone + } + + switch p.focusIdx { + case 0: // Input field + // If preview not loaded yet, load it; otherwise import + if p.prMetadata == nil && !p.previewing { + return nil, PRImportActionPreview + } + return nil, PRImportActionSubmit + case 1: // Import button + return nil, PRImportActionSubmit + case 2: // Cancel button + p.input.Blur() + return nil, PRImportActionCancel + } + } + + // Handle input based on focused element + if p.focusIdx == 0 { + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return cmd, PRImportActionNone + } + + return nil, PRImportActionNone +} + +// GetResult returns the current form values +func (p *PRImportPanel) GetResult() PRImportResult { + return PRImportResult{ + PRURL: strings.TrimSpace(p.input.Value()), + } +} + +// SetImporting sets the importing state +func (p *PRImportPanel) SetImporting(importing bool) { + p.importing = importing +} + +// SetPreviewing sets the previewing state +func (p *PRImportPanel) SetPreviewing(previewing bool) { + p.previewing = previewing +} + +// SetPreviewResult sets the PR metadata preview result +func (p *PRImportPanel) SetPreviewResult(metadata *github.PRMetadata, err error) { + p.prMetadata = metadata + p.previewErr = err + p.previewing = false +} + +// Blur removes focus from the input +func (p *PRImportPanel) Blur() { + p.input.Blur() +} + +// SetSize updates the panel dimensions +func (p *PRImportPanel) SetSize(width, height int) { + p.width = width + p.height = height +} + +// SetFocus updates the focus state +func (p *PRImportPanel) SetFocus(focused bool) { + p.focused = focused +} + +// IsFocused returns whether the panel is focused +func (p *PRImportPanel) IsFocused() bool { + return p.focused +} + +// SetHoveredButton updates which button is hovered +func (p *PRImportPanel) SetHoveredButton(button string) { + p.hoveredButton = button +} + +// Render returns the PR import form content +func (p *PRImportPanel) Render() string { + var content strings.Builder + + // Adapt input width + inputWidth := p.width - 4 + if inputWidth < 20 { + inputWidth = 20 + } + p.input.Width = inputWidth + + // Show focus label with context-aware hint + prURLLabel := "PR URL:" + if p.focusIdx == 0 { + if p.prMetadata != nil { + prURLLabel = tuiValueStyle.Render("PR URL:") + " (Enter to import)" + } else { + prURLLabel = tuiValueStyle.Render("PR URL:") + " (Enter to load preview)" + } + } + + content.WriteString(tuiLabelStyle.Render("Import from GitHub PR")) + content.WriteString("\n\n") + content.WriteString(prURLLabel) + content.WriteString("\n") + content.WriteString(p.input.View()) + content.WriteString("\n\n") + + // Show PR preview if available + if p.previewing { + content.WriteString(tuiDimStyle.Render("Loading PR details...")) + content.WriteString("\n\n") + } else if p.previewErr != nil { + content.WriteString(tuiErrorStyle.Render(fmt.Sprintf("Error: %v", p.previewErr))) + content.WriteString("\n\n") + } else if p.prMetadata != nil { + content.WriteString(tuiLabelStyle.Render("PR Preview:")) + content.WriteString("\n") + content.WriteString(fmt.Sprintf(" #%d: %s\n", p.prMetadata.Number, tuiValueStyle.Render(p.prMetadata.Title))) + content.WriteString(fmt.Sprintf(" Author: %s\n", p.prMetadata.Author)) + content.WriteString(fmt.Sprintf(" State: %s\n", formatPRState(p.prMetadata.State))) + content.WriteString(fmt.Sprintf(" Branch: %s -> %s\n", p.prMetadata.HeadRefName, p.prMetadata.BaseRefName)) + if len(p.prMetadata.Labels) > 0 { + content.WriteString(fmt.Sprintf(" Labels: %s\n", strings.Join(p.prMetadata.Labels, ", "))) + } + content.WriteString("\n") + } + + // Render buttons (Import and Cancel only) + var importLabel, cancelLabel string + focusHint := "" + + if p.focusIdx == 1 { + importLabel = tuiValueStyle.Render("[Import]") + focusHint = tuiDimStyle.Render(" (press Enter)") + } else { + importLabel = styleButtonWithHover("Import", p.hoveredButton == "import") + } + + if p.focusIdx == 2 { + cancelLabel = tuiValueStyle.Render("[Cancel]") + focusHint = tuiDimStyle.Render(" (press Enter)") + } else { + cancelLabel = styleButtonWithHover("Cancel", p.hoveredButton == "cancel") + } + + content.WriteString(importLabel + " " + cancelLabel + focusHint) + content.WriteString("\n") + + if p.importing { + content.WriteString(tuiDimStyle.Render("Importing...")) + } else { + content.WriteString(tuiDimStyle.Render("[Tab] Next field [Esc] Cancel")) + } + + return content.String() +} + +// formatPRState formats the PR state with appropriate styling +func formatPRState(state string) string { + switch state { + case "OPEN": + return tuiSuccessStyle.Render("OPEN") + case "CLOSED": + return tuiErrorStyle.Render("CLOSED") + case "MERGED": + return lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Render("MERGED") + default: + return state + } +} + +// RenderWithPanel returns the panel with border styling +func (p *PRImportPanel) RenderWithPanel(contentHeight int) string { + panelContent := p.Render() + + panelStyle := tuiPanelStyle.Width(p.width).Height(contentHeight - 2) + if p.focused { + panelStyle = panelStyle.BorderForeground(lipgloss.Color("214")) + } + + result := panelStyle.Render(tuiTitleStyle.Render("Import PR") + "\n" + panelContent) + + // If the result is taller than expected (due to lipgloss wrapping), fix it + if lipgloss.Height(result) > contentHeight { + lines := strings.Split(result, "\n") + extraLines := len(lines) - contentHeight + if extraLines > 0 && len(lines) > 3 { + topBorder := lines[0] + titleLine := lines[1] + bottomBorder := lines[len(lines)-1] + contentLines := lines[2 : len(lines)-1] + keepContentLines := len(contentLines) - extraLines + if keepContentLines < 1 { + keepContentLines = 1 + } + if keepContentLines < len(contentLines) { + contentLines = contentLines[:keepContentLines] + } + lines = []string{topBorder, titleLine} + lines = append(lines, contentLines...) + lines = append(lines, bottomBorder) + result = strings.Join(lines, "\n") + } + } + + return result +} diff --git a/internal/tui/tui_plan.go b/internal/tui/tui_plan.go index b4ac1bee..4f412f08 100644 --- a/internal/tui/tui_plan.go +++ b/internal/tui/tui_plan.go @@ -63,6 +63,7 @@ type planModel struct { workDetails *WorkDetailsPanel workTabsBar *WorkTabsBar linearImportPanel *LinearImportPanel + prImportPanel *PRImportPanel beadFormPanel *BeadFormPanel createWorkPanel *CreateWorkPanel @@ -206,6 +207,7 @@ func newPlanModel(ctx context.Context, proj *project.Project) *planModel { m.workDetails = NewWorkDetailsPanel() m.workTabsBar = NewWorkTabsBar() m.linearImportPanel = NewLinearImportPanel() + m.prImportPanel = NewPRImportPanel() m.beadFormPanel = NewBeadFormPanel() m.createWorkPanel = NewCreateWorkPanel() @@ -828,6 +830,30 @@ func (m *planModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusIsError = false return m, nil + case prImportPreviewMsg: + m.prImportPanel.SetPreviewing(false) + m.prImportPanel.SetPreviewResult(msg.metadata, msg.err) + if msg.err != nil { + m.statusMessage = fmt.Sprintf("Preview failed: %v", msg.err) + m.statusIsError = true + } else { + m.statusMessage = fmt.Sprintf("PR #%d: %s", msg.metadata.Number, msg.metadata.Title) + m.statusIsError = false + } + return m, nil + + case prImportCompleteMsg: + m.prImportPanel.SetImporting(false) + m.viewMode = ViewNormal + if msg.err != nil { + m.statusMessage = fmt.Sprintf("PR import failed: %v", msg.err) + m.statusIsError = true + } else { + m.statusMessage = fmt.Sprintf("Imported PR into work %s", msg.workID) + m.statusIsError = false + } + return m, tea.Batch(m.refreshData(), m.loadWorkTiles(), clearStatusAfter(7*time.Second)) + case statusClearMsg: m.statusMessage = "" m.statusIsError = false @@ -1078,6 +1104,33 @@ func (m *planModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + return m, cmd + case ViewPRImportInline: + // Delegate to PR import panel and handle returned action + cmd, action := m.prImportPanel.Update(msg) + + switch action { + case PRImportActionCancel: + m.viewMode = ViewNormal + return m, cmd + + case PRImportActionPreview: + result := m.prImportPanel.GetResult() + if result.PRURL != "" { + m.prImportPanel.SetPreviewing(true) + return m, m.previewPR(result.PRURL) + } + return m, cmd + + case PRImportActionSubmit: + result := m.prImportPanel.GetResult() + if result.PRURL != "" { + m.prImportPanel.SetImporting(true) + return m, m.importPR(result.PRURL) + } + return m, cmd + } + return m, cmd case ViewDestroyConfirm: // Handle destroy confirmation dialog @@ -1458,6 +1511,12 @@ func (m *planModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.linearImportPanel.Reset() return m, m.linearImportPanel.Init() + case "I": + // Import GitHub PR inline + m.viewMode = ViewPRImportInline + m.prImportPanel.Reset() + return m, m.prImportPanel.Init() + case "A": // Add selected issue(s) to the focused work if m.focusedWorkID == "" { @@ -1607,6 +1666,11 @@ func (m *planModel) syncPanels() { m.linearImportPanel.SetFocus(m.activePanel == PanelRight && m.viewMode == ViewLinearImportInline) m.linearImportPanel.SetHoveredButton(m.hoveredDialogButton) + // Sync PR import panel + m.prImportPanel.SetSize(detailsWidth, m.height) + m.prImportPanel.SetFocus(m.activePanel == PanelRight && m.viewMode == ViewPRImportInline) + m.prImportPanel.SetHoveredButton(m.hoveredDialogButton) + // Sync bead form panel m.beadFormPanel.SetSize(detailsWidth, m.height) m.beadFormPanel.SetFocus(m.activePanel == PanelRight) @@ -1640,6 +1704,9 @@ func (m *planModel) View() string { case ViewLinearImportInline: // Inline import mode - render normal view with import form in details area // Fall through to normal rendering + case ViewPRImportInline: + // Inline PR import mode - render normal view with import form in details area + // Fall through to normal rendering case ViewHelp: return m.renderHelp() } diff --git a/internal/tui/tui_plan_data.go b/internal/tui/tui_plan_data.go index 31b2570a..41f3512d 100644 --- a/internal/tui/tui_plan_data.go +++ b/internal/tui/tui_plan_data.go @@ -7,8 +7,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/newhook/co/internal/beads" + "github.com/newhook/co/internal/control" "github.com/newhook/co/internal/db" + "github.com/newhook/co/internal/github" "github.com/newhook/co/internal/linear" + "github.com/newhook/co/internal/work" ) // refreshData creates a tea.Cmd that refreshes bead data @@ -413,3 +416,85 @@ func (m *planModel) importLinearIssue(issueIDsInput string) tea.Cmd { } } } + +// prImportCompleteMsg indicates a PR import completed +type prImportCompleteMsg struct { + workID string + prMetadata *github.PRMetadata + err error +} + +// prImportPreviewMsg indicates a PR preview was fetched +type prImportPreviewMsg struct { + metadata *github.PRMetadata + err error +} + +// previewPR fetches PR metadata for preview +func (m *planModel) previewPR(prURL string) tea.Cmd { + return func() tea.Msg { + ghClient := github.NewClient() + importer := work.NewPRImporter(ghClient) + + metadata, err := importer.FetchPRMetadata(m.ctx, prURL, "") + if err != nil { + return prImportPreviewMsg{err: fmt.Errorf("failed to fetch PR: %w", err)} + } + + return prImportPreviewMsg{metadata: metadata} + } +} + +// importPR imports a PR into a work unit asynchronously via the control plane. +func (m *planModel) importPR(prURL string) tea.Cmd { + return func() tea.Msg { + ghClient := github.NewClient() + importer := work.NewPRImporter(ghClient) + + // Fetch PR metadata first + metadata, err := importer.FetchPRMetadata(m.ctx, prURL, "") + if err != nil { + return prImportCompleteMsg{err: fmt.Errorf("failed to fetch PR: %w", err)} + } + + // Use the PR's branch name + branchName := metadata.HeadRefName + + // Create bead from PR metadata (required for work to function) + // This is done in the TUI because we need the bead ID before scheduling + var rootIssueID string + beadResult, err := importer.CreateBeadFromPR(m.ctx, metadata, &work.CreateBeadOptions{ + BeadsDir: m.proj.BeadsPath(), + SkipIfExists: true, + }) + if err != nil { + return prImportCompleteMsg{err: fmt.Errorf("failed to create bead: %w", err)} + } + rootIssueID = beadResult.BeadID + + // Schedule the PR import via the control plane + result, err := work.ImportPRAsync(m.ctx, m.proj, work.ImportPRAsyncOptions{ + PRURL: prURL, + BranchName: branchName, + RootIssueID: rootIssueID, + }) + if err != nil { + return prImportCompleteMsg{err: fmt.Errorf("failed to schedule PR import: %w", err)} + } + + // Ensure control plane is running to process the import task + if err := control.EnsureControlPlane(m.ctx, m.proj); err != nil { + // Non-fatal: task was scheduled but control plane might need manual start + return prImportCompleteMsg{ + workID: result.WorkID, + prMetadata: metadata, + err: fmt.Errorf("import scheduled but control plane failed: %w", err), + } + } + + return prImportCompleteMsg{ + workID: result.WorkID, + prMetadata: metadata, + } + } +} diff --git a/internal/tui/tui_plan_render.go b/internal/tui/tui_plan_render.go index f0e074ac..06bac146 100644 --- a/internal/tui/tui_plan_render.go +++ b/internal/tui/tui_plan_render.go @@ -48,6 +48,9 @@ func (m *planModel) renderFocusedWorkSplitView() string { case ViewLinearImportInline: m.linearImportPanel.SetSize(detailsWidth, planPanelHeight) detailsPanel = m.linearImportPanel.RenderWithPanel(planPanelHeight) + case ViewPRImportInline: + m.prImportPanel.SetSize(detailsWidth, planPanelHeight) + detailsPanel = m.prImportPanel.RenderWithPanel(planPanelHeight) case ViewCreateWork: m.createWorkPanel.SetSize(detailsWidth, planPanelHeight) detailsPanel = m.createWorkPanel.RenderWithPanel(planPanelHeight) @@ -83,6 +86,8 @@ func (m *planModel) renderTwoColumnLayout() string { rightPanel = m.beadFormPanel.RenderWithPanel(contentHeight) case ViewLinearImportInline: rightPanel = m.linearImportPanel.RenderWithPanel(contentHeight) + case ViewPRImportInline: + rightPanel = m.prImportPanel.RenderWithPanel(contentHeight) case ViewCreateWork: rightPanel = m.createWorkPanel.RenderWithPanel(contentHeight) default: @@ -487,6 +492,7 @@ func (m *planModel) renderHelp() string { w Create work from issue(s) A Add issue to existing work i Import issue from Linear + I Import from GitHub PR Filtering & Sorting ──────────────────────────── diff --git a/internal/tui/tui_shared.go b/internal/tui/tui_shared.go index d1cdad26..a6bf185f 100644 --- a/internal/tui/tui_shared.go +++ b/internal/tui/tui_shared.go @@ -139,6 +139,7 @@ const ( ViewBeadSearch ViewLabelFilter ViewLinearImportInline // Import from Linear (inline in details panel) + ViewPRImportInline // Import from GitHub PR (inline in details panel) ViewHelp ) diff --git a/internal/work/import_pr.go b/internal/work/import_pr.go new file mode 100644 index 00000000..32b2406b --- /dev/null +++ b/internal/work/import_pr.go @@ -0,0 +1,391 @@ +package work + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/newhook/co/internal/beads" + "github.com/newhook/co/internal/git" + "github.com/newhook/co/internal/github" + "github.com/newhook/co/internal/logging" + "github.com/newhook/co/internal/worktree" +) + +// PRImporter handles importing PRs into work units. +type PRImporter struct { + client github.ClientInterface + gitOps git.Operations + worktreeOps worktree.Operations +} + +// NewPRImporter creates a new PR importer with default operations. +func NewPRImporter(client github.ClientInterface) *PRImporter { + return &PRImporter{ + client: client, + gitOps: git.NewOperations(), + worktreeOps: worktree.NewOperations(), + } +} + +// NewPRImporterWithOps creates a new PR importer with custom operations (for testing). +func NewPRImporterWithOps(client github.ClientInterface, gitOps git.Operations, worktreeOps worktree.Operations) *PRImporter { + return &PRImporter{ + client: client, + gitOps: gitOps, + worktreeOps: worktreeOps, + } +} + +// SetupWorktreeFromPR fetches a PR's branch and creates a worktree for it. +// It returns the created worktree path and the PR metadata. +// +// Parameters: +// - repoPath: Path to the main repository +// - prURLOrNumber: PR URL or number +// - repo: Repository in owner/repo format (only needed if prURLOrNumber is a number) +// - workDir: Directory where the worktree should be created (worktree will be at workDir/tree) +// - branchName: Name to use for the local branch (if empty, uses the PR's branch name) +// +// The function: +// 1. Fetches PR metadata to get branch information +// 2. Fetches the PR's head ref using GitHub's refs/pull//head +// 3. Creates a worktree at workDir/tree from the fetched branch +func (p *PRImporter) SetupWorktreeFromPR(ctx context.Context, repoPath, prURLOrNumber, repo, workDir, branchName string) (*github.PRMetadata, string, error) { + logging.Info("setting up worktree from PR", + "repoPath", repoPath, + "prURLOrNumber", prURLOrNumber, + "repo", repo, + "workDir", workDir, + "branchName", branchName) + + // Fetch PR metadata + metadata, err := p.client.GetPRMetadata(ctx, prURLOrNumber, repo) + if err != nil { + return nil, "", fmt.Errorf("failed to get PR metadata: %w", err) + } + + // Determine the local branch name + localBranch := branchName + if localBranch == "" { + localBranch = metadata.HeadRefName + } + + // Fetch the PR's head ref + logging.Debug("fetching PR ref", "prNumber", metadata.Number, "localBranch", localBranch) + if err := p.gitOps.FetchPRRef(ctx, repoPath, metadata.Number, localBranch); err != nil { + return metadata, "", fmt.Errorf("failed to fetch PR ref: %w", err) + } + + // Create the worktree directory path + worktreePath := filepath.Join(workDir, "tree") + + // Create worktree from the fetched branch + logging.Debug("creating worktree", "worktreePath", worktreePath, "branch", localBranch) + if err := p.worktreeOps.CreateFromExisting(ctx, repoPath, worktreePath, localBranch); err != nil { + return metadata, "", fmt.Errorf("failed to create worktree: %w", err) + } + + logging.Info("successfully set up worktree from PR", + "prNumber", metadata.Number, + "worktreePath", worktreePath, + "branch", localBranch) + + return metadata, worktreePath, nil +} + +// FetchPRMetadata is a convenience method that just fetches PR metadata without creating a worktree. +func (p *PRImporter) FetchPRMetadata(ctx context.Context, prURLOrNumber, repo string) (*github.PRMetadata, error) { + return p.client.GetPRMetadata(ctx, prURLOrNumber, repo) +} + +// CreateBeadOptions contains options for creating a bead from a PR. +type CreateBeadOptions struct { + // BeadsDir is the directory containing the beads database. + BeadsDir string + // SkipIfExists skips creation if a bead with the same PR URL already exists. + SkipIfExists bool + // OverrideTitle allows overriding the PR title. + OverrideTitle string + // OverrideType allows overriding the inferred type. + OverrideType string + // OverridePriority allows overriding the inferred priority. + OverridePriority string +} + +// CreateBeadResult contains the result of creating a bead from a PR. +type CreateBeadResult struct { + BeadID string + Created bool + SkipReason string +} + +// CreateBeadFromPR creates a bead from PR metadata. +// This allows users to optionally track imported PRs in the beads system. +func (p *PRImporter) CreateBeadFromPR(ctx context.Context, metadata *github.PRMetadata, opts *CreateBeadOptions) (*CreateBeadResult, error) { + logging.Info("creating bead from PR", + "prNumber", metadata.Number, + "prTitle", metadata.Title, + "beadsDir", opts.BeadsDir) + + result := &CreateBeadResult{} + + // Check for existing bead if requested + if opts.SkipIfExists { + existingID, err := findExistingPRBead(ctx, opts.BeadsDir, metadata.URL) + if err != nil { + logging.Warn("failed to check for existing bead", "error", err) + // Continue anyway - we'll try to create + } else if existingID != "" { + result.BeadID = existingID + result.Created = false + result.SkipReason = "bead already exists for this PR" + logging.Info("found existing bead for PR", "beadID", existingID) + return result, nil + } + } + + // Map PR to bead options + beadOpts := mapPRToBeadCreate(metadata) + + // Apply overrides + if opts.OverrideTitle != "" { + beadOpts.title = opts.OverrideTitle + } + if opts.OverrideType != "" { + beadOpts.issueType = opts.OverrideType + } + if opts.OverridePriority != "" { + beadOpts.priority = opts.OverridePriority + } + + // Format description with PR metadata + beadOpts.description = formatBeadDescription(metadata) + + // Convert priority string (P0-P4) to int (0-4) + priority := parsePriority(beadOpts.priority) + + // Create the bead + createOpts := beads.CreateOptions{ + Title: beadOpts.title, + Description: beadOpts.description, + Type: beadOpts.issueType, + Priority: priority, + } + + beadID, err := beads.Create(ctx, opts.BeadsDir, createOpts) + if err != nil { + return nil, fmt.Errorf("failed to create bead: %w", err) + } + + // Set external reference to PR URL for deduplication + if err := beads.SetExternalRef(ctx, beadID, metadata.URL, opts.BeadsDir); err != nil { + logging.Warn("failed to set external ref on bead", "error", err, "beadID", beadID) + // Continue - bead was created successfully + } + + // Add labels if present + if len(beadOpts.labels) > 0 { + if err := beads.AddLabels(ctx, beadID, opts.BeadsDir, beadOpts.labels); err != nil { + logging.Warn("failed to add labels to bead", "error", err, "beadID", beadID) + // Continue - bead was created successfully + } + } + + result.BeadID = beadID + result.Created = true + + logging.Info("successfully created bead from PR", + "beadID", beadID, + "prNumber", metadata.Number) + + return result, nil +} + +// beadCreateOptions represents internal options for creating a bead from a PR. +type beadCreateOptions struct { + title string + description string + issueType string // task, bug, feature + priority string // P0-P4 + status string // open, in_progress, closed + labels []string // label names + metadata map[string]string +} + +// mapPRToBeadCreate converts PR metadata to bead creation options. +func mapPRToBeadCreate(pr *github.PRMetadata) *beadCreateOptions { + opts := &beadCreateOptions{ + title: pr.Title, + description: pr.Body, + issueType: mapPRType(pr), + priority: mapPRPriority(pr), + status: mapPRStatus(pr), + labels: pr.Labels, + metadata: make(map[string]string), + } + + // Store PR metadata + opts.metadata["pr_url"] = pr.URL + opts.metadata["pr_number"] = fmt.Sprintf("%d", pr.Number) + opts.metadata["pr_branch"] = pr.HeadRefName + opts.metadata["pr_base_branch"] = pr.BaseRefName + opts.metadata["pr_author"] = pr.Author + opts.metadata["pr_repo"] = pr.Repo + + return opts +} + +// mapPRType infers a bead issue type from PR labels and title. +// Returns: "task", "bug", or "feature" +func mapPRType(pr *github.PRMetadata) string { + // Check labels for type hints + for _, label := range pr.Labels { + labelLower := strings.ToLower(label) + if strings.Contains(labelLower, "bug") || strings.Contains(labelLower, "fix") { + return "bug" + } + if strings.Contains(labelLower, "feature") || strings.Contains(labelLower, "enhancement") { + return "feature" + } + } + + // Check title for type hints + titleLower := strings.ToLower(pr.Title) + if strings.Contains(titleLower, "bug") || strings.Contains(titleLower, "fix") { + return "bug" + } + if strings.Contains(titleLower, "feat") || strings.Contains(titleLower, "add") { + return "feature" + } + + // Default to task + return "task" +} + +// mapPRPriority infers priority from PR labels. +// Returns: "P0", "P1", "P2", "P3", or "P4" +func mapPRPriority(pr *github.PRMetadata) string { + for _, label := range pr.Labels { + labelLower := strings.ToLower(label) + // Check for explicit priority labels + if strings.Contains(labelLower, "critical") || strings.Contains(labelLower, "urgent") || strings.Contains(labelLower, "p0") { + return "P0" + } + if strings.Contains(labelLower, "high") || strings.Contains(labelLower, "p1") { + return "P1" + } + if strings.Contains(labelLower, "medium") || strings.Contains(labelLower, "p2") { + return "P2" + } + if strings.Contains(labelLower, "low") || strings.Contains(labelLower, "p3") { + return "P3" + } + } + // Default to medium priority + return "P2" +} + +// mapPRStatus converts PR state to bead status. +func mapPRStatus(pr *github.PRMetadata) string { + if pr.Merged { + return "closed" + } + switch strings.ToUpper(pr.State) { + case "OPEN": + if pr.IsDraft { + return "open" + } + return "in_progress" + case "CLOSED": + return "closed" + case "MERGED": + return "closed" + default: + return "open" + } +} + +// formatBeadDescription formats a bead description with PR metadata. +func formatBeadDescription(pr *github.PRMetadata) string { + var builder strings.Builder + + // Add the original PR body + if pr.Body != "" { + builder.WriteString(pr.Body) + builder.WriteString("\n\n") + } + + // Add PR metadata section + builder.WriteString("---\n") + builder.WriteString("**Imported from GitHub PR**\n") + fmt.Fprintf(&builder, "- PR: #%d\n", pr.Number) + fmt.Fprintf(&builder, "- URL: %s\n", pr.URL) + fmt.Fprintf(&builder, "- Branch: %s → %s\n", pr.HeadRefName, pr.BaseRefName) + fmt.Fprintf(&builder, "- Author: %s\n", pr.Author) + fmt.Fprintf(&builder, "- State: %s\n", pr.State) + + if pr.IsDraft { + builder.WriteString("- Draft: yes\n") + } + if pr.Merged { + fmt.Fprintf(&builder, "- Merged: %s\n", pr.MergedAt.Format("2006-01-02")) + } + if len(pr.Labels) > 0 { + fmt.Fprintf(&builder, "- Labels: %s\n", strings.Join(pr.Labels, ", ")) + } + + return builder.String() +} + +// findExistingPRBead checks if a bead already exists for the given PR URL. +// Uses the bd CLI to list beads and find one with matching external_ref. +func findExistingPRBead(ctx context.Context, beadsDir, prURL string) (string, error) { + cmd := exec.CommandContext(ctx, "bd", "list", "--json") + if beadsDir != "" { + cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir) + } + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to list beads: %w", err) + } + + var beadsList []struct { + ID string `json:"id"` + ExternalRef string `json:"external_ref"` + } + if err := json.Unmarshal(output, &beadsList); err != nil { + return "", fmt.Errorf("failed to parse beads list: %w", err) + } + + for _, bead := range beadsList { + if bead.ExternalRef == prURL { + return bead.ID, nil + } + } + + return "", nil +} + +// parsePriority converts priority string (P0-P4) to int (0-4). +func parsePriority(priority string) int { + if len(priority) >= 2 && priority[0] == 'P' { + switch priority[1] { + case '0': + return 0 + case '1': + return 1 + case '2': + return 2 + case '3': + return 3 + case '4': + return 4 + } + } + return 2 // default to medium +} diff --git a/internal/work/import_pr_test.go b/internal/work/import_pr_test.go new file mode 100644 index 00000000..ac9d4187 --- /dev/null +++ b/internal/work/import_pr_test.go @@ -0,0 +1,987 @@ +package work + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/newhook/co/internal/github" + "github.com/newhook/co/internal/worktree" +) + +// MockClientInterface implements github.ClientInterface for testing. +type MockClientInterface struct { + GetPRStatusFunc func(ctx context.Context, prURL string) (*github.PRStatus, error) + GetPRMetadataFunc func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) + PostPRCommentFunc func(ctx context.Context, prURL string, body string) error + PostReplyToCommentFunc func(ctx context.Context, prURL string, commentID int, body string) error + PostReviewReplyFunc func(ctx context.Context, prURL string, reviewCommentID int, body string) error + ResolveReviewThreadFunc func(ctx context.Context, prURL string, commentID int) error + GetJobLogsFunc func(ctx context.Context, repo string, jobID int64) (string, error) +} + +func (m *MockClientInterface) GetPRStatus(ctx context.Context, prURL string) (*github.PRStatus, error) { + if m.GetPRStatusFunc != nil { + return m.GetPRStatusFunc(ctx, prURL) + } + return nil, errors.New("GetPRStatus not implemented") +} + +func (m *MockClientInterface) GetPRMetadata(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + if m.GetPRMetadataFunc != nil { + return m.GetPRMetadataFunc(ctx, prURLOrNumber, repo) + } + return nil, errors.New("GetPRMetadata not implemented") +} + +func (m *MockClientInterface) PostPRComment(ctx context.Context, prURL string, body string) error { + if m.PostPRCommentFunc != nil { + return m.PostPRCommentFunc(ctx, prURL, body) + } + return errors.New("PostPRComment not implemented") +} + +func (m *MockClientInterface) PostReplyToComment(ctx context.Context, prURL string, commentID int, body string) error { + if m.PostReplyToCommentFunc != nil { + return m.PostReplyToCommentFunc(ctx, prURL, commentID, body) + } + return errors.New("PostReplyToComment not implemented") +} + +func (m *MockClientInterface) PostReviewReply(ctx context.Context, prURL string, reviewCommentID int, body string) error { + if m.PostReviewReplyFunc != nil { + return m.PostReviewReplyFunc(ctx, prURL, reviewCommentID, body) + } + return errors.New("PostReviewReply not implemented") +} + +func (m *MockClientInterface) ResolveReviewThread(ctx context.Context, prURL string, commentID int) error { + if m.ResolveReviewThreadFunc != nil { + return m.ResolveReviewThreadFunc(ctx, prURL, commentID) + } + return errors.New("ResolveReviewThread not implemented") +} + +func (m *MockClientInterface) GetJobLogs(ctx context.Context, repo string, jobID int64) (string, error) { + if m.GetJobLogsFunc != nil { + return m.GetJobLogsFunc(ctx, repo, jobID) + } + return "", errors.New("GetJobLogs not implemented") +} + +// MockGitOperations implements git.Operations for testing. +type MockGitOperations struct { + PushSetUpstreamFunc func(ctx context.Context, branch, dir string) error + PullFunc func(ctx context.Context, dir string) error + CloneFunc func(ctx context.Context, source, dest string) error + FetchBranchFunc func(ctx context.Context, repoPath, branch string) error + FetchPRRefFunc func(ctx context.Context, repoPath string, prNumber int, localBranch string) error + BranchExistsFunc func(ctx context.Context, repoPath, branchName string) bool + ValidateExistingBranchFunc func(ctx context.Context, repoPath, branchName string) (bool, bool, error) + ListBranchesFunc func(ctx context.Context, repoPath string) ([]string, error) +} + +func (m *MockGitOperations) PushSetUpstream(ctx context.Context, branch, dir string) error { + if m.PushSetUpstreamFunc != nil { + return m.PushSetUpstreamFunc(ctx, branch, dir) + } + return nil +} + +func (m *MockGitOperations) Pull(ctx context.Context, dir string) error { + if m.PullFunc != nil { + return m.PullFunc(ctx, dir) + } + return nil +} + +func (m *MockGitOperations) Clone(ctx context.Context, source, dest string) error { + if m.CloneFunc != nil { + return m.CloneFunc(ctx, source, dest) + } + return nil +} + +func (m *MockGitOperations) FetchBranch(ctx context.Context, repoPath, branch string) error { + if m.FetchBranchFunc != nil { + return m.FetchBranchFunc(ctx, repoPath, branch) + } + return nil +} + +func (m *MockGitOperations) FetchPRRef(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + if m.FetchPRRefFunc != nil { + return m.FetchPRRefFunc(ctx, repoPath, prNumber, localBranch) + } + return nil +} + +func (m *MockGitOperations) BranchExists(ctx context.Context, repoPath, branchName string) bool { + if m.BranchExistsFunc != nil { + return m.BranchExistsFunc(ctx, repoPath, branchName) + } + return false +} + +func (m *MockGitOperations) ValidateExistingBranch(ctx context.Context, repoPath, branchName string) (bool, bool, error) { + if m.ValidateExistingBranchFunc != nil { + return m.ValidateExistingBranchFunc(ctx, repoPath, branchName) + } + return false, false, nil +} + +func (m *MockGitOperations) ListBranches(ctx context.Context, repoPath string) ([]string, error) { + if m.ListBranchesFunc != nil { + return m.ListBranchesFunc(ctx, repoPath) + } + return nil, nil +} + +// MockWorktreeOperations implements worktree.Operations for testing. +type MockWorktreeOperations struct { + CreateFunc func(ctx context.Context, repoPath, worktreePath, branch, baseBranch string) error + CreateFromExistingFunc func(ctx context.Context, repoPath, worktreePath, branch string) error + RemoveForceFunc func(ctx context.Context, repoPath, worktreePath string) error + ListFunc func(ctx context.Context, repoPath string) ([]worktree.Worktree, error) + ExistsPathFunc func(worktreePath string) bool +} + +func (m *MockWorktreeOperations) Create(ctx context.Context, repoPath, worktreePath, branch, baseBranch string) error { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, repoPath, worktreePath, branch, baseBranch) + } + return nil +} + +func (m *MockWorktreeOperations) CreateFromExisting(ctx context.Context, repoPath, worktreePath, branch string) error { + if m.CreateFromExistingFunc != nil { + return m.CreateFromExistingFunc(ctx, repoPath, worktreePath, branch) + } + return nil +} + +func (m *MockWorktreeOperations) RemoveForce(ctx context.Context, repoPath, worktreePath string) error { + if m.RemoveForceFunc != nil { + return m.RemoveForceFunc(ctx, repoPath, worktreePath) + } + return nil +} + +func (m *MockWorktreeOperations) List(ctx context.Context, repoPath string) ([]worktree.Worktree, error) { + if m.ListFunc != nil { + return m.ListFunc(ctx, repoPath) + } + return nil, nil +} + +func (m *MockWorktreeOperations) ExistsPath(worktreePath string) bool { + if m.ExistsPathFunc != nil { + return m.ExistsPathFunc(worktreePath) + } + return false +} + +func TestNewPRImporter(t *testing.T) { + client := &MockClientInterface{} + importer := NewPRImporter(client) + + if importer == nil { + t.Fatal("NewPRImporter returned nil") + } + if importer.client != client { + t.Error("client not set correctly") + } + if importer.gitOps == nil { + t.Error("gitOps should be initialized") + } + if importer.worktreeOps == nil { + t.Error("worktreeOps should be initialized") + } +} + +func TestNewPRImporterWithOps(t *testing.T) { + client := &MockClientInterface{} + gitOps := &MockGitOperations{} + worktreeOps := &MockWorktreeOperations{} + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + if importer == nil { + t.Fatal("NewPRImporterWithOps returned nil") + } + if importer.client != client { + t.Error("client not set correctly") + } + if importer.gitOps == nil { + t.Error("gitOps should be set") + } + if importer.worktreeOps == nil { + t.Error("worktreeOps should be set") + } +} + +func TestSetupWorktreeFromPR_Success(t *testing.T) { + ctx := context.Background() + + metadata := &github.PRMetadata{ + Number: 123, + URL: "https://github.com/owner/repo/pull/123", + Title: "Test PR", + HeadRefName: "feature-branch", + BaseRefName: "main", + } + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + return metadata, nil + }, + } + + fetchPRRefCalled := false + gitOps := &MockGitOperations{ + FetchPRRefFunc: func(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + fetchPRRefCalled = true + if prNumber != 123 { + t.Errorf("expected PR number 123, got %d", prNumber) + } + if localBranch != "feature-branch" { + t.Errorf("expected branch 'feature-branch', got %s", localBranch) + } + return nil + }, + } + + createFromExistingCalled := false + worktreeOps := &MockWorktreeOperations{ + CreateFromExistingFunc: func(ctx context.Context, repoPath, worktreePath, branch string) error { + createFromExistingCalled = true + if branch != "feature-branch" { + t.Errorf("expected branch 'feature-branch', got %s", branch) + } + if worktreePath != "/work/dir/tree" { + t.Errorf("expected worktreePath '/work/dir/tree', got %s", worktreePath) + } + return nil + }, + } + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + resultMetadata, worktreePath, err := importer.SetupWorktreeFromPR(ctx, "/repo/path", "https://github.com/owner/repo/pull/123", "", "/work/dir", "") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !fetchPRRefCalled { + t.Error("FetchPRRef was not called") + } + + if !createFromExistingCalled { + t.Error("CreateFromExisting was not called") + } + + if resultMetadata.Number != 123 { + t.Errorf("expected PR number 123, got %d", resultMetadata.Number) + } + + if worktreePath != "/work/dir/tree" { + t.Errorf("expected worktreePath '/work/dir/tree', got %s", worktreePath) + } +} + +func TestSetupWorktreeFromPR_CustomBranchName(t *testing.T) { + ctx := context.Background() + + metadata := &github.PRMetadata{ + Number: 123, + URL: "https://github.com/owner/repo/pull/123", + Title: "Test PR", + HeadRefName: "feature-branch", + BaseRefName: "main", + } + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + return metadata, nil + }, + } + + gitOps := &MockGitOperations{ + FetchPRRefFunc: func(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + if localBranch != "custom-branch" { + t.Errorf("expected branch 'custom-branch', got %s", localBranch) + } + return nil + }, + } + + worktreeOps := &MockWorktreeOperations{ + CreateFromExistingFunc: func(ctx context.Context, repoPath, worktreePath, branch string) error { + if branch != "custom-branch" { + t.Errorf("expected branch 'custom-branch', got %s", branch) + } + return nil + }, + } + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + _, _, err := importer.SetupWorktreeFromPR(ctx, "/repo/path", "https://github.com/owner/repo/pull/123", "", "/work/dir", "custom-branch") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetupWorktreeFromPR_MetadataError(t *testing.T) { + ctx := context.Background() + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + return nil, errors.New("API error") + }, + } + + gitOps := &MockGitOperations{} + worktreeOps := &MockWorktreeOperations{} + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + _, _, err := importer.SetupWorktreeFromPR(ctx, "/repo/path", "https://github.com/owner/repo/pull/123", "", "/work/dir", "") + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, errors.New("API error")) && err.Error() != "failed to get PR metadata: API error" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestSetupWorktreeFromPR_FetchPRRefError(t *testing.T) { + ctx := context.Background() + + metadata := &github.PRMetadata{ + Number: 123, + HeadRefName: "feature-branch", + } + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + return metadata, nil + }, + } + + gitOps := &MockGitOperations{ + FetchPRRefFunc: func(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + return errors.New("fetch failed") + }, + } + + worktreeOps := &MockWorktreeOperations{} + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + resultMetadata, _, err := importer.SetupWorktreeFromPR(ctx, "/repo/path", "https://github.com/owner/repo/pull/123", "", "/work/dir", "") + + if err == nil { + t.Fatal("expected error, got nil") + } + + // Metadata should still be returned on fetch failure + if resultMetadata == nil { + t.Error("metadata should be returned even on fetch failure") + } +} + +func TestSetupWorktreeFromPR_WorktreeCreateError(t *testing.T) { + ctx := context.Background() + + metadata := &github.PRMetadata{ + Number: 123, + HeadRefName: "feature-branch", + } + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + return metadata, nil + }, + } + + gitOps := &MockGitOperations{ + FetchPRRefFunc: func(ctx context.Context, repoPath string, prNumber int, localBranch string) error { + return nil + }, + } + + worktreeOps := &MockWorktreeOperations{ + CreateFromExistingFunc: func(ctx context.Context, repoPath, worktreePath, branch string) error { + return errors.New("worktree create failed") + }, + } + + importer := NewPRImporterWithOps(client, gitOps, worktreeOps) + + resultMetadata, _, err := importer.SetupWorktreeFromPR(ctx, "/repo/path", "https://github.com/owner/repo/pull/123", "", "/work/dir", "") + + if err == nil { + t.Fatal("expected error, got nil") + } + + // Metadata should still be returned on worktree create failure + if resultMetadata == nil { + t.Error("metadata should be returned even on worktree create failure") + } +} + +func TestFetchPRMetadata(t *testing.T) { + ctx := context.Background() + + expectedMetadata := &github.PRMetadata{ + Number: 456, + Title: "Test PR", + } + + client := &MockClientInterface{ + GetPRMetadataFunc: func(ctx context.Context, prURLOrNumber string, repo string) (*github.PRMetadata, error) { + if prURLOrNumber != "456" { + t.Errorf("expected prURLOrNumber '456', got %s", prURLOrNumber) + } + if repo != "owner/repo" { + t.Errorf("expected repo 'owner/repo', got %s", repo) + } + return expectedMetadata, nil + }, + } + + importer := NewPRImporter(client) + + metadata, err := importer.FetchPRMetadata(ctx, "456", "owner/repo") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if metadata.Number != 456 { + t.Errorf("expected PR number 456, got %d", metadata.Number) + } +} + +func TestMapPRToBeadCreate(t *testing.T) { + pr := &github.PRMetadata{ + Number: 123, + URL: "https://github.com/owner/repo/pull/123", + Title: "Add new feature", + Body: "This PR adds a new feature", + HeadRefName: "feature-branch", + BaseRefName: "main", + Author: "testuser", + Labels: []string{"feature", "enhancement"}, + State: "OPEN", + Repo: "owner/repo", + } + + opts := mapPRToBeadCreate(pr) + + if opts.title != "Add new feature" { + t.Errorf("expected title 'Add new feature', got %s", opts.title) + } + + if opts.description != "This PR adds a new feature" { + t.Errorf("expected description 'This PR adds a new feature', got %s", opts.description) + } + + // Should detect feature type from labels + if opts.issueType != "feature" { + t.Errorf("expected type 'feature', got %s", opts.issueType) + } + + // Should have default P2 priority + if opts.priority != "P2" { + t.Errorf("expected priority 'P2', got %s", opts.priority) + } + + // Labels should be passed through + if len(opts.labels) != 2 { + t.Errorf("expected 2 labels, got %d", len(opts.labels)) + } + + // Metadata should contain PR info + if opts.metadata["pr_url"] != "https://github.com/owner/repo/pull/123" { + t.Error("pr_url metadata not set correctly") + } + if opts.metadata["pr_number"] != "123" { + t.Error("pr_number metadata not set correctly") + } + if opts.metadata["pr_branch"] != "feature-branch" { + t.Error("pr_branch metadata not set correctly") + } + if opts.metadata["pr_author"] != "testuser" { + t.Error("pr_author metadata not set correctly") + } +} + +func TestMapPRType(t *testing.T) { + tests := []struct { + name string + pr *github.PRMetadata + expected string + }{ + { + name: "Bug from label", + pr: &github.PRMetadata{ + Title: "Some change", + Labels: []string{"bug"}, + }, + expected: "bug", + }, + { + name: "Bug from fix label", + pr: &github.PRMetadata{ + Title: "Some change", + Labels: []string{"bugfix"}, + }, + expected: "bug", + }, + { + name: "Feature from label", + pr: &github.PRMetadata{ + Title: "Some change", + Labels: []string{"feature"}, + }, + expected: "feature", + }, + { + name: "Feature from enhancement label", + pr: &github.PRMetadata{ + Title: "Some change", + Labels: []string{"enhancement"}, + }, + expected: "feature", + }, + { + name: "Bug from title", + pr: &github.PRMetadata{ + Title: "Fix broken login", + Labels: []string{}, + }, + expected: "bug", + }, + { + name: "Feature from title with feat", + pr: &github.PRMetadata{ + Title: "feat: Add new button", + Labels: []string{}, + }, + expected: "feature", + }, + { + name: "Feature from title with add", + pr: &github.PRMetadata{ + Title: "Add user authentication", + Labels: []string{}, + }, + expected: "feature", + }, + { + name: "Default to task", + pr: &github.PRMetadata{ + Title: "Update documentation", + Labels: []string{}, + }, + expected: "task", + }, + { + name: "Label takes precedence over title", + pr: &github.PRMetadata{ + Title: "Fix: Add new feature", // Title suggests bug + Labels: []string{"feature"}, // Label says feature + }, + expected: "feature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mapPRType(tt.pr) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestMapPRPriority(t *testing.T) { + tests := []struct { + name string + pr *github.PRMetadata + expected string + }{ + { + name: "Critical from label", + pr: &github.PRMetadata{ + Labels: []string{"critical"}, + }, + expected: "P0", + }, + { + name: "Urgent from label", + pr: &github.PRMetadata{ + Labels: []string{"urgent"}, + }, + expected: "P0", + }, + { + name: "P0 from label", + pr: &github.PRMetadata{ + Labels: []string{"p0"}, + }, + expected: "P0", + }, + { + name: "High priority from label", + pr: &github.PRMetadata{ + Labels: []string{"high-priority"}, + }, + expected: "P1", + }, + { + name: "P1 from label", + pr: &github.PRMetadata{ + Labels: []string{"priority-p1"}, + }, + expected: "P1", + }, + { + name: "Medium priority from label", + pr: &github.PRMetadata{ + Labels: []string{"medium"}, + }, + expected: "P2", + }, + { + name: "P2 from label", + pr: &github.PRMetadata{ + Labels: []string{"p2"}, + }, + expected: "P2", + }, + { + name: "Low priority from label", + pr: &github.PRMetadata{ + Labels: []string{"low"}, + }, + expected: "P3", + }, + { + name: "P3 from label", + pr: &github.PRMetadata{ + Labels: []string{"p3"}, + }, + expected: "P3", + }, + { + name: "Default to P2", + pr: &github.PRMetadata{ + Labels: []string{"documentation"}, + }, + expected: "P2", + }, + { + name: "Empty labels default to P2", + pr: &github.PRMetadata{ + Labels: []string{}, + }, + expected: "P2", + }, + { + name: "First matching priority wins", + pr: &github.PRMetadata{ + Labels: []string{"low", "critical"}, // critical comes second but matches first in loop + }, + expected: "P3", // low is checked before critical in the loop order + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mapPRPriority(tt.pr) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestMapPRStatus(t *testing.T) { + tests := []struct { + name string + pr *github.PRMetadata + expected string + }{ + { + name: "Merged PR", + pr: &github.PRMetadata{ + State: "MERGED", + Merged: true, + }, + expected: "closed", + }, + { + name: "Open draft PR", + pr: &github.PRMetadata{ + State: "OPEN", + IsDraft: true, + Merged: false, + }, + expected: "open", + }, + { + name: "Open PR not draft", + pr: &github.PRMetadata{ + State: "OPEN", + IsDraft: false, + Merged: false, + }, + expected: "in_progress", + }, + { + name: "Closed PR", + pr: &github.PRMetadata{ + State: "CLOSED", + Merged: false, + }, + expected: "closed", + }, + { + name: "Merged state", + pr: &github.PRMetadata{ + State: "MERGED", + Merged: false, // Even if Merged is false, MERGED state maps to closed + }, + expected: "closed", + }, + { + name: "Unknown state defaults to open", + pr: &github.PRMetadata{ + State: "UNKNOWN", + Merged: false, + }, + expected: "open", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mapPRStatus(tt.pr) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestFormatBeadDescription(t *testing.T) { + mergedAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + tests := []struct { + name string + pr *github.PRMetadata + contains []string + }{ + { + name: "Basic PR", + pr: &github.PRMetadata{ + Number: 123, + URL: "https://github.com/owner/repo/pull/123", + Body: "Original description", + HeadRefName: "feature-branch", + BaseRefName: "main", + Author: "testuser", + State: "OPEN", + }, + contains: []string{ + "Original description", + "**Imported from GitHub PR**", + "PR: #123", + "URL: https://github.com/owner/repo/pull/123", + "Branch: feature-branch → main", + "Author: testuser", + "State: OPEN", + }, + }, + { + name: "Draft PR", + pr: &github.PRMetadata{ + Number: 456, + URL: "https://github.com/owner/repo/pull/456", + HeadRefName: "draft-branch", + BaseRefName: "main", + Author: "otheruser", + State: "OPEN", + IsDraft: true, + }, + contains: []string{ + "Draft: yes", + }, + }, + { + name: "Merged PR", + pr: &github.PRMetadata{ + Number: 789, + URL: "https://github.com/owner/repo/pull/789", + HeadRefName: "merged-branch", + BaseRefName: "main", + Author: "mergeduser", + State: "MERGED", + Merged: true, + MergedAt: mergedAt, + }, + contains: []string{ + "Merged: 2024-01-15", + }, + }, + { + name: "PR with labels", + pr: &github.PRMetadata{ + Number: 101, + URL: "https://github.com/owner/repo/pull/101", + HeadRefName: "labeled-branch", + BaseRefName: "main", + Author: "labeluser", + State: "OPEN", + Labels: []string{"bug", "urgent", "needs-review"}, + }, + contains: []string{ + "Labels: bug, urgent, needs-review", + }, + }, + { + name: "Empty body", + pr: &github.PRMetadata{ + Number: 200, + URL: "https://github.com/owner/repo/pull/200", + Body: "", + HeadRefName: "no-body-branch", + BaseRefName: "main", + Author: "noBodyUser", + State: "OPEN", + }, + contains: []string{ + "**Imported from GitHub PR**", + "PR: #200", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatBeadDescription(tt.pr) + + for _, expected := range tt.contains { + if !containsString(result, expected) { + t.Errorf("expected description to contain %q, got:\n%s", expected, result) + } + } + }) + } +} + +func TestParsePriority(t *testing.T) { + tests := []struct { + input string + expected int + }{ + {"P0", 0}, + {"P1", 1}, + {"P2", 2}, + {"P3", 3}, + {"P4", 4}, + {"p0", 2}, // lowercase doesn't match, defaults to 2 + {"", 2}, // empty defaults to 2 + {"P5", 2}, // unknown P-level defaults to 2 + {"invalid", 2}, + {"P", 2}, // just P defaults to 2 + {"Priority1", 2}, // doesn't start with P followed by digit + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parsePriority(tt.input) + if result != tt.expected { + t.Errorf("parsePriority(%q) = %d, expected %d", tt.input, result, tt.expected) + } + }) + } +} + +func TestCreateBeadOptions(t *testing.T) { + opts := &CreateBeadOptions{ + BeadsDir: "/path/to/beads", + SkipIfExists: true, + OverrideTitle: "Custom Title", + OverrideType: "bug", + OverridePriority: "P1", + } + + if opts.BeadsDir != "/path/to/beads" { + t.Error("BeadsDir not set correctly") + } + if !opts.SkipIfExists { + t.Error("SkipIfExists not set correctly") + } + if opts.OverrideTitle != "Custom Title" { + t.Error("OverrideTitle not set correctly") + } + if opts.OverrideType != "bug" { + t.Error("OverrideType not set correctly") + } + if opts.OverridePriority != "P1" { + t.Error("OverridePriority not set correctly") + } +} + +func TestCreateBeadResult(t *testing.T) { + result := &CreateBeadResult{ + BeadID: "bead-123", + Created: true, + SkipReason: "", + } + + if result.BeadID != "bead-123" { + t.Error("BeadID not set correctly") + } + if !result.Created { + t.Error("Created not set correctly") + } + + // Test skip result + skipResult := &CreateBeadResult{ + BeadID: "existing-bead", + Created: false, + SkipReason: "bead already exists for this PR", + } + + if skipResult.Created { + t.Error("Created should be false for skipped bead") + } + if skipResult.SkipReason == "" { + t.Error("SkipReason should be set for skipped bead") + } +} + +// containsString checks if s contains substr. +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/work/work.go b/internal/work/work.go index 18086c52..cd158313 100644 --- a/internal/work/work.go +++ b/internal/work/work.go @@ -150,6 +150,7 @@ type CreateWorkAsyncOptions struct { RootIssueID string Auto bool UseExistingBranch bool + BeadIDs []string // Beads to add to the work (added immediately, not by control plane) } // CreateWorkAsyncWithOptions creates a work unit asynchronously with the given options. @@ -189,6 +190,14 @@ func CreateWorkAsyncWithOptions(ctx context.Context, proj *project.Project, opts return nil, fmt.Errorf("failed to create work record: %w", err) } + // Add beads to work_beads (done immediately, not by control plane) + if len(opts.BeadIDs) > 0 { + if err := AddBeadsToWorkInternal(ctx, proj, workID, opts.BeadIDs); err != nil { + _ = proj.DB.DeleteWork(ctx, workID) + return nil, fmt.Errorf("failed to add beads to work: %w", err) + } + } + // Schedule the worktree creation task for the control plane autoStr := "false" if opts.Auto { @@ -221,6 +230,72 @@ func CreateWorkAsyncWithOptions(ctx context.Context, proj *project.Project, opts }, nil } +// ImportPRAsyncOptions contains options for importing a PR asynchronously. +type ImportPRAsyncOptions struct { + PRURL string + BranchName string + RootIssueID string +} + +// ImportPRAsyncResult contains the result of scheduling a PR import. +type ImportPRAsyncResult struct { + WorkID string + WorkerName string + BranchName string + RootIssueID string +} + +// ImportPRAsync imports a PR asynchronously by scheduling a control plane task. +// This creates the work record and schedules the import task - the actual +// worktree setup happens in the control plane. +func ImportPRAsync(ctx context.Context, proj *project.Project, opts ImportPRAsyncOptions) (*ImportPRAsyncResult, error) { + baseBranch := proj.Config.Repo.GetBaseBranch() + + // Generate work ID + workID, err := proj.DB.GenerateWorkID(ctx, opts.BranchName, proj.Config.Project.Name) + if err != nil { + return nil, fmt.Errorf("failed to generate work ID: %w", err) + } + + // Get a human-readable name for this worker + workerName, err := names.GetNextAvailableName(ctx, proj.DB.DB) + if err != nil { + workerName = "" // Non-fatal + } + + // Create work record in DB (without worktree path - control plane will set it) + if err := proj.DB.CreateWork(ctx, workID, workerName, "", opts.BranchName, baseBranch, opts.RootIssueID, false); err != nil { + return nil, fmt.Errorf("failed to create work record: %w", err) + } + + // Add root issue to work_beads immediately (before control plane runs) + if opts.RootIssueID != "" { + if err := AddBeadsToWorkInternal(ctx, proj, workID, []string{opts.RootIssueID}); err != nil { + _ = proj.DB.DeleteWork(ctx, workID) + return nil, fmt.Errorf("failed to add bead to work: %w", err) + } + } + + // Schedule the PR import task for the control plane + err = proj.DB.ScheduleTaskWithRetry(ctx, workID, db.TaskTypeImportPR, time.Now(), map[string]string{ + "pr_url": opts.PRURL, + "branch": opts.BranchName, + "worker_name": workerName, + }, fmt.Sprintf("import-pr-%s", workID), db.DefaultMaxAttempts) + if err != nil { + // Work record created but task scheduling failed - cleanup + _ = proj.DB.DeleteWork(ctx, workID) + return nil, fmt.Errorf("failed to schedule PR import: %w", err) + } + + return &ImportPRAsyncResult{ + WorkID: workID, + WorkerName: workerName, + BranchName: opts.BranchName, + RootIssueID: opts.RootIssueID, + }, nil +} + // DestroyWork destroys a work unit and all its resources. // This is the core work destruction logic that can be called from both the CLI and TUI. // It does not perform interactive confirmation - that should be handled by the caller.