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
1 change: 1 addition & 0 deletions changelog.d/added-review-max-perspectives-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a **Max perspectives per PR** control to Settings → Features → Review Gate, so the per-PR perspective cap can be set from the dashboard instead of only in YAML. Without it the cap shipped unreachable: parallel review slots are spent in PR order, so the first PR in a deep queue absorbs every slot and adding reviewers buys more opinions on one PR instead of coverage across many. 0 or empty keeps the existing no-cap default.
9 changes: 9 additions & 0 deletions src/pkg/dashboard/api_config_review.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func (s *Server) handleReviewConfigPut(w http.ResponseWriter, r *http.Request) {
RequireApproval *bool `json:"require_approval"`
FanOut *bool `json:"fan_out"`
MaxParallelReviews *int `json:"max_parallel_reviews"`
MaxPerspectives *int `json:"max_perspectives_per_pr"`
ReviewerAgents *[]string `json:"reviewer_agents"`
FixerAgent *string `json:"fixer_agent"`
AllAuthors *bool `json:"all_authors"`
Expand All @@ -45,6 +46,11 @@ func (s *Server) handleReviewConfigPut(w http.ResponseWriter, r *http.Request) {
return
}

if body.MaxPerspectives != nil && *body.MaxPerspectives < 0 {
jsonError(w, "max_perspectives_per_pr must be >= 0", http.StatusBadRequest)
return
}

cfg := s.deps.Config
if body.RequireApproval != nil {
cfg.Review.RequireApproval = *body.RequireApproval
Expand All @@ -55,6 +61,9 @@ func (s *Server) handleReviewConfigPut(w http.ResponseWriter, r *http.Request) {
if body.MaxParallelReviews != nil {
cfg.Review.MaxParallelReviews = *body.MaxParallelReviews
}
if body.MaxPerspectives != nil {
cfg.Review.MaxPerspectivesPerPR = *body.MaxPerspectives
}
if body.ReviewerAgents != nil {
agents := make([]string, 0, len(*body.ReviewerAgents))
for _, a := range *body.ReviewerAgents {
Expand Down
33 changes: 33 additions & 0 deletions src/pkg/dashboard/api_config_sections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,39 @@ func TestReviewConfigPut_ValidatesAndApplies(t *testing.T) {
}
}

func TestReviewConfigPut_MaxPerspectivesPerPR(t *testing.T) {
s := covApiServer(t)

if rec := doPut(s, "/api/config/review", map[string]any{"max_perspectives_per_pr": -1}); rec.Code != http.StatusBadRequest {
t.Fatalf("negative max_perspectives_per_pr: expected 400, got %d", rec.Code)
}
if s.deps.Config.Review.MaxPerspectivesPerPR != 0 {
t.Fatalf("rejected write still mutated config: %d", s.deps.Config.Review.MaxPerspectivesPerPR)
}

if rec := doPut(s, "/api/config/review", map[string]any{"max_perspectives_per_pr": 1}); rec.Code != http.StatusOK {
t.Fatalf("valid put: expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if s.deps.Config.Review.MaxPerspectivesPerPR != 1 {
t.Fatalf("cap not applied: %d", s.deps.Config.Review.MaxPerspectivesPerPR)
}

// Absent key leaves the cap untouched; an explicit 0 clears it back to
// "no cap", which is the documented way to turn the cap off.
if rec := doPut(s, "/api/config/review", map[string]any{}); rec.Code != http.StatusOK {
t.Fatalf("empty put: expected 200, got %d", rec.Code)
}
if s.deps.Config.Review.MaxPerspectivesPerPR != 1 {
t.Fatalf("empty put mutated the cap: %d", s.deps.Config.Review.MaxPerspectivesPerPR)
}
if rec := doPut(s, "/api/config/review", map[string]any{"max_perspectives_per_pr": 0}); rec.Code != http.StatusOK {
t.Fatalf("clearing put: expected 200, got %d", rec.Code)
}
if s.deps.Config.Review.MaxPerspectivesPerPR != 0 {
t.Fatalf("cap not cleared: %d", s.deps.Config.Review.MaxPerspectivesPerPR)
}
}

func TestReviewConfigPut_RejectsNonOwner(t *testing.T) {
s := covApiServer(t)
if rec := doPutNoRole(s, "/api/config/review", `{"require_approval":true}`); rec.Code != http.StatusForbidden {
Expand Down
5 changes: 5 additions & 0 deletions src/pkg/dashboard/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23550,6 +23550,10 @@ <h4 style="margin:18px 0 8px">Escalation Breaker</h4>
<label>Max parallel reviews <span class="config-info">i<span class="config-tooltip">Caps how many reviewer agents run concurrently when fan-out is on. 0 or empty uses the default.</span></span></label>
<input type="number" min="0" step="1" value="${rv.max_parallel_reviews || ''}" placeholder="3" data-change-action="gh73">
</div>
<div class="config-field" style="margin-top:10px">
<label>Max perspectives per PR <span class="config-info">i<span class="config-tooltip">Caps how many review perspectives ONE PR may be given in a single cycle. Parallel review slots are a fixed budget spent in PR order, so without a cap the first PR in a deep queue absorbs every slot for its own perspectives &mdash; more opinions on one PR instead of coverage across many. Capping spends the same budget breadth-first. No coverage is lost: perspectives skipped this cycle are still missing next cycle and get dispatched then, so this schedules depth rather than dropping it. It also bounds how many comments one PR can collect at once, which matters once reviewers publish. 0 or empty means no cap (the default).</span></span></label>
<input type="number" min="0" step="1" value="${rv.max_perspectives_per_pr || ''}" placeholder="0 — no cap" data-change-action="gh81">
</div>
<div class="config-field" style="margin-top:10px">
<label>Reviewer agents <span class="config-info">i<span class="config-tooltip">Comma-separated agent names that review each PR, e.g. reviewer, security-reviewer.</span></span></label>
<input type="text" value="${esc(rvAgents.join(', '))}" placeholder="reviewer, security-reviewer" data-change-action="gh74">
Expand Down Expand Up @@ -26650,6 +26654,7 @@ <h4 style="margin:18px 0 8px">Escalation Breaker</h4>
gh72: function (event, A) { markDirty('auto-merge','required_checks',this.value.split(',').map(s=>s.trim()).filter(Boolean)); },
gh73: function (event, A) { markDirty('review','max_parallel_reviews',Number(this.value)||0); },
gh74: function (event, A) { markDirty('review','reviewer_agents',this.value.split(',').map(s=>s.trim()).filter(Boolean)); },
gh81: function (event, A) { markDirty('review','max_perspectives_per_pr',Number(this.value)||0); },
gh77: function (event, A) { projectObservabilityPlatformsChanged(this); },
gh78: function (event, A) { projectObservabilityReferenceChanged(this); },
gh79: function (event, A) { markDirty('repos','maxIssuesPerKick',Number(this.value)); },
Expand Down
Loading