Files
superwork/tui/internal/tui/implement.go
T
2026-06-23 05:02:15 +08:00

255 lines
8.3 KiB
Go

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{}
}
}