package tui import ( "context" "crypto/sha256" "fmt" "os" "path/filepath" "strconv" "time" tea "charm.land/bubbletea/v2" "superwork-tui/internal/auth" "superwork-tui/internal/cc" "superwork-tui/internal/config" "superwork-tui/internal/git" "superwork-tui/internal/gitea" "superwork-tui/internal/issue" "superwork-tui/internal/logging" ) // workspaceRootFn is injectable so tests can point at a temp git repo without // polluting the real workspace. var workspaceRootFn = config.WorkspaceRoot // persistStateFn is injectable for tests to assert ordering of the state-write // step relative to OpenOrFocus. Production code sets it to the real Gitea write. var persistStateFn = defaultPersistState func defaultPersistState(ctx context.Context, workspaceRoot, branch, relWorktreePath string, issueNumber int) string { remote, rErr := git.DetectRepo(workspaceRoot) if rErr != nil { return fmt.Sprintf("detect repo: %v", rErr) } token, tErr := auth.ResolveGiteaToken(remote.Host) if tErr != nil { return fmt.Sprintf("resolve token: %v", tErr) } client := gitea.New(remote.Host, token) if sErr := issue.MergeStateJSON(ctx, client, remote.Owner, remote.Repo, issueNumber, map[string]any{ "column": string(issue.ColumnInProgress), "branch": branch, "worktreePath": relWorktreePath, "implementStatus": "running", }); sErr != nil { return fmt.Sprintf("写入 state JSON 失败: %v", sErr) } return "" } // overrideWorkspaceRoot swaps workspaceRootFn for tests and returns a restore func. func overrideWorkspaceRoot(root string) func() { orig := workspaceRootFn workspaceRootFn = func() (string, error) { return root, nil } return func() { workspaceRootFn = orig } } // implementResultMsg is the outcome of the implement flow tea.Cmd. type implementResultMsg struct { issueNumber int branch string relWorktreePath string worktreePath string hookWarnings []string err error } // implementSessionCapturedMsg carries the captured implementSessionId from the watcher. type implementSessionCapturedMsg struct { issueNumber int sessionID string } // implementFeature returns the 8-hex-char feature id derived from planFile. func implementFeature(planFile string) string { h := sha256.Sum256([]byte(planFile)) return fmt.Sprintf("%x", h)[:8] } // implementCmd runs the full implement pipeline inside a tea.Cmd goroutine. // // Flow (fresh): CreateWorktree → RunPostCreateHook → MergeStateJSON(best-effort) // // → RunImplTabPreCreateHook → OpenOrFocus // // Flow (resume): MergeStateJSON(best-effort) → RunImplTabPreCreateHook → OpenOrFocus // // Fatal steps: PlanFile missing, WorkspaceRoot, CreateWorktree, OpenOrFocus. // Non-fatal steps: settings load, hook failures, Gitea state write. func implementCmd(iss issue.Issue, sm *SessionManager) tea.Cmd { return func() tea.Msg { ctx := context.Background() if iss.PlanFile == "" { return implementResultMsg{ issueNumber: iss.Number, err: fmt.Errorf("issue #%d has no planFile", iss.Number), } } feature := implementFeature(iss.PlanFile) branch := "feature/" + feature relWorktreePath := ".claude/worktrees/" + feature workspaceRoot, err := workspaceRootFn() if err != nil { return implementResultMsg{issueNumber: iss.Number, err: fmt.Errorf("workspace root: %w", err)} } worktreePath := filepath.Join(workspaceRoot, relWorktreePath) settings, err := config.Load() if err != nil { settings = config.DefaultSettings() } mainBranch := settings.DevBranch if mainBranch == "" { mainBranch = "main" } var hookWarnings []string // Detect resume: issue already has branch+worktreePath AND dir still on disk. isResume := iss.Branch != "" && iss.WorktreePath != "" if isResume { if _, statErr := os.Stat(worktreePath); os.IsNotExist(statErr) { isResume = false } } hctx := git.HookContext{ WorkspaceRoot: workspaceRoot, WorktreePath: worktreePath, Branch: branch, IssueNumber: iss.Number, MainBranch: mainBranch, } if !isResume { if err := git.CreateWorktree(ctx, git.CreateWorktreeOpts{ WorkspaceRoot: workspaceRoot, WorktreePath: worktreePath, Branch: branch, }); err != nil { logging.Default.Error("tui", fmt.Sprintf("创建 worktree 失败 #%d: %v", iss.Number, err)) return implementResultMsg{issueNumber: iss.Number, err: fmt.Errorf("create worktree: %w", err)} } logging.Default.Info("tui", fmt.Sprintf("worktree 已创建 #%d: %s", iss.Number, worktreePath)) postHctx := hctx postHctx.CustomScriptPath = settings.WorktreePostCreateScript r := git.RunPostCreateHook(ctx, postHctx) if r.Status != git.HookStatusOk && r.Status != git.HookStatusSkipped { hookWarnings = append(hookWarnings, fmt.Sprintf("post-create hook %s: %s", r.Status, r.ErrorMessage)) } } // State JSON write is best-effort: Gitea unavailability must not block the flow. // Write BEFORE opening the terminal so the record exists even if the terminal step fails. if warn := persistStateFn(ctx, workspaceRoot, branch, relWorktreePath, iss.Number); warn != "" { hookWarnings = append(hookWarnings, warn) } // impl-tab-pre-create runs on both fresh and resume. preHctx := hctx preHctx.CustomScriptPath = settings.ImplTabPreCreateScript pr := git.RunImplTabPreCreateHook(ctx, preHctx) if pr.Status != git.HookStatusOk && pr.Status != git.HookStatusSkipped { hookWarnings = append(hookWarnings, fmt.Sprintf("impl-tab-pre-create hook %s: %s", pr.Status, pr.ErrorMessage)) } // Build the claude command. var command string if isResume && iss.ImplementSessionID != "" { command = claudeCmd(claudeCmdOpts{ ResumeSessionID: iss.ImplementSessionID, ProfilePath: iss.ProfilePath, EffortHigh: true, SystemPromptCommand: settings.SystemPromptCommand, }) } else { prompt := cc.ImplementPlanPrompt(settings, struct{ PlanFile, IssueNumber string }{ PlanFile: iss.PlanFile, IssueNumber: strconv.Itoa(iss.Number), }) command = claudeCmd(claudeCmdOpts{ Prompt: prompt, ProfilePath: iss.ProfilePath, EffortHigh: true, SystemPromptCommand: settings.SystemPromptCommand, }) } logging.Default.Info("tui", fmt.Sprintf("打开实施会话 #%d", iss.Number)) if err := sm.OpenOrFocus(ctx, PhaseImpl, iss, worktreePath, command); err != nil { logging.Default.Error("tui", fmt.Sprintf("打开实施会话失败 #%d: %v", iss.Number, err)) return implementResultMsg{issueNumber: iss.Number, err: fmt.Errorf("open session: %w", err)} } return implementResultMsg{ issueNumber: iss.Number, branch: branch, relWorktreePath: relWorktreePath, worktreePath: worktreePath, hookWarnings: hookWarnings, } } } // watchSessionCmd watches for a new claude session jsonl in the worktree's // projects dir and returns implementSessionCapturedMsg when found (or on timeout). func watchSessionCmd(issueNumber int, worktreePath string) tea.Cmd { return func() tea.Msg { home, err := os.UserHomeDir() if err != nil { return implementSessionCapturedMsg{issueNumber: issueNumber} } projectsDir := cc.ClaudeProjectsDir(home, worktreePath) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() sid, _ := cc.WatchForNewSession(ctx, cc.SessionWatchOpts{ ProjectsDir: projectsDir, Timeout: 120 * time.Second, }) return implementSessionCapturedMsg{issueNumber: issueNumber, sessionID: sid} } } // writeImplementSessionCmd persists the captured implementSessionId to the Gitea // state JSON comment. Errors are non-fatal; the tea.Msg is sessionActionResultMsg // with nil err (a no-op in Update). func writeImplementSessionCmd(issueNumber int, sessionID string) tea.Cmd { return func() tea.Msg { ctx := context.Background() root, err := workspaceRootFn() if err != nil { return sessionActionResultMsg{} } remote, err := git.DetectRepo(root) if err != nil { return sessionActionResultMsg{} } token, err := auth.ResolveGiteaToken(remote.Host) if err != nil { return sessionActionResultMsg{} } client := gitea.New(remote.Host, token) _ = issue.MergeStateJSON(ctx, client, remote.Owner, remote.Repo, issueNumber, map[string]any{ "implementSessionId": sessionID, }) return sessionActionResultMsg{} } }