11
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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{}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user