284 lines
8.7 KiB
Go
284 lines
8.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// watchBrainstormFn is injectable for tests; production code uses the real watcher.
|
|
var watchBrainstormFn = func(projectsDir string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
sid, err := cc.WatchForNewSession(ctx, cc.SessionWatchOpts{
|
|
ProjectsDir: projectsDir,
|
|
Timeout: 120 * time.Second,
|
|
})
|
|
return sid, err
|
|
}
|
|
|
|
type reviewResultMsg struct {
|
|
err error
|
|
}
|
|
|
|
type testResultMsg struct {
|
|
err error
|
|
}
|
|
|
|
type testSessionCapturedMsg struct {
|
|
issueNumber int
|
|
sessionID string
|
|
}
|
|
|
|
// reviewCmd opens/focuses the review session using codex resume.
|
|
func reviewCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if iss.ReviewSessionID == "" {
|
|
return reviewResultMsg{err: fmt.Errorf("issue #%d has no reviewSessionId", iss.Number)}
|
|
}
|
|
|
|
workspaceRoot, err := workspaceRootFn()
|
|
if err != nil {
|
|
return reviewResultMsg{err: fmt.Errorf("workspace root: %w", err)}
|
|
}
|
|
|
|
if iss.WorktreePath == "" {
|
|
return reviewResultMsg{err: fmt.Errorf("审查会话无法恢复 #%d: worktree 不存在", iss.Number)}
|
|
}
|
|
abs := filepath.Join(workspaceRoot, iss.WorktreePath)
|
|
if _, err := os.Stat(abs); err != nil {
|
|
return reviewResultMsg{err: fmt.Errorf("审查会话无法恢复 #%d: worktree 不存在", iss.Number)}
|
|
}
|
|
|
|
cmd := fmt.Sprintf("codex resume -c model_reasoning_effort=xhigh --dangerously-bypass-approvals-and-sandbox %s", shellQuote(iss.ReviewSessionID))
|
|
|
|
if err := sm.OpenOrFocus(context.Background(), PhaseReview, iss, abs, cmd); err != nil {
|
|
return reviewResultMsg{err: fmt.Errorf("open review session: %w", err)}
|
|
}
|
|
return reviewResultMsg{}
|
|
}
|
|
}
|
|
|
|
// resolveTestSessionCwd returns worktree abs path if it exists on disk, else workspaceRoot.
|
|
func resolveTestSessionCwd(workspaceRoot string, iss issue.Issue) string {
|
|
if iss.WorktreePath != "" {
|
|
abs := filepath.Join(workspaceRoot, iss.WorktreePath)
|
|
if _, err := os.Stat(abs); err == nil {
|
|
return abs
|
|
}
|
|
}
|
|
return workspaceRoot
|
|
}
|
|
|
|
// testCmd starts or resumes the test session.
|
|
func testCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
|
|
return func() tea.Msg {
|
|
workspaceRoot, err := workspaceRootFn()
|
|
if err != nil {
|
|
return testResultMsg{err: fmt.Errorf("workspace root: %w", err)}
|
|
}
|
|
|
|
cwd := resolveTestSessionCwd(workspaceRoot, iss)
|
|
|
|
settings, err := config.Load()
|
|
if err != nil {
|
|
settings = config.DefaultSettings()
|
|
}
|
|
|
|
profilePath := iss.TestProfilePath
|
|
if profilePath == "" {
|
|
profilePath = iss.ProfilePath
|
|
}
|
|
|
|
var command string
|
|
if iss.TestSessionID != "" {
|
|
command = claudeCmd(claudeCmdOpts{
|
|
ResumeSessionID: iss.TestSessionID,
|
|
ProfilePath: profilePath,
|
|
SystemPromptCommand: settings.SystemPromptCommand,
|
|
})
|
|
} else {
|
|
inWorktree := cwd != workspaceRoot
|
|
if !inWorktree && iss.PR == "" {
|
|
return testResultMsg{err: fmt.Errorf("issue #%d: no worktree and no PR for test session", iss.Number)}
|
|
}
|
|
var prompt string
|
|
if inWorktree {
|
|
prompt = "当前工单的改动代码就在这个 worktree 工作目录里,无需用 tea 拉取,直接开始:怎么在本地测试以确保本次改动符合预期?"
|
|
} else {
|
|
prompt = fmt.Sprintf("我已经合并了#%s号pr,你先通过tea熟悉本次pr改动的代码,然后,怎么在本地测试以确保改动符合预期?", iss.PR)
|
|
}
|
|
command = claudeCmd(claudeCmdOpts{
|
|
Prompt: prompt,
|
|
ProfilePath: profilePath,
|
|
SystemPromptCommand: settings.SystemPromptCommand,
|
|
})
|
|
}
|
|
|
|
if err := sm.OpenOrFocus(context.Background(), PhaseTest, iss, cwd, command); err != nil {
|
|
return testResultMsg{err: fmt.Errorf("open test session: %w", err)}
|
|
}
|
|
return testResultMsg{}
|
|
}
|
|
}
|
|
|
|
// watchTestSessionCmd watches for a new claude session in the test cwd's projects dir.
|
|
func watchTestSessionCmd(issueNumber int, cwd string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return testSessionCapturedMsg{issueNumber: issueNumber}
|
|
}
|
|
projectsDir := cc.ClaudeProjectsDir(home, cwd)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
sid, _ := cc.WatchForNewSession(ctx, cc.SessionWatchOpts{
|
|
ProjectsDir: projectsDir,
|
|
Timeout: 120 * time.Second,
|
|
})
|
|
return testSessionCapturedMsg{issueNumber: issueNumber, sessionID: sid}
|
|
}
|
|
}
|
|
|
|
// writeTestSessionCmd persists the captured testSessionId to Gitea state JSON.
|
|
func writeTestSessionCmd(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{
|
|
"testSessionId": sessionID,
|
|
})
|
|
return sessionActionResultMsg{}
|
|
}
|
|
}
|
|
|
|
// InjectFeedback tries impl window first, falls back to test window.
|
|
// Returns true if injection succeeded.
|
|
func InjectFeedback(sm *SessionManager, iss issue.Issue, text string) bool {
|
|
ctx := context.Background()
|
|
session := resolveSessionName()
|
|
|
|
implWin := windowName(iss.Number, PhaseImpl)
|
|
exists, err := sm.windowExists(session, implWin)
|
|
if err == nil && exists {
|
|
if injectErr := sm.Inject(ctx, PhaseImpl, iss, text); injectErr == nil {
|
|
return true
|
|
}
|
|
}
|
|
|
|
testWin := windowName(iss.Number, PhaseTest)
|
|
exists, err = sm.windowExists(session, testWin)
|
|
if err == nil && exists {
|
|
if injectErr := sm.Inject(ctx, PhaseTest, iss, text); injectErr == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// brainstormStartCmd starts a new brainstorm session when no SessionID exists.
|
|
// brainstormStartedMsg is returned by brainstormStartCmd on successful window open.
|
|
// The model uses it to start the session-capture watcher.
|
|
type brainstormStartedMsg struct {
|
|
issueNumber int
|
|
workspaceRoot string
|
|
}
|
|
|
|
// brainstormSessionCapturedMsg carries the captured sessionId for a brainstorm session.
|
|
type brainstormSessionCapturedMsg struct {
|
|
issueNumber int
|
|
sessionID string
|
|
}
|
|
|
|
// brainstormStartCmd starts a fresh brainstorm session (no existing SessionID).
|
|
// On success it returns brainstormStartedMsg so the model can launch the watcher.
|
|
func brainstormStartCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
|
|
return func() tea.Msg {
|
|
workspaceRoot, err := workspaceRootFn()
|
|
if err != nil {
|
|
return sessionActionResultMsg{err: fmt.Errorf("workspace root: %w", err)}
|
|
}
|
|
|
|
settings, err := config.Load()
|
|
if err != nil {
|
|
settings = config.DefaultSettings()
|
|
}
|
|
|
|
prompt := cc.BrainstormContinuePrompt(settings, struct{ IssueNumber string }{
|
|
IssueNumber: strconv.Itoa(iss.Number),
|
|
})
|
|
|
|
command := claudeCmd(claudeCmdOpts{
|
|
Prompt: prompt,
|
|
ProfilePath: iss.ProfilePath,
|
|
SystemPromptCommand: settings.SystemPromptCommand,
|
|
})
|
|
|
|
if err := sm.OpenOrFocus(context.Background(), PhaseBrain, iss, workspaceRoot, command); err != nil {
|
|
return sessionActionResultMsg{err: fmt.Errorf("open brainstorm: %w", err)}
|
|
}
|
|
return brainstormStartedMsg{issueNumber: iss.Number, workspaceRoot: workspaceRoot}
|
|
}
|
|
}
|
|
|
|
// watchBrainstormSessionCmd watches for a new claude session in the workspace projects dir.
|
|
func watchBrainstormSessionCmd(issueNumber int, workspaceRoot string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return brainstormSessionCapturedMsg{issueNumber: issueNumber}
|
|
}
|
|
projectsDir := cc.ClaudeProjectsDir(home, workspaceRoot)
|
|
sid, _ := watchBrainstormFn(projectsDir)
|
|
return brainstormSessionCapturedMsg{issueNumber: issueNumber, sessionID: sid}
|
|
}
|
|
}
|
|
|
|
// writeBrainstormSessionCmd persists the captured sessionId to Gitea state JSON.
|
|
func writeBrainstormSessionCmd(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{
|
|
"sessionId": sessionID,
|
|
})
|
|
return sessionActionResultMsg{}
|
|
}
|
|
}
|