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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ When the `merged` label is applied, the scanner also transitions the ticket to t

### PR Validation Labels

Configurable GitHub PR labels (`pr_validation_labels` in project config) applied when the AI session reports a problem. Labels are mutually exclusive: at most one is set on a PR at any time. Empty strings disable the corresponding label. Suggested values: `ai-validation-failed` and `ai-nonzero-exit`.
Configurable GitHub PR labels (`pr_validation_labels` in project config) applied when the AI session reports a problem or the bot cannot proceed. Labels are mutually exclusive: at most one is set on a PR at any time. Empty strings disable the corresponding label. Suggested values: `ai-validation-failed`, `ai-nonzero-exit`, and `ai-budget-exceeded`.
- **`validation_failed`**: Applied when the AI session explicitly reports `validation_passed: false`.
- **`nonzero_exit`**: Applied when the AI container exits with a non-zero code (and validation was not explicitly reported as failed).
- **`cost_cap_exceeded`**: Applied when the per-ticket AI session cost cap has been reached. **Defaults to `"ai-budget-exceeded"` when not configured** (the only label with a non-empty default). Set to `""` to disable. Reflects live state: re-applied if removed while the condition holds, removed when the bot successfully runs again (e.g., after the cap is raised).

Labels are applied when code is pushed (both new-ticket and feedback paths) and cleared when a subsequent push passes validation. When the AI produces no code changes, labels are left unchanged. Label management is best-effort — failures are logged but never block core operations.

Expand Down
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jira:
pr_validation_labels:
validation_failed: "ai-validation-failed" # AI explicitly reported validation failure
nonzero_exit: "ai-nonzero-exit" # AI container exited with non-zero code
# cost_cap_exceeded: "ai-budget-exceeded" # Per-ticket cost cap reached (default; set to "" to disable)

# Triage label cleanup: when a ticket leaves new_status and is
# assigned to a human, active triage labels are replaced with stale.
Expand Down
5 changes: 5 additions & 0 deletions executor/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ func ClearPRValidationLabels(p *Pipeline, logger *zap.Logger, owner, repo string
p.clearPRValidationLabels(logger, owner, repo, prNumber, vl)
}

// ApplyCostCapPRLabel exposes applyCostCapPRLabel for testing.
func ApplyCostCapPRLabel(p *Pipeline, logger *zap.Logger, ticketKey string, settings *models.ProjectSettings, exceeded bool) {
p.applyCostCapPRLabel(logger, ticketKey, settings, exceeded)
}

// TicketCostPath exposes the ticket cost file path constant for tests.
const TicketCostPath = ticketCostPath

Expand Down
4 changes: 2 additions & 2 deletions executor/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ func (p *Pipeline) executeFeedback(ctx context.Context, job *jobmanager.Job) (re
logger.Info("Per-ticket cost cap exceeded, skipping feedback",
zap.String("ticket", job.TicketKey),
zap.Float64("cap_usd", settings.MaxTicketCostUSD))
allLabels := models.AllPipelineLabels(settings.FailureLabels, settings.LifecycleLabels)
p.setPipelineLabel(logger, job.TicketKey, allLabels, settings.FailureLabels.Blocked)
p.applyCostCapPRLabel(logger, job.TicketKey, settings, true)
return result, errTicketCostCapExceeded
}
p.applyCostCapPRLabel(logger, job.TicketKey, settings, false)

defer func() {
// On failure post error comment (but do NOT revert status --
Expand Down
45 changes: 45 additions & 0 deletions executor/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,48 @@ func (p *Pipeline) clearPRValidationLabels(
}
}
}

// applyCostCapPRLabel applies or removes the cost-cap-exceeded label
// on all open PRs for the given ticket across all configured repos.
// When exceeded is true, the label is set and other validation labels
// are removed (mutual exclusivity). When exceeded is false, only the
// cost cap label is removed. All operations are best-effort.
func (p *Pipeline) applyCostCapPRLabel(
logger *zap.Logger,
ticketKey string,
settings *models.ProjectSettings,
exceeded bool,
) {
label := settings.PRValidationLabels.CostCapLabel()
if label == "" {
return
}

branchName := fmt.Sprintf("%s/%s", p.cfg.BotUsername, ticketKey)
heads := settings.PRHeads(branchName)

for _, repo := range settings.Repos {
pr, err := p.findPRByHeadsOptional(repo.Owner, repo.Repo, heads)
if err != nil {
logger.Debug("Failed to find PR for cost cap label",
zap.String("repo", repo.Owner+"/"+repo.Repo),
zap.Error(err))
continue
}
if pr == nil {
continue
}

if exceeded {
p.setPRValidationLabel(logger, repo.Owner, repo.Repo,
pr.Number, settings.PRValidationLabels, label)
} else {
if err := p.git.RemovePRLabel(repo.Owner, repo.Repo, pr.Number, label); err != nil {
logger.Debug("Failed to remove cost cap PR label",
zap.String("repo", repo.Owner+"/"+repo.Repo),
zap.Int("pr", pr.Number),
zap.Error(err))
}
}
}
}
192 changes: 192 additions & 0 deletions executor/labels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,39 @@ func TestSetPRValidationLabel(t *testing.T) {
p := d.pipeline(t)
executor.SetPRValidationLabel(p, zap.NewNop(), "org", "repo", 42, vl, "ai-validation-failed")
})

