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
16 changes: 8 additions & 8 deletions cmd/roborev/refine.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ func runRefine(agentName, modelStr, reasoningStr string, maxIterations int, quie
if verdict == "F" && !review.Addressed {
currentFailedReview = review
} else if verdict == "P" {
if err := client.MarkReviewAddressed(review.ID); err != nil {
fmt.Printf("Warning: failed to mark review %d as addressed: %v\n", review.ID, err)
if err := client.MarkReviewAddressed(review.JobID); err != nil {
fmt.Printf("Warning: failed to mark review (job %d) as addressed: %v\n", review.JobID, err)
}
continue // Loop back to check for more
}
Expand Down Expand Up @@ -484,8 +484,8 @@ func runRefine(agentName, modelStr, reasoningStr string, maxIterations int, quie
client.AddComment(currentFailedReview.JobID, "roborev-refine", responseText)

// Mark old review as addressed
if err := client.MarkReviewAddressed(currentFailedReview.ID); err != nil {
fmt.Printf("Warning: failed to mark review %d as addressed: %v\n", currentFailedReview.ID, err)
if err := client.MarkReviewAddressed(currentFailedReview.JobID); err != nil {
fmt.Printf("Warning: failed to mark review (job %d) as addressed: %v\n", currentFailedReview.JobID, err)
}

// Wait for new commit to be reviewed (if post-commit hook triggers it)
Expand All @@ -511,8 +511,8 @@ func runRefine(agentName, modelStr, reasoningStr string, maxIterations int, quie
verdict := storage.ParseVerdict(review.Output)
if verdict == "P" {
fmt.Println("New commit passed review!")
if err := client.MarkReviewAddressed(review.ID); err != nil {
fmt.Printf("Warning: failed to mark review %d as addressed: %v\n", review.ID, err)
if err := client.MarkReviewAddressed(review.JobID); err != nil {
fmt.Printf("Warning: failed to mark review (job %d) as addressed: %v\n", review.JobID, err)
}
currentFailedReview = nil // Move on to next oldest failed commit
} else {
Expand Down Expand Up @@ -573,8 +573,8 @@ func findFailedReviewForBranch(client daemon.Client, commits []string, skip map[

// Mark passing reviews as addressed so they don't need to be checked again
if verdict == "P" {
if err := client.MarkReviewAddressed(review.ID); err != nil {
return nil, fmt.Errorf("marking review %d as addressed: %w", review.ID, err)
if err := client.MarkReviewAddressed(review.JobID); err != nil {
return nil, fmt.Errorf("marking review (job %d) as addressed: %w", review.JobID, err)
}
}
}
Expand Down
54 changes: 27 additions & 27 deletions cmd/roborev/refine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type mockDaemonClient struct {
responses map[int64][]storage.Response

// Track calls for assertions
addressedReviews []int64
addressedJobIDs []int64
addedComments []addedComment
enqueuedReviews []enqueuedReview

Expand Down Expand Up @@ -70,11 +70,11 @@ func (m *mockDaemonClient) GetReviewByJobID(jobID int64) (*storage.Review, error
return m.reviews[job.GitRef], nil
}

func (m *mockDaemonClient) MarkReviewAddressed(reviewID int64) error {
func (m *mockDaemonClient) MarkReviewAddressed(jobID int64) error {
if m.markAddressedErr != nil {
return m.markAddressedErr
}
m.addressedReviews = append(m.addressedReviews, reviewID)
m.addressedJobIDs = append(m.addressedJobIDs, jobID)
return nil
}

Expand Down Expand Up @@ -497,18 +497,18 @@ func TestFindFailedReviewForBranch_MarksPassingAsAddressed(t *testing.T) {
t.Errorf("expected no failed reviews, got job %d", found.JobID)
}

// Both passing reviews should be marked as addressed
if len(client.addressedReviews) != 2 {
t.Errorf("expected 2 reviews to be marked addressed, got %d", len(client.addressedReviews))
// Both passing reviews should be marked as addressed (by job ID)
if len(client.addressedJobIDs) != 2 {
t.Errorf("expected 2 reviews to be marked addressed, got %d", len(client.addressedJobIDs))
}

// Verify the specific review IDs were marked
// Verify the specific job IDs were marked
addressed := make(map[int64]bool)
for _, id := range client.addressedReviews {
for _, id := range client.addressedJobIDs {
addressed[id] = true
}
if !addressed[1] || !addressed[2] {
t.Errorf("expected reviews 1 and 2 to be marked addressed, got %v", client.addressedReviews)
if !addressed[100] || !addressed[200] {
t.Errorf("expected jobs 100 and 200 to be marked addressed, got %v", client.addressedJobIDs)
}
}

Expand All @@ -533,12 +533,12 @@ func TestFindFailedReviewForBranch_MarksPassingBeforeFailure(t *testing.T) {
t.Errorf("expected failed review (job 200), got %v", found)
}

// Passing review before the failure should be marked addressed
if len(client.addressedReviews) != 1 {
t.Errorf("expected 1 review to be marked addressed, got %d", len(client.addressedReviews))
// Passing review before the failure should be marked addressed (by job ID)
if len(client.addressedJobIDs) != 1 {
t.Errorf("expected 1 review to be marked addressed, got %d", len(client.addressedJobIDs))
}
if len(client.addressedReviews) > 0 && client.addressedReviews[0] != 1 {
t.Errorf("expected review 1 to be marked addressed, got %v", client.addressedReviews)
if len(client.addressedJobIDs) > 0 && client.addressedJobIDs[0] != 100 {
t.Errorf("expected job 100 to be marked addressed, got %v", client.addressedJobIDs)
}
}

Expand All @@ -563,8 +563,8 @@ func TestFindFailedReviewForBranch_DoesNotMarkAlreadyAddressed(t *testing.T) {
}

// Already-addressed review should NOT be marked again
if len(client.addressedReviews) != 0 {
t.Errorf("expected no reviews to be marked addressed (already addressed), got %v", client.addressedReviews)
if len(client.addressedJobIDs) != 0 {
t.Errorf("expected no reviews to be marked addressed (already addressed), got %v", client.addressedJobIDs)
}
}

Expand Down Expand Up @@ -598,16 +598,16 @@ func TestFindFailedReviewForBranch_MixedScenario(t *testing.T) {
}

// commit1 and commit4 should be marked as addressed (unaddressed passing reviews)
if len(client.addressedReviews) != 2 {
t.Errorf("expected 2 reviews to be marked addressed, got %d: %v", len(client.addressedReviews), client.addressedReviews)
if len(client.addressedJobIDs) != 2 {
t.Errorf("expected 2 reviews to be marked addressed, got %d: %v", len(client.addressedJobIDs), client.addressedJobIDs)
}

addressed := make(map[int64]bool)
for _, id := range client.addressedReviews {
for _, id := range client.addressedJobIDs {
addressed[id] = true
}
if !addressed[1] || !addressed[4] {
t.Errorf("expected reviews 1 and 4 to be marked addressed, got %v", client.addressedReviews)
if !addressed[100] || !addressed[400] {
t.Errorf("expected jobs 100 and 400 to be marked addressed, got %v", client.addressedJobIDs)
}
}

Expand Down Expand Up @@ -635,8 +635,8 @@ func TestFindFailedReviewForBranch_StopsAtFirstFailure(t *testing.T) {
}

// No reviews should be marked as addressed (we stopped at first failure)
if len(client.addressedReviews) != 0 {
t.Errorf("expected no reviews to be marked addressed, got %v", client.addressedReviews)
if len(client.addressedJobIDs) != 0 {
t.Errorf("expected no reviews to be marked addressed, got %v", client.addressedJobIDs)
}
}

Expand All @@ -663,10 +663,10 @@ func TestFindFailedReviewForBranch_MarkAddressedError(t *testing.T) {
t.Errorf("expected nil review when error occurs, got job %d", found.JobID)
}

// Error message should indicate which review failed
expectedMsg := "marking review 1 as addressed"
// Error message should indicate which job failed
expectedMsg := "marking review (job 100) as addressed"
if !strings.Contains(err.Error(), expectedMsg) {
t.Errorf("error should mention review ID, got: %v", err)
t.Errorf("error should mention job ID, got: %v", err)
}
}

Expand Down
13 changes: 13 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ type SyncConfig struct {

// ConnectTimeout is the connection timeout (e.g., "5s"). Default: 5s
ConnectTimeout string `toml:"connect_timeout"`

// RepoNames provides custom display names for synced repos by identity.
// Example: {"git@github.com:org/repo.git": "my-project"}
RepoNames map[string]string `toml:"repo_names"`
}

// PostgresURLExpanded returns the PostgreSQL URL with environment variables expanded.
Expand All @@ -61,6 +65,15 @@ func (c *SyncConfig) PostgresURLExpanded() string {
return os.ExpandEnv(c.PostgresURL)
}

// GetRepoDisplayName returns the configured display name for a repo identity,
// or empty string if no override is configured.
func (c *SyncConfig) GetRepoDisplayName(identity string) string {
if c == nil || c.RepoNames == nil {
return ""
}
return c.RepoNames[identity]
}

// Validate checks the sync configuration for common issues.
// Returns a list of warnings (non-fatal issues).
func (c *SyncConfig) Validate() []string {
Expand Down
39 changes: 39 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@

// Test per-repo config
repoConfig := filepath.Join(tmpDir, ".roborev.toml")
os.WriteFile(repoConfig, []byte(`agent = "claude-code"`), 0644)

Check failure on line 99 in internal/config/config_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `os.WriteFile` is not checked (errcheck)

agent = ResolveAgent("", tmpDir, cfg)
if agent != "claude-code" {
Expand Down Expand Up @@ -570,6 +570,45 @@
})
}

func TestSyncConfigGetRepoDisplayName(t *testing.T) {
t.Run("nil receiver returns empty", func(t *testing.T) {
var cfg *SyncConfig
if got := cfg.GetRepoDisplayName("any"); got != "" {
t.Errorf("Expected empty string for nil receiver, got %q", got)
}
})

t.Run("nil map returns empty", func(t *testing.T) {
cfg := &SyncConfig{}
if got := cfg.GetRepoDisplayName("any"); got != "" {
t.Errorf("Expected empty string for nil map, got %q", got)
}
})

t.Run("missing key returns empty", func(t *testing.T) {
cfg := &SyncConfig{
RepoNames: map[string]string{
"git@github.com:org/repo.git": "my-repo",
},
}
if got := cfg.GetRepoDisplayName("unknown"); got != "" {
t.Errorf("Expected empty string for missing key, got %q", got)
}
})

t.Run("returns configured name", func(t *testing.T) {
cfg := &SyncConfig{
RepoNames: map[string]string{
"git@github.com:org/repo.git": "my-custom-name",
},
}
expected := "my-custom-name"
if got := cfg.GetRepoDisplayName("git@github.com:org/repo.git"); got != expected {
t.Errorf("Expected %q, got %q", expected, got)
}
})
}

func TestSyncConfigValidate(t *testing.T) {
t.Run("disabled returns no warnings", func(t *testing.T) {
cfg := SyncConfig{Enabled: false}
Expand Down
8 changes: 4 additions & 4 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ type Client interface {
// GetReviewByJobID retrieves a review by job ID
GetReviewByJobID(jobID int64) (*storage.Review, error)

// MarkReviewAddressed marks a review as addressed
MarkReviewAddressed(reviewID int64) error
// MarkReviewAddressed marks a review as addressed by job ID
MarkReviewAddressed(jobID int64) error

// AddComment adds a comment to a job
AddComment(jobID int64, commenter, comment string) error
Expand Down Expand Up @@ -130,9 +130,9 @@ func (c *HTTPClient) GetReviewByJobID(jobID int64) (*storage.Review, error) {
return &review, nil
}

func (c *HTTPClient) MarkReviewAddressed(reviewID int64) error {
func (c *HTTPClient) MarkReviewAddressed(jobID int64) error {
reqBody, _ := json.Marshal(map[string]interface{}{
"review_id": reviewID,
"job_id": jobID,
"addressed": true,
})

Expand Down
4 changes: 2 additions & 2 deletions internal/daemon/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ func TestHTTPClientMarkReviewAddressed(t *testing.T) {
t.Fatalf("MarkReviewAddressed failed: %v", err)
}

if received["review_id"].(float64) != 99 {
t.Errorf("expected review_id 99, got %v", received["review_id"])
if received["job_id"].(float64) != 99 {
t.Errorf("expected job_id 99, got %v", received["job_id"])
}
if received["addressed"] != true {
t.Errorf("expected addressed true, got %v", received["addressed"])
Expand Down
33 changes: 20 additions & 13 deletions internal/storage/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,27 +660,34 @@ func (db *DB) migrateSyncColumns() error {
}
}

// Create unique index on repos.identity (allows NULL values, only enforces uniqueness on non-NULL)
// First normalize empty strings to NULL (treat empty as "unset")
// Normalize empty strings to NULL (treat empty as "unset")
_, err = db.Exec(`UPDATE repos SET identity = NULL WHERE identity = ''`)
if err != nil {
return fmt.Errorf("normalize empty identities to NULL: %w", err)
}

// Check for duplicates that would prevent index creation
var dupCount int
err = db.QueryRow(`SELECT COUNT(*) FROM (
SELECT identity FROM repos WHERE identity IS NOT NULL
GROUP BY identity HAVING COUNT(*) > 1
)`).Scan(&dupCount)
if err != nil {
return fmt.Errorf("check duplicate identities: %w", err)
// Create non-unique index on repos.identity for query performance.
// Note: identity is NOT unique because multiple local clones of the same repo
// (e.g., ~/project-1 and ~/project-2 both cloned from the same remote)
// should be allowed and will share the same identity.
// See: https://github.com/roborev-dev/roborev/issues/131

// Migration: If an old UNIQUE index exists, drop it first
var indexSQL sql.NullString
err = db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_repos_identity'`).Scan(&indexSQL)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("check existing idx_repos_identity: %w", err)
}
if dupCount > 0 {
return fmt.Errorf("cannot create unique index on repos.identity: %d duplicate non-NULL identities exist; resolve duplicates before upgrading", dupCount)
if indexSQL.Valid && strings.Contains(strings.ToUpper(indexSQL.String), "UNIQUE") {
// Drop the old unique index
_, err = db.Exec(`DROP INDEX idx_repos_identity`)
if err != nil {
return fmt.Errorf("drop old unique idx_repos_identity: %w", err)
}
}

_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_repos_identity ON repos(identity) WHERE identity IS NOT NULL`)
// Create non-unique index (or recreate after dropping unique)
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_repos_identity ON repos(identity) WHERE identity IS NOT NULL`)
if err != nil {
return fmt.Errorf("create idx_repos_identity: %w", err)
}
Expand Down
Loading
Loading