diff --git a/internal/config/config.go b/internal/config/config.go index ee56d4c8..fa89b398 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -165,6 +165,9 @@ type Config struct { // Analysis settings DefaultMaxPromptSize int `toml:"default_max_prompt_size"` // Max prompt size in bytes before falling back to paths (default: 200KB) + // Behavior + AutoClosePassingReviews bool `toml:"auto_close_passing_reviews" comment:"Automatically close reviews that pass with no findings."` + // UI preferences HideClosedByDefault bool `toml:"hide_closed_by_default" comment:"Hide closed reviews by default in the TUI queue."` HideAddressedByDefault bool `toml:"hide_addressed_by_default"` // deprecated: use hide_closed_by_default @@ -673,6 +676,9 @@ type RepoConfig struct { SecurityBackupModel string `toml:"security_backup_model" comment:"Backup model for security review in this repo."` DesignBackupModel string `toml:"design_backup_model" comment:"Backup model for design review in this repo."` + // Behavior + AutoClosePassingReviews *bool `toml:"auto_close_passing_reviews" comment:"Automatically close reviews that pass with no findings in this repo."` + // Hooks configuration (per-repo) Hooks []HookConfig `toml:"hooks"` @@ -1117,6 +1123,20 @@ func ResolveJobTimeout(repoPath string, globalCfg *Config) int { return resolve(30, repoVal, globalVal) } +// ResolveAutoClosePassingReviews returns whether passing reviews should +// be automatically closed. Per-repo config overrides global. +func ResolveAutoClosePassingReviews(repoPath string, globalCfg *Config) bool { + var repoVal *bool + if repoCfg, err := LoadRepoConfig(repoPath); err == nil && repoCfg != nil { + repoVal = repoCfg.AutoClosePassingReviews + } + var globalVal bool + if globalCfg != nil { + globalVal = globalCfg.AutoClosePassingReviews + } + return resolveBool(globalVal, repoVal) +} + // ResolveExcludePatterns returns the merged exclude patterns from // repo config and global config. Repo patterns are read from the // default branch (like review guidelines) to prevent untrusted diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b790c8eb..ce9f03e9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -347,6 +347,45 @@ func TestResolveJobTimeout(t *testing.T) { } } +func TestResolveAutoClosePassingReviews(t *testing.T) { + tests := []struct { + name string + repoConfig string + globalConfig *Config + want bool + }{ + { + name: "default false", + want: false, + }, + { + name: "global enabled", + globalConfig: &Config{AutoClosePassingReviews: true}, + want: true, + }, + { + name: "repo overrides global to true", + repoConfig: `auto_close_passing_reviews = true`, + globalConfig: &Config{AutoClosePassingReviews: false}, + want: true, + }, + { + name: "repo overrides global to false", + repoConfig: `auto_close_passing_reviews = false`, + globalConfig: &Config{AutoClosePassingReviews: true}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := newTempRepo(t, tt.repoConfig) + got := ResolveAutoClosePassingReviews(tmpDir, tt.globalConfig) + assert.Equal(t, tt.want, got) + }) + } +} + func TestResolveReasoning(t *testing.T) { type resolverFunc func(explicit string, dir string) (string, error) diff --git a/internal/daemon/worker.go b/internal/daemon/worker.go index 508e1a89..566aa633 100644 --- a/internal/daemon/worker.go +++ b/internal/daemon/worker.go @@ -610,6 +610,16 @@ func (wp *WorkerPool) processJob(workerID string, job *storage.ReviewJob) { } } + // Auto-close passing reviews when configured. + if job.IsReviewJob() && storage.ParseVerdict(output) == "P" { + cfg := wp.cfgGetter.Config() + if config.ResolveAutoClosePassingReviews(job.RepoPath, cfg) { + if err := wp.db.MarkReviewClosedByJobID(job.ID, true); err != nil { + log.Printf("[%s] Warning: auto-close passing review for job %d: %v", workerID, job.ID, err) + } + } + } + // Fetch token usage from agentsview (best-effort). // Only collect for fresh sessions (where we captured a new session ID). // Resumed sessions report cumulative totals across all turns, which diff --git a/internal/daemon/worker_test.go b/internal/daemon/worker_test.go index 94ffb95d..0d8be6c0 100644 --- a/internal/daemon/worker_test.go +++ b/internal/daemon/worker_test.go @@ -1156,3 +1156,80 @@ func TestFailOrRetryInner_RetryExhaustedPassesBackupModel(t *testing.T) { }, "model=%q, want backup-model", updated.Model) } } + +func TestAutoClosePassingReviews(t *testing.T) { + t.Parallel() + + // Register a test agent whose output parses as a clear pass verdict. + const passAgentName = "auto-close-pass-agent" + agent.Register(&agent.FakeAgent{ + NameStr: passAgentName, + ReviewFn: func(_ context.Context, _, _, _ string, w io.Writer) (string, error) { + out := "No issues found." + _, _ = w.Write([]byte(out)) + return out, nil + }, + }) + t.Cleanup(func() { agent.Unregister(passAgentName) }) + + tests := []struct { + name string + enabled bool + wantClosed bool + }{ + {"enabled", true, true}, + {"disabled", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tc := newWorkerTestContext(t, 1) + cfg := config.DefaultConfig() + cfg.AutoClosePassingReviews = tt.enabled + tc.reconfigurePool(cfg) + + sha := testutil.GetHeadSHA(t, tc.TmpDir) + job := tc.createAndClaimJobWithAgent(t, sha, testWorkerID, passAgentName) + + tc.Pool.processJob(testWorkerID, job) + + tc.assertJobStatus(t, job.ID, storage.JobStatusDone) + review, err := tc.DB.GetReviewByJobID(job.ID) + require.NoError(t, err) + assert.Equal(t, tt.wantClosed, review.Closed) + }) + } + + // Non-review job types must never be auto-closed, even with the setting enabled. + t.Run("skips_non_review_jobs", func(t *testing.T) { + t.Parallel() + tc := newWorkerTestContext(t, 1) + cfg := config.DefaultConfig() + cfg.AutoClosePassingReviews = true + tc.reconfigurePool(cfg) + + sha := testutil.GetHeadSHA(t, tc.TmpDir) + commit, err := tc.DB.GetOrCreateCommit(tc.Repo.ID, sha, "Author", "Subject", time.Now()) + require.NoError(t, err) + job, err := tc.DB.EnqueueJob(storage.EnqueueOpts{ + RepoID: tc.Repo.ID, + CommitID: commit.ID, + GitRef: sha, + Agent: passAgentName, + JobType: "task", + Prompt: "test prompt", + }) + require.NoError(t, err) + claimed, err := tc.DB.ClaimJob(testWorkerID) + require.NoError(t, err) + require.Equal(t, job.ID, claimed.ID) + + tc.Pool.processJob(testWorkerID, claimed) + + tc.assertJobStatus(t, job.ID, storage.JobStatusDone) + review, err := tc.DB.GetReviewByJobID(job.ID) + require.NoError(t, err) + assert.False(t, review.Closed, "task job should not be auto-closed") + }) +}