t.Run("removes cost cap label when setting validation label", func(t *testing.T) {
costCap := models.DefaultCostCapLabel
vlAll := models.PRValidationLabels{
ValidationFailed: "ai-validation-failed",
NonzeroExit: "ai-nonzero-exit",
CostCapExceeded: &costCap,
}
var added, removed []string
d := newTestDeps(t)
d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { added = append(added, label); return nil }
d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { removed = append(removed, label); return nil }

p := d.pipeline(t)
executor.SetPRValidationLabel(p, zap.NewNop(), "org", "repo", 42, vlAll, "ai-validation-failed")

if len(added) != 1 || added[0] != "ai-validation-failed" {
t.Errorf("added = %v, want [ai-validation-failed]", added)
}
removedSet := make(map[string]bool, len(removed))
for _, l := range removed {
removedSet[l] = true
}
if !removedSet["ai-nonzero-exit"] {
t.Error("expected ai-nonzero-exit to be removed")
}
if !removedSet[models.DefaultCostCapLabel] {
t.Errorf("expected %s to be removed", models.DefaultCostCapLabel)
}
if len(removed) != 2 {
t.Errorf("removed = %v, want exactly 2 entries", removed)
}
})
}

func TestClearPRValidationLabels(t *testing.T) {
Expand Down Expand Up @@ -676,3 +709,162 @@ func TestSetPipelineLabel_ErrorsAreSwallowed(t *testing.T) {
}
})
}

func TestApplyCostCapPRLabel(t *testing.T) {
costCapLabel := models.DefaultCostCapLabel

makeSettings := func(repos []models.RepoSettings) *models.ProjectSettings {
return &models.ProjectSettings{
Repos: repos,
PRValidationLabels: models.PRValidationLabels{
ValidationFailed: "ai-validation-failed",
NonzeroExit: "ai-nonzero-exit",
CostCapExceeded: &costCapLabel,
},
}
}

t.Run("applies label and removes others on exceeded", func(t *testing.T) {
var added, removed []string
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, _, _ string) (*models.PRDetails, error) {
return &models.PRDetails{Number: 42}, nil
}
d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { added = append(added, label); return nil }
d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { removed = append(removed, label); return nil }

settings := makeSettings([]models.RepoSettings{{Owner: "org", Repo: "repo"}})
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, true)

if len(added) != 1 || added[0] != models.DefaultCostCapLabel {
t.Errorf("added = %v, want [%s]", added, models.DefaultCostCapLabel)
}
removedSet := make(map[string]bool, len(removed))
for _, l := range removed {
removedSet[l] = true
}
if !removedSet["ai-validation-failed"] {
t.Error("expected ai-validation-failed to be removed")
}
if !removedSet["ai-nonzero-exit"] {
t.Error("expected ai-nonzero-exit to be removed")
}
})

t.Run("removes only cost cap label when not exceeded", func(t *testing.T) {
var removed []string
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, _, _ string) (*models.PRDetails, error) {
return &models.PRDetails{Number: 42}, nil
}
d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { removed = append(removed, label); return nil }

settings := makeSettings([]models.RepoSettings{{Owner: "org", Repo: "repo"}})
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, false)

if len(removed) != 1 || removed[0] != models.DefaultCostCapLabel {
t.Errorf("removed = %v, want [%s]", removed, models.DefaultCostCapLabel)
}
})

t.Run("no-op when label is disabled", func(t *testing.T) {
var prLookups int
empty := ""
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, _, _ string) (*models.PRDetails, error) {
prLookups++
return &models.PRDetails{Number: 42}, nil
}

settings := &models.ProjectSettings{
Repos: []models.RepoSettings{{Owner: "org", Repo: "repo"}},
PRValidationLabels: models.PRValidationLabels{
CostCapExceeded: &empty,
},
}
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, true)

if prLookups != 0 {
t.Errorf("expected no PR lookups when label disabled, got %d", prLookups)
}
})

t.Run("skips repos without open PRs", func(t *testing.T) {
var added []string
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, repo, _ string) (*models.PRDetails, error) {
if repo == "repo-with-pr" {
return &models.PRDetails{Number: 10}, nil
}
return nil, nil
}
d.git.AddPRLabelFunc = func(_, repo string, prNum int, label string) error {
added = append(added, fmt.Sprintf("%s#%d:%s", repo, prNum, label))
return nil
}
d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { return nil }

settings := makeSettings([]models.RepoSettings{
{Owner: "org", Repo: "repo-no-pr"},
{Owner: "org", Repo: "repo-with-pr"},
})
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, true)

if len(added) != 1 {
t.Fatalf("added = %v, want 1 entry", added)
}
want := fmt.Sprintf("repo-with-pr#10:%s", models.DefaultCostCapLabel)
if added[0] != want {
t.Errorf("added[0] = %q, want %q", added[0], want)
}
})

t.Run("multi-repo applies to all PRs", func(t *testing.T) {
var addedRepos []string
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, repo, _ string) (*models.PRDetails, error) {
if repo == "repo1" {
return &models.PRDetails{Number: 10}, nil
}
return &models.PRDetails{Number: 20}, nil
}
d.git.AddPRLabelFunc = func(_, repo string, _ int, _ string) error {
addedRepos = append(addedRepos, repo)
return nil
}
d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { return nil }

settings := makeSettings([]models.RepoSettings{
{Owner: "org", Repo: "repo1"},
{Owner: "org", Repo: "repo2"},
})
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, true)

if len(addedRepos) != 2 {
t.Fatalf("expected label on 2 repos, got %d", len(addedRepos))
}
})

t.Run("PR lookup errors are swallowed", func(t *testing.T) {
var labelCalls int
d := newTestDeps(t)
d.git.GetPRForBranchFunc = func(_, _, _ string) (*models.PRDetails, error) {
return nil, fmt.Errorf("API error")
}
d.git.AddPRLabelFunc = func(_, _ string, _ int, _ string) error { labelCalls++; return nil }
d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { labelCalls++; return nil }

settings := makeSettings([]models.RepoSettings{{Owner: "org", Repo: "repo"}})
p := d.pipeline(t)
executor.ApplyCostCapPRLabel(p, zap.NewNop(), "TEST-1", settings, true)

if labelCalls != 0 {
t.Errorf("expected no label calls on lookup error, got %d", labelCalls)
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
4 changes: 2 additions & 2 deletions executor/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ func (p *Pipeline) executeNewTicket(ctx context.Context, job *jobmanager.Job) (r
logger.Info("Per-ticket cost cap exceeded, skipping",
zap.String("ticket", job.TicketKey),
zap.Float64("cap_usd", settings.MaxTicketCostUSD))
allLabels := models.AllPipelineLabels(settings.FailureLabels, settings.LifecycleLabels)
p.setPipelineLabel(logger, job.TicketKey, allLabels, settings.FailureLabels.Blocked)
p.applyCostCapPRLabel(logger, job.TicketKey, settings, true)
return result, errTicketCostCapExceeded
}
p.applyCostCapPRLabel(logger, job.TicketKey, settings, false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// --- Clean retry: delete stale branches and workspace ---
if job.CleanRetry {
Expand Down
23 changes: 22 additions & 1 deletion models/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ func (ll LifecycleLabels) All() []string {
return []string{ll.Queued, ll.Review, ll.Merged}
}

// DefaultCostCapLabel is the default GitHub PR label applied when the
// per-ticket AI session cost cap is exceeded.
const DefaultCostCapLabel = "ai-budget-exceeded"

// PRValidationLabels holds configurable GitHub PR labels applied when
// the AI session's validation or exit code indicates a problem. Labels
// are mutually exclusive: at most one is set on a given PR. Empty
Expand All @@ -441,12 +445,29 @@ type PRValidationLabels struct {
// non-zero code (and validation was not explicitly reported
// as failed).
NonzeroExit string `yaml:"nonzero_exit" mapstructure:"nonzero_exit"`

// CostCapExceeded is applied when the per-ticket AI session
// cost cap has been reached. Defaults to DefaultCostCapLabel
// when not configured (nil); set to "" to disable.
CostCapExceeded *string `yaml:"cost_cap_exceeded,omitempty" mapstructure:"cost_cap_exceeded"`
}

// CostCapLabel returns the raw cost cap exceeded label value.
// Returns empty string when the pointer is nil or points to "".
// The default (DefaultCostCapLabel) is applied by the project
// resolver, not here; callers outside the resolver should only
// see post-resolution values.
func (vl PRValidationLabels) CostCapLabel() string {
if vl.CostCapExceeded == nil {
return ""
}
return *vl.CostCapExceeded
}

// All returns the configured label strings in a fixed order. Empty
// strings (disabled labels) are included; callers should skip them.
func (vl PRValidationLabels) All() []string {
return []string{vl.ValidationFailed, vl.NonzeroExit}
return []string{vl.ValidationFailed, vl.NonzeroExit, vl.CostCapLabel()}
}

// AllPipelineLabels returns all configured failure and lifecycle labels
Expand Down
Loading
Loading