Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,46 @@ go test ./internal/services/... -run TestCreateSession

## Test Patterns

Follow the established patterns from existing tests:
### Table-Driven Tests (Preferred)

Use table-driven tests for functions with multiple scenarios:

```go
func TestFunctionName(t *testing.T) {
tests := []struct {
name string
input string
expected int
assertErr assert.ErrorAssertionFunc
}{
{
name: "valid input",
input: "hello",
expected: 5,
assertErr: assert.NoError,
},
{
name: "empty input returns error",
input: "",
expected: 0,
assertErr: assert.Error,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := FunctionName(tt.input)

tt.assertErr(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
```

### Single Scenario Tests

For tests with mocks or complex setup:

```go
func TestFunctionName_Scenario(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/adapters/git/cli_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ func (r *CLIRepository) FetchGitStats(ctx context.Context, worktreePath string)

// PRInfoProvider methods

// FetchAllPRs implements PRInfoProvider.FetchAllPRs
func (r *CLIRepository) FetchAllPRs(ctx context.Context, repoPath string) (map[string]*domain.PRInfo, error) {
return fetchAllPRs(ctx, repoPath)
}

// FetchPRInfo implements PRInfoProvider.FetchPRInfo
func (r *CLIRepository) FetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.PRInfo, error) {
return fetchPRInfo(ctx, worktreePath, branchName)
Expand Down
56 changes: 56 additions & 0 deletions internal/adapters/git/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ type ghPRResponse struct {
URL string `json:"url"`
}

// ghPRListResponse represents a single PR from gh pr list output
type ghPRListResponse struct {
HeadRefName string `json:"headRefName"`
Number int `json:"number"`
State string `json:"state"`
URL string `json:"url"`
}

// fetchPRInfo fetches PR information for a branch using gh CLI.
// Returns (nil, nil) if gh CLI is not installed.
// Returns (PRInfo with Number=0, nil) if no PR exists for the branch.
Expand Down Expand Up @@ -74,6 +82,54 @@ func fetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.
}, nil
}

// fetchAllPRs fetches all PRs for a repository in one call.
// Returns map[branchName]*PRInfo where branchName is the head branch of the PR.
// Returns (nil, nil) if gh CLI is not installed.
func fetchAllPRs(ctx context.Context, repoPath string) (map[string]*domain.PRInfo, error) {
logging.Logger.Debug("Fetching all PRs for repo", "path", repoPath)

// Check if gh is available
if _, err := exec.LookPath("gh"); err != nil {
logging.Logger.Debug("gh CLI not found, skipping PR fetch")
return nil, nil
}

// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, prInfoFetchTimeout)
defer cancel()

// Run gh pr list for all PRs in the repo
cmd := exec.CommandContext(ctx, "gh", "pr", "list", "--state", "all", "--json", "number,headRefName,state,url", "--limit", "100")
cmd.Dir = repoPath

output, err := cmd.Output()
if err != nil {
logging.Logger.Debug("gh pr list failed", "error", err)
return nil, fmt.Errorf("gh pr list failed: %w", err)
}

var prList []ghPRListResponse
if err := json.Unmarshal(output, &prList); err != nil {
logging.Logger.Debug("Failed to parse gh pr list output", "error", err, "output", string(output))
return nil, fmt.Errorf("failed to parse gh response: %w", err)
}

// Build map by branch name
result := make(map[string]*domain.PRInfo, len(prList))
checkedAt := time.Now().UTC()
for _, pr := range prList {
result[pr.HeadRefName] = &domain.PRInfo{
CheckedAt: checkedAt,
Number: pr.Number,
State: pr.State,
URL: pr.URL,
}
}

logging.Logger.Debug("Fetched all PRs", "repo", repoPath, "count", len(result))
return result, nil
}

// openPRInBrowser opens the PR URL in the default browser using gh CLI
func openPRInBrowser(worktreePath string) error {
logging.Logger.Debug("Opening PR in browser", "path", worktreePath)
Expand Down
1 change: 1 addition & 0 deletions internal/ports/git_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type GitStatsProvider interface {

// PRInfoProvider provides PR information for UI
type PRInfoProvider interface {
FetchAllPRs(ctx context.Context, repoPath string) (map[string]*domain.PRInfo, error)
FetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.PRInfo, error)
OpenPRInBrowser(worktreePath string) error
}
Expand Down
68 changes: 68 additions & 0 deletions internal/ports/mocks/mock_git_repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/services/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ func (s *GitService) GetBranchName(path string) string {
return s.gitRepo.GetBranchName(path)
}

// FetchAllPRs fetches all PRs for a repository in one call
func (s *GitService) FetchAllPRs(ctx context.Context, repoPath string) (map[string]*domain.PRInfo, error) {
return s.gitRepo.FetchAllPRs(ctx, repoPath)
}

// FetchPRInfo fetches PR information for a branch
func (s *GitService) FetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.PRInfo, error) {
return s.gitRepo.FetchPRInfo(ctx, worktreePath, branchName)
Expand Down
53 changes: 37 additions & 16 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,18 @@ func NewModel(
}

func (m *Model) Init() tea.Cmd {
// Delegate to session list component (starts auto-refresh polling)
return m.sessionList.Init()
cmds := []tea.Cmd{m.sessionList.Init()}

// Batch fetch PR info for all sessions on startup
if m.showPRNumber {
requests := GroupSessionsByRepo(m.sessionState.Sessions)
if len(requests) > 0 {
logging.Logger.Debug("Triggering batch PR fetch on init", "repos", len(requests))
cmds = append(cmds, StartBatchPRInfoFetcher(m.gitService, requests))
}
}

return tea.Batch(cmds...)
}

func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -397,6 +407,24 @@ func (m *Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
logging.Logger.Debug("PR info fetch failed", "session", msg.SessionName, "error", msg.Err)
return m, nil

case BatchPRInfoReadyMsg:
// Batch PR info fetched - update all sessions
logging.Logger.Debug("Received batch PR info", "count", len(msg.Results))
for sessionName, prInfo := range msg.Results {
if sessionInfo, exists := m.sessionState.Sessions[sessionName]; exists {
sessionInfo.PRInfo = prInfo
m.sessionState.Sessions[sessionName] = sessionInfo

// Persist to database
if err := m.sessionService.UpdatePRInfo(context.Background(), sessionName, prInfo); err != nil {
logging.Logger.Error("Failed to persist PR info", "error", err, "session", sessionName)
}
}
}
// Single UI refresh after all updates
refreshCmd := m.sessionList.RefreshFromState()
return m, tea.Batch(refreshCmd, m.sessionList.Init())

case OpenPRMsg:
// Open PR in browser for session
sessionInfo, exists := m.sessionState.Sessions[msg.SessionName]
Expand Down Expand Up @@ -432,24 +460,17 @@ func (m *Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
}

// Handle detach message - session list auto-refreshes via polling
if detachMsg, ok := msg.(detachedMsg); ok {
if _, ok := msg.(detachedMsg); ok {
m.state = stateList
refreshCmd := m.sessionList.RefreshFromState()

// Trigger PR fetch for detached session if enabled
// Trigger batch PR fetch for all sessions if enabled
var prFetchCmd tea.Cmd
if m.showPRNumber && detachMsg.SessionName != "" {
if sessionInfo, exists := m.sessionState.Sessions[detachMsg.SessionName]; exists {
if sessionInfo.WorktreePath != "" && sessionInfo.BranchName != "" {
logging.Logger.Debug("Triggering PR fetch on detach",
"session", detachMsg.SessionName,
"branch", sessionInfo.BranchName)
prFetchCmd = StartPRInfoFetcher(m.gitService, PRInfoRequest{
BranchName: sessionInfo.BranchName,
SessionName: detachMsg.SessionName,
WorktreePath: sessionInfo.WorktreePath,
})
}
if m.showPRNumber {
requests := GroupSessionsByRepo(m.sessionState.Sessions)
if len(requests) > 0 {
logging.Logger.Debug("Triggering batch PR fetch on detach", "repos", len(requests))
prFetchCmd = StartBatchPRInfoFetcher(m.gitService, requests)
}
}

Expand Down
Loading