255 lines
8.3 KiB
Go
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{}
|
|
}
|
|
}
|