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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ func openNewSession(wt worktree.Worktree, t terminal.Terminal) error {
if wt.Type != worktree.TypePRReview {
initialPrompt = ""
action = "Starting new session"
} else {
// Ensure /review-pr command is installed
if err := ensureClaudeCommand("review-pr"); err != nil {
ui.LogInfo(fmt.Sprintf("Warning: could not install /review-pr command: %v", err))
}
}

if resumeNoITerm {
Expand Down
110 changes: 25 additions & 85 deletions cmd/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import (
"strconv"
"strings"

ctxpkg "github.com/mgreau/zen/internal/context"
"github.com/mgreau/zen/internal/github"
"github.com/mgreau/zen/internal/prcache"
"github.com/mgreau/zen/internal/review"
"github.com/mgreau/zen/internal/terminal"
"github.com/mgreau/zen/internal/ui"
wt "github.com/mgreau/zen/internal/worktree"
Expand Down Expand Up @@ -63,14 +62,6 @@ func init() {
rootCmd.AddCommand(reviewCmd)
}

// ReviewResult holds the output for --json mode.
type ReviewResult struct {
WorktreePath string `json:"worktree_path"`
PRNumber int `json:"pr_number"`
Title string `json:"title"`
Author string `json:"author"`
}

func runReview(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return cmd.Help()
Expand All @@ -91,107 +82,56 @@ func runReview(cmd *cobra.Command, args []string) error {
reviewRepo = detected
}

// Validate repo exists in config
// Check if worktree already exists and resume
basePath := cfg.RepoBasePath(reviewRepo)
if basePath == "" {
return fmt.Errorf("unknown repo %q — check ~/.zen/config.yaml", reviewRepo)
}
fullRepo := cfg.RepoFullName(reviewRepo)

// Construct paths
originPath := filepath.Join(basePath, reviewRepo)
worktreeName := fmt.Sprintf("%s-pr-%d", reviewRepo, prNumber)
worktreePath := filepath.Join(basePath, worktreeName)

// If worktree already exists, resume it
if _, err := os.Stat(worktreePath); err == nil {
ui.LogInfo(fmt.Sprintf("Worktree already exists, resuming PR #%d...", prNumber))
// Pass model through to resume path
if reviewModel != "" {
resumeModel = reviewModel
if basePath != "" {
worktreeName := fmt.Sprintf("%s-pr-%d", reviewRepo, prNumber)
worktreePath := filepath.Join(basePath, worktreeName)
if _, err := os.Stat(worktreePath); err == nil {
ui.LogInfo(fmt.Sprintf("Worktree already exists, resuming PR #%d...", prNumber))
if reviewModel != "" {
resumeModel = reviewModel
}
return openReviewTab(worktreePath, worktreeName)
}
return openReviewTab(worktreePath, worktreeName)
}

// Fetch PR details from GitHub
ui.LogInfo(fmt.Sprintf("Fetching PR #%d from %s...", prNumber, fullRepo))
client, err := github.NewClient(ctx)
if err != nil {
return fmt.Errorf("creating GitHub client: %w", err)
}
details, err := client.GetPRDetails(ctx, fullRepo, prNumber)
// Create worktree using shared logic
result, err := review.CreateWorktree(ctx, cfg, reviewRepo, prNumber, ui.LogInfo)
if err != nil {
return fmt.Errorf("fetching PR details: %w", err)
}

ui.LogInfo(fmt.Sprintf("PR #%d: %s (by %s)", prNumber, details.Title, details.Author))

// Create worktree under lock
branchName := fmt.Sprintf("pr-%d", prNumber)

wt.GitMu.Lock()

ui.LogInfo(fmt.Sprintf("Fetching pull/%d/head...", prNumber))
fetchCmd := exec.Command("git", "fetch", "origin", fmt.Sprintf("+pull/%d/head:%s", prNumber, branchName))
fetchCmd.Dir = originPath
if out, err := fetchCmd.CombinedOutput(); err != nil {
wt.GitMu.Unlock()
return fmt.Errorf("git fetch: %w: %s", err, string(out))
}

ui.LogInfo(fmt.Sprintf("Creating worktree %s...", worktreeName))
wtCmd := exec.Command("git", "worktree", "add", worktreePath, branchName)
wtCmd.Dir = originPath
if out, err := wtCmd.CombinedOutput(); err != nil {
wt.GitMu.Unlock()
return fmt.Errorf("git worktree add: %w: %s", err, string(out))
}

// Clean stale index.lock
lockFile := filepath.Join(originPath, ".git", "worktrees", worktreeName, "index.lock")
os.Remove(lockFile)

wt.GitMu.Unlock()

// Inject PR context into CLAUDE.local.md
ui.LogInfo("Injecting PR context into CLAUDE.local.md...")
if err := ctxpkg.InjectPRContext(ctx, worktreePath, fullRepo, prNumber); err != nil {
ui.LogInfo(fmt.Sprintf("Warning: failed to inject context: %v", err))
return err
}

// Cache PR metadata
prcache.Set(reviewRepo, prNumber, details.Title, details.Author)

home := homeDir()
shortPath := ui.ShortenHome(worktreePath, home)
shortPath := ui.ShortenHome(result.WorktreePath, home)

if jsonFlag {
printJSON(ReviewResult{
WorktreePath: worktreePath,
PRNumber: prNumber,
Title: details.Title,
Author: details.Author,
})
printJSON(result)
return nil
}

fmt.Println()
ui.LogSuccess(fmt.Sprintf("Created worktree: %s", shortPath))
fmt.Printf(" PR: #%d — %s\n", prNumber, details.Title)
fmt.Printf(" Author: %s\n", details.Author)
fmt.Printf(" PR: #%d — %s\n", result.PRNumber, result.Title)
fmt.Printf(" Author: %s\n", result.Author)

if reviewModel != "" {
fmt.Printf(" Model: %s\n", ui.CyanText(reviewModel))
}

// Ensure /review-pr command is installed
if err := ensureClaudeCommand("review-pr"); err != nil {
ui.LogInfo(fmt.Sprintf("Warning: could not install /review-pr command: %v", err))
}

if reviewNoITerm {
fmt.Println()
fmt.Println(ui.BoldText("Open manually:"))
modelFlag := ""
if reviewModel != "" {
modelFlag = fmt.Sprintf(" --model %s", reviewModel)
}
fmt.Printf(" cd %s && %s%s \"/review-pr\"\n", worktreePath, cfg.ClaudeBin, modelFlag)
fmt.Printf(" cd %s && %s%s \"/review-pr\"\n", result.WorktreePath, cfg.ClaudeBin, modelFlag)
return nil
}

Expand All @@ -201,7 +141,7 @@ func runReview(cmd *cobra.Command, args []string) error {
return err
}

if err := term.OpenTabWithClaude(worktreePath, "/review-pr", cfg.ClaudeBin, reviewModel); err != nil {
if err := term.OpenTabWithClaude(result.WorktreePath, "/review-pr", cfg.ClaudeBin, reviewModel); err != nil {
return fmt.Errorf("opening %s tab: %w", term.Name(), err)
}

Expand Down
31 changes: 31 additions & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,37 @@ func promptRequired(scanner *bufio.Scanner, label string) string {
}
}

// ensureClaudeCommand checks if a specific Claude command file exists and
// installs it silently from the embedded FS if missing.
func ensureClaudeCommand(name string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
targetDir := filepath.Join(home, ".claude", "commands")
dst := filepath.Join(targetDir, name+".md")

if _, err := os.Stat(dst); err == nil {
return nil // already exists
}

srcData, err := fs.ReadFile(EmbeddedCommands, filepath.Join("commands", name+".md"))
if err != nil {
return fmt.Errorf("reading embedded %s.md: %w", name, err)
}

if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", targetDir, err)
}

if err := os.WriteFile(dst, srcData, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", dst, err)
}

ui.LogInfo(fmt.Sprintf("Installed Claude command /%s", name))
return nil
}

// installClaudeCommands prompts the user and installs embedded Claude Code
// command files to ~/.claude/commands/.
func installClaudeCommands(scanner *bufio.Scanner) (int, error) {
Expand Down
4 changes: 2 additions & 2 deletions cmd/work.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ func runWorkNew(cmd *cobra.Command, args []string) error {
return fmt.Errorf("git checkout in worktree: %w: %s", err, string(out))
}

// Clean stale index.lock
// Clean stale index.lock (only if holding process is dead)
lockFile := filepath.Join(originPath, ".git", "worktrees", worktreeName, "index.lock")
os.Remove(lockFile)
wt.RemoveStaleLock(lockFile, worktreeName)

wt.GitMu.Unlock()

Expand Down
23 changes: 23 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,27 @@ func (s *Server) registerTools() {
),
s.handleConfigRepos,
)

s.server.AddTool(
mcpgo.NewTool("zen_review",
mcpgo.WithDescription("Create a worktree for a PR number (fetches branch, creates worktree, injects context)"),
mcpgo.WithNumber("pr_number", mcpgo.Description("Pull request number"), mcpgo.Required()),
mcpgo.WithString("repo", mcpgo.Description("Short repo name (auto-detected if omitted)")),
mcpgo.WithReadOnlyHintAnnotation(false),
mcpgo.WithDestructiveHintAnnotation(false),
mcpgo.WithOpenWorldHintAnnotation(true),
),
s.handleReview,
)

s.server.AddTool(
mcpgo.NewTool("zen_review_resume",
mcpgo.WithDescription("Get resume info (worktree path and sessions) for an existing PR review worktree"),
mcpgo.WithNumber("pr_number", mcpgo.Description("Pull request number"), mcpgo.Required()),
mcpgo.WithReadOnlyHintAnnotation(true),
mcpgo.WithDestructiveHintAnnotation(false),
mcpgo.WithOpenWorldHintAnnotation(false),
),
s.handleReviewResume,
)
}
47 changes: 47 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,53 @@ func TestHandleAgentStatusNoSessions(t *testing.T) {
}
}

func TestHandleReviewMissingParams(t *testing.T) {
srv := New(testConfig())
ctx := context.Background()

// Missing required pr_number
result, err := srv.handleReview(ctx, makeRequest(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.IsError {
t.Fatal("expected tool error for missing pr_number")
}
}

func TestHandleReviewResumeMissingParams(t *testing.T) {
srv := New(testConfig())
ctx := context.Background()

// Missing required pr_number
result, err := srv.handleReviewResume(ctx, makeRequest(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.IsError {
t.Fatal("expected tool error for missing pr_number")
}
}

func TestHandleReviewResumeNoWorktree(t *testing.T) {
// Use paths that definitely don't have worktrees
cfg := &config.Config{
Repos: map[string]config.RepoConfig{
"fake": {FullName: "test/fake", BasePath: "/tmp/nonexistent-zen-test"},
},
}
srv := New(cfg)
ctx := context.Background()

result, err := srv.handleReviewResume(ctx, makeRequest(map[string]any{"pr_number": 99999}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.IsError {
t.Fatal("expected tool error for non-existent worktree")
}
}

func TestJsonResult(t *testing.T) {
type testData struct {
Name string `json:"name"`
Expand Down
62 changes: 62 additions & 0 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
mcpgo "github.com/mark3labs/mcp-go/mcp"
ghpkg "github.com/mgreau/zen/internal/github"
"github.com/mgreau/zen/internal/reconciler"
"github.com/mgreau/zen/internal/review"
"github.com/mgreau/zen/internal/session"
"github.com/mgreau/zen/internal/worktree"
)
Expand Down Expand Up @@ -208,6 +209,67 @@ type repoEntry struct {
BasePath string `json:"base_path"`
}

// handleReview creates a worktree for a PR number.
func (s *Server) handleReview(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
prNumber, err := req.RequireInt("pr_number")
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}

repoShort := req.GetString("repo", "")
if repoShort == "" {
detected, err := review.DetectRepo(ctx, s.cfg, prNumber)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
repoShort = detected
}

// Pass nil logger -- MCP must not write to stdout
result, err := review.CreateWorktree(ctx, s.cfg, repoShort, prNumber, nil)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}

return jsonResult(result)
}

// reviewResumeEntry holds the response for zen_review_resume.
type reviewResumeEntry struct {
WorktreePath string `json:"worktree_path"`
Name string `json:"name"`
Sessions []session.Session `json:"sessions"`
}

// handleReviewResume gets resume info for an existing PR worktree.
func (s *Server) handleReviewResume(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
prNumber, err := req.RequireInt("pr_number")
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}

wts, err := worktree.ListAll(s.cfg)
if err != nil {
return mcpgo.NewToolResultError("failed to list worktrees: " + err.Error()), nil
}

for _, wt := range wts {
if wt.Type == worktree.TypePRReview && wt.PRNumber == prNumber {
sessions, _ := session.FindSessions(wt.Path)
if sessions == nil {
sessions = []session.Session{}
}
return jsonResult(reviewResumeEntry{
WorktreePath: wt.Path,
Name: wt.Name,
Sessions: sessions,
})
}
}

return mcpgo.NewToolResultError(fmt.Sprintf("no PR review worktree for #%d", prNumber)), nil
}

// handleConfigRepos lists configured repositories.
func (s *Server) handleConfigRepos(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
var repos []repoEntry
Expand Down
4 changes: 2 additions & 2 deletions internal/reconciler/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ func (r *SetupReconciler) ensureWorktree(originPath, worktreePath, worktreeName
return fmt.Errorf("git checkout in worktree: %w: %s", err, string(out))
}

// Clean stale lock immediately
// Clean stale index.lock (only if holding process is dead)
lockFile := filepath.Join(originPath, ".git", "worktrees", worktreeName, "index.lock")
os.Remove(lockFile)
wt.RemoveStaleLock(lockFile, worktreeName)

return nil
}
Expand Down
Loading
Loading