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
12 changes: 12 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Testing Guidelines

## When to write Unit Tests

ALWAYS but we seek for writting tests
for things that are worthwhile not just for
coverage per se.

Always write tests for a bug, before you fix it.

## When Planning

Include what tests you will include and which you don't and why.

## Running Unit Tests

```bash
Expand Down
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ graph LR
subgraph "External Tools"
TMUX[tmux]
GIT[git]
GH[gh CLI]
CLAUDE[claude CLI]
end

Expand All @@ -244,6 +245,7 @@ graph LR
APP --> GORM
APP --> TMUX
APP --> GIT
APP -.-> GH
APP -.-> CLAUDE
```

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,5 @@ After adding new packages/components, update ARCHITECTURE.md diagrams (minimize
- **tmux** (required) - Terminal multiplexer
- **git** (required) - Version control for worktrees
- **claude** (auto-bootstrapped) - Claude Code CLI
- **gh** (optional) - GitHub CLI for PR information and browser opening
- **editor** (optional) - VS Code or any text editor
12 changes: 12 additions & 0 deletions internal/adapters/git/cli_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ func (r *CLIRepository) FetchGitStats(ctx context.Context, worktreePath string)
return fetchGitStats(ctx, worktreePath)
}

// PRInfoProvider methods

// FetchPRInfo implements PRInfoProvider.FetchPRInfo
func (r *CLIRepository) FetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.PRInfo, error) {
return fetchPRInfo(ctx, worktreePath, branchName)
}

// OpenPRInBrowser implements PRInfoProvider.OpenPRInBrowser
func (r *CLIRepository) OpenPRInBrowser(worktreePath string) error {
return openPRInBrowser(worktreePath)
}

// repoSourceToDomain converts local repoSource to domain.RepoSource
func repoSourceToDomain(rs *repoSource) *domain.RepoSource {
if rs == nil {
Expand Down
94 changes: 94 additions & 0 deletions internal/adapters/git/pr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package git

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"time"

"github.com/renato0307/rocha/internal/domain"
"github.com/renato0307/rocha/internal/logging"
)

const prInfoFetchTimeout = 5 * time.Second

// ghPRResponse represents the JSON response from gh pr view
type ghPRResponse struct {
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.
func fetchPRInfo(ctx context.Context, worktreePath, branchName string) (*domain.PRInfo, error) {
logging.Logger.Debug("Fetching PR info", "path", worktreePath, "branch", branchName)

// 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 view for the branch
cmd := exec.CommandContext(ctx, "gh", "pr", "view", branchName, "--json", "number,state,url")
cmd.Dir = worktreePath

output, err := cmd.Output()
if err != nil {
// Check if it's just "no PR found" error (exit code 1)
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.ExitCode() == 1 {
logging.Logger.Debug("No PR found for branch", "branch", branchName)
return &domain.PRInfo{
CheckedAt: time.Now().UTC(),
Number: 0,
State: "",
URL: "",
}, nil
}
}
logging.Logger.Debug("gh pr view failed", "error", err)
return nil, fmt.Errorf("gh pr view failed: %w", err)
}

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

logging.Logger.Debug("Fetched PR info", "number", resp.Number, "state", resp.State, "url", resp.URL)

return &domain.PRInfo{
CheckedAt: time.Now().UTC(),
Number: resp.Number,
State: resp.State,
URL: resp.URL,
}, 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)

// Check if gh is available
if _, err := exec.LookPath("gh"); err != nil {
return fmt.Errorf("gh CLI not found")
}

cmd := exec.Command("gh", "pr", "view", "--web")
cmd.Dir = worktreePath

if err := cmd.Run(); err != nil {
return fmt.Errorf("gh pr view --web failed: %w", err)
}

return nil
}
3 changes: 2 additions & 1 deletion internal/adapters/storage/mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
)

// sessionModelToDomain converts a SessionModel (GORM) to domain.Session
func sessionModelToDomain(m SessionModel, isFlagged bool, status *string, comment string, isArchived bool, allowSkipPerms bool) domain.Session {
func sessionModelToDomain(m SessionModel, isFlagged bool, status *string, comment string, isArchived bool, allowSkipPerms bool, prInfo *domain.PRInfo) domain.Session {
return domain.Session{
AllowDangerouslySkipPermissions: allowSkipPerms,
BranchName: m.BranchName,
Expand All @@ -19,6 +19,7 @@ func sessionModelToDomain(m SessionModel, isFlagged bool, status *string, commen
IsFlagged: isFlagged,
LastUpdated: m.LastUpdated,
Name: m.Name,
PRInfo: prInfo,
RepoInfo: m.RepoInfo,
RepoPath: m.RepoPath,
RepoSource: m.RepoSource,
Expand Down
14 changes: 14 additions & 0 deletions internal/adapters/storage/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,17 @@ type SessionAgentCLIFlagsModel struct {

// TableName specifies the table name for GORM
func (SessionAgentCLIFlagsModel) TableName() string { return "session_agent_cli_flags" }

// SessionPRInfoModel is the GORM model for PR info
type SessionPRInfoModel struct {
CheckedAt time.Time
CreatedAt time.Time
Number int `gorm:"not null;default:0"`
SessionName string `gorm:"primaryKey"`
State string `gorm:"not null;default:''"`
UpdatedAt time.Time
URL string `gorm:"not null;default:''"`
}

// TableName specifies the table name for GORM
func (SessionPRInfoModel) TableName() string { return "session_pr_info" }
85 changes: 79 additions & 6 deletions internal/adapters/storage/sqlite_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
}
}

if !migrator.HasTable(&SessionPRInfoModel{}) {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS session_pr_info (
session_name TEXT PRIMARY KEY,
number INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
checked_at DATETIME,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (session_name) REFERENCES sessions(name) ON UPDATE CASCADE ON DELETE CASCADE
)
`).Error; err != nil {
return nil, fmt.Errorf("failed to create session_pr_info table: %w", err)
}
}

// Configure connection pool
sqlDB, err := db.DB()
if err != nil {
Expand Down Expand Up @@ -242,6 +259,7 @@ func (r *SQLiteRepository) Get(ctx context.Context, name string) (*domain.Sessio
var archive SessionArchiveModel
var agentCLIFlags SessionAgentCLIFlagsModel
var nestedAgentCLIFlags SessionAgentCLIFlagsModel
var prInfo SessionPRInfoModel

err := withRetry(func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Expand All @@ -255,6 +273,7 @@ func (r *SQLiteRepository) Get(ctx context.Context, name string) (*domain.Sessio
tx.Where("session_name = ?", name).First(&comment)
tx.Where("session_name = ?", name).First(&archive)
tx.Where("session_name = ?", name).First(&agentCLIFlags)
tx.Where("session_name = ?", name).First(&prInfo)

// Load nested session
err := tx.Where("parent_name = ?", name).First(&nestedSession).Error
Expand All @@ -278,11 +297,21 @@ func (r *SQLiteRepository) Get(ctx context.Context, name string) (*domain.Sessio
statusPtr = &status.Status
}

result := sessionModelToDomain(session, flag.IsFlagged, statusPtr, comment.Comment, archive.IsArchived, agentCLIFlags.AllowDangerouslySkipPermissions)
var prInfoPtr *domain.PRInfo
if prInfo.Number > 0 || prInfo.URL != "" {
prInfoPtr = &domain.PRInfo{
CheckedAt: prInfo.CheckedAt,
Number: prInfo.Number,
State: prInfo.State,
URL: prInfo.URL,
}
}

result := sessionModelToDomain(session, flag.IsFlagged, statusPtr, comment.Comment, archive.IsArchived, agentCLIFlags.AllowDangerouslySkipPermissions, prInfoPtr)

// Add nested session if found
if nestedSession.Name != "" {
nested := sessionModelToDomain(nestedSession, false, nil, "", false, nestedAgentCLIFlags.AllowDangerouslySkipPermissions)
nested := sessionModelToDomain(nestedSession, false, nil, "", false, nestedAgentCLIFlags.AllowDangerouslySkipPermissions, nil)
result.ShellSession = &nested
}

Expand All @@ -298,6 +327,7 @@ func (r *SQLiteRepository) List(ctx context.Context, includeArchived bool) ([]do
var comments []SessionCommentModel
var archives []SessionArchiveModel
var agentCLIFlags []SessionAgentCLIFlagsModel
var prInfos []SessionPRInfoModel

err := withRetry(func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Expand All @@ -315,6 +345,7 @@ func (r *SQLiteRepository) List(ctx context.Context, includeArchived bool) ([]do
tx.Find(&comments)
tx.Find(&archives)
tx.Find(&agentCLIFlags)
tx.Find(&prInfos)

return nil
})
Expand Down Expand Up @@ -358,13 +389,23 @@ func (r *SQLiteRepository) List(ctx context.Context, includeArchived bool) ([]do
cliMap[f.SessionName] = f.AllowDangerouslySkipPermissions
}

prInfoMap := make(map[string]*domain.PRInfo)
for _, p := range prInfos {
prInfoMap[p.SessionName] = &domain.PRInfo{
CheckedAt: p.CheckedAt,
Number: p.Number,
State: p.State,
URL: p.URL,
}
}

// Convert to domain
result := make([]domain.Session, len(sessions))
for i, sess := range sessions {
result[i] = sessionModelToDomain(sess, flagMap[sess.Name], statusMap[sess.Name], commentMap[sess.Name], archiveMap[sess.Name], cliMap[sess.Name])
result[i] = sessionModelToDomain(sess, flagMap[sess.Name], statusMap[sess.Name], commentMap[sess.Name], archiveMap[sess.Name], cliMap[sess.Name], prInfoMap[sess.Name])

if nested, ok := nestedMap[sess.Name]; ok {
nestedDomain := sessionModelToDomain(nested, false, nil, "", false, cliMap[nested.Name])
nestedDomain := sessionModelToDomain(nested, false, nil, "", false, cliMap[nested.Name], nil)
result[i].ShellSession = &nestedDomain
}
}
Expand Down Expand Up @@ -763,6 +804,26 @@ func (r *SQLiteRepository) UpdateComment(ctx context.Context, name, comment stri
}, 3)
}

// UpdatePRInfo implements SessionMetadataUpdater.UpdatePRInfo
func (r *SQLiteRepository) UpdatePRInfo(ctx context.Context, name string, prInfo *domain.PRInfo) error {
return withRetry(func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if prInfo == nil {
tx.Where("session_name = ?", name).Delete(&SessionPRInfoModel{})
return nil
}

return tx.Save(&SessionPRInfoModel{
CheckedAt: prInfo.CheckedAt,
Number: prInfo.Number,
SessionName: name,
State: prInfo.State,
URL: prInfo.URL,
}).Error
})
}, 3)
}

// LoadState implements SessionStateLoader.LoadState
func (r *SQLiteRepository) LoadState(ctx context.Context, includeArchived bool) (*domain.SessionCollection, error) {
var sessions []SessionModel
Expand All @@ -771,6 +832,7 @@ func (r *SQLiteRepository) LoadState(ctx context.Context, includeArchived bool)
var statuses []SessionStatusModel
var archives []SessionArchiveModel
var agentCLIFlags []SessionAgentCLIFlagsModel
var prInfos []SessionPRInfoModel

err := withRetry(func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Expand All @@ -787,6 +849,7 @@ func (r *SQLiteRepository) LoadState(ctx context.Context, includeArchived bool)
tx.Find(&statuses)
tx.Find(&archives)
tx.Find(&agentCLIFlags)
tx.Find(&prInfos)

// Normalize positions if needed
needsNormalization := false
Expand Down Expand Up @@ -843,6 +906,16 @@ func (r *SQLiteRepository) LoadState(ctx context.Context, includeArchived bool)
cliMap[f.SessionName] = f.AllowDangerouslySkipPermissions
}

prInfoMap := make(map[string]*domain.PRInfo)
for _, p := range prInfos {
prInfoMap[p.SessionName] = &domain.PRInfo{
CheckedAt: p.CheckedAt,
Number: p.Number,
State: p.State,
URL: p.URL,
}
}

// Build result
collection := &domain.SessionCollection{
OrderedNames: make([]string, len(sessions)),
Expand All @@ -852,12 +925,12 @@ func (r *SQLiteRepository) LoadState(ctx context.Context, includeArchived bool)
for i, sess := range sessions {
collection.OrderedNames[i] = sess.Name

domainSess := sessionModelToDomain(sess, flagMap[sess.Name], statusMap[sess.Name], commentMap[sess.Name], archiveMap[sess.Name], cliMap[sess.Name])
domainSess := sessionModelToDomain(sess, flagMap[sess.Name], statusMap[sess.Name], commentMap[sess.Name], archiveMap[sess.Name], cliMap[sess.Name], prInfoMap[sess.Name])

// Load nested session
var nestedSession SessionModel
if err := r.db.Where("parent_name = ?", sess.Name).First(&nestedSession).Error; err == nil {
nested := sessionModelToDomain(nestedSession, false, nil, "", false, cliMap[nestedSession.Name])
nested := sessionModelToDomain(nestedSession, false, nil, "", false, cliMap[nestedSession.Name], nil)
domainSess.ShellSession = &nested
}

Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ type RunCmd struct {
Dev bool `help:"Enable development mode (shows version info in dialogs)"`
Editor string `help:"Editor to open sessions in (overrides $ROCHA_EDITOR, $VISUAL, $EDITOR)" default:"code"`
ErrorClearDelay int `help:"Seconds before error messages auto-clear" default:"10"`
ShowPRNumber bool `help:"Show PR number in git stats (fetched on detach)" default:"true"`
ShowTimestamps bool `help:"Show relative timestamps for last state changes" default:"false"`
ShowTokenChart bool `help:"Show token usage chart by default" default:"false"`
StatusColors string `help:"Comma-separated ANSI color codes for statuses (e.g., '141,33,214,226,46')" default:"141,33,214,226,46"`
Expand Down Expand Up @@ -210,6 +211,13 @@ func (r *RunCmd) Run(cli *CLI) error {
r.ShowTokenChart = true
}
}

// Apply ShowPRNumber setting (default is true, so check for explicit false)
if r.ShowPRNumber {
if cli.settings.ShowPRNumber != nil && !*cli.settings.ShowPRNumber {
r.ShowPRNumber = false
}
}
}

logging.Logger.Info("Starting rocha TUI")
Expand Down Expand Up @@ -297,6 +305,7 @@ func (r *RunCmd) Run(cli *CLI) error {
r.Dev,
r.ShowTimestamps,
r.ShowTokenChart,
r.ShowPRNumber,
r.TmuxStatusPosition,
allowDangerouslySkipPermissionsDefault,
tipsConfig,
Expand Down
Loading