diff --git a/AGENTS.md b/AGENTS.md index d5ffe3a..6303ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,19 @@ Label management is best-effort — failures are logged but never block core ope Optional per-project Jira labels (`lifecycle_labels` in project config) that track ticket progression through the autofix pipeline. Labels are mutually exclusive: setting one removes the others. Empty string disables the label: - **`queued`**: Set externally (e.g., by a triage bot) to indicate the ticket is waiting. This bot never sets it, but removes it when applying `review`. -- **`review`**: Applied by the executor when a PR is created and the ticket transitions to "in review" (non-draft PRs only). +- **`review`**: Applied by the executor when a PR is created and the ticket transitions to "in review". - **`merged`**: Applied by the feedback scanner when all repos' PRs are merged. For multi-repo workspaces, requires every repo's PR to be merged. When the `merged` label is applied, the scanner also transitions the ticket to the configured `merged` status (e.g., "MODIFIED") if set in `status_transitions`. The `merged` status field is optional; omitting it disables the transition. +### 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`. +- **`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). + +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. + ### Security Features - **Security level redaction**: Tickets with security levels get redacted PR titles/descriptions diff --git a/config.example.yaml b/config.example.yaml index e6a4599..0354340 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,6 +56,14 @@ jira: review: "jira-autofix-review" # Applied when PR is created merged: "jira-autofix-merged" # Applied when all PRs are merged + # GitHub PR labels applied when the AI session reports a problem. + # At most one is set on a PR at any time. Applied when code is + # pushed; cleared when a subsequent push passes. Labels unchanged + # when no code is pushed. Omit or leave empty to disable. + 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 + # Status transitions can be configured per ticket type # All ticket types must be explicitly configured # IMPORTANT: Status names are case-sensitive and must match Jira exactly diff --git a/executor/executor.go b/executor/executor.go index a96b140..43192e6 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -38,7 +38,7 @@ // 11. Check for changes; fail if none // 12. Commit changes via GitHub API // 13. Sync workspace with remote -// 14. Create pull request (draft if validation failed) +// 14. Create pull request and apply validation labels if needed // 15. Update ticket with PR URL and transition status // 16. Stop container (workspace retained for future jobs) // @@ -214,6 +214,14 @@ type GitService interface { // GetFailedJobLogs returns truncated log output from failed // workflow job steps, keyed by job name. GetFailedJobLogs(owner, repo, headSHA string, maxBytesPerStep int) (map[string][]models.FailedStep, error) + + // AddPRLabel adds a label to a GitHub pull request. Creates the + // label if it does not already exist on the repository. + AddPRLabel(owner, repo string, number int, label string) error + + // RemovePRLabel removes a label from a GitHub pull request. + // Returns nil if the label is not present (idempotent). + RemovePRLabel(owner, repo string, number int, label string) error } // ProjectResolver maps work items to their project-specific settings. diff --git a/executor/executortest/stubs.go b/executor/executortest/stubs.go index 04919af..054a12d 100644 --- a/executor/executortest/stubs.go +++ b/executor/executortest/stubs.go @@ -59,6 +59,8 @@ type StubGitService struct { ListCheckRunAnnotationsFunc func(owner, repo string, checkRunID int64) ([]models.CheckAnnotation, error) GetFailedJobLogsFunc func(owner, repo, headSHA string, maxBytesPerStep int) (map[string][]models.FailedStep, error) AddCommentReactionFunc func(owner, repo string, comment models.PRComment, reaction string) error + AddPRLabelFunc func(owner, repo string, number int, label string) error + RemovePRLabelFunc func(owner, repo string, number int, label string) error } func (s *StubGitService) SyncFork(forkOwner, repo, branch string) error { @@ -230,6 +232,20 @@ func (s *StubGitService) AddCommentReaction(owner, repo string, comment models.P return nil } +func (s *StubGitService) AddPRLabel(owner, repo string, number int, label string) error { + if s.AddPRLabelFunc != nil { + return s.AddPRLabelFunc(owner, repo, number, label) + } + return nil +} + +func (s *StubGitService) RemovePRLabel(owner, repo string, number int, label string) error { + if s.RemovePRLabelFunc != nil { + return s.RemovePRLabelFunc(owner, repo, number, label) + } + return nil +} + // StubProjectResolver is a test double for [executor.ProjectResolver]. // Set the corresponding Func field to control each method's behavior. // When a Func field is nil, the method returns zero values. diff --git a/executor/export_test.go b/executor/export_test.go index aeeb74e..850a24f 100644 --- a/executor/export_test.go +++ b/executor/export_test.go @@ -68,3 +68,23 @@ func SetLifecycleLabel(p *Pipeline, logger *zap.Logger, ticketKey string, ll mod func ValidateForkMode(p *Pipeline, logger *zap.Logger, ticketKey string, workItem *models.WorkItem, settings *models.ProjectSettings) error { return p.validateForkMode(logger, ticketKey, workItem, settings) } + +// ValidationLabel exposes validationLabel for testing. +func ValidationLabel(session SessionOutput, exitCode int, vl models.PRValidationLabels) string { + return validationLabel(session, exitCode, vl) +} + +// ValidationPassed exposes validationPassed for testing. +func ValidationPassed(session SessionOutput, exitCode int) bool { + return validationPassed(session, exitCode) +} + +// SetPRValidationLabel exposes setPRValidationLabel for testing. +func SetPRValidationLabel(p *Pipeline, logger *zap.Logger, owner, repo string, prNumber int, vl models.PRValidationLabels, targetLabel string) { + p.setPRValidationLabel(logger, owner, repo, prNumber, vl, targetLabel) +} + +// ClearPRValidationLabels exposes clearPRValidationLabels for testing. +func ClearPRValidationLabels(p *Pipeline, logger *zap.Logger, owner, repo string, prNumber int, vl models.PRValidationLabels) { + p.clearPRValidationLabels(logger, owner, repo, prNumber, vl) +} diff --git a/executor/feedback.go b/executor/feedback.go index 9710725..08c73d7 100644 --- a/executor/feedback.go +++ b/executor/feedback.go @@ -250,11 +250,19 @@ func (p *Pipeline) executeFeedback(ctx context.Context, job *jobmanager.Job) (re settings.Repos[0].Owner, settings.Repos[0].Repo, prDetails.Number, result.CostUSD, "Feedback", job.AttemptNum) + // --- Step 17b: Apply or clear PR validation labels --- + vlTarget := validationLabel(session, exitCode, settings.PRValidationLabels) + if vlTarget != "" { + p.setPRValidationLabel(logger, owner, repo, prDetails.Number, + settings.PRValidationLabels, vlTarget) + } else { + p.clearPRValidationLabels(logger, owner, repo, prDetails.Number, + settings.PRValidationLabels) + } + result.PRURL = prDetails.URL result.PRNumber = prDetails.Number - // Repo-config draft setting is not consulted here (hardcoded false) - // because the PR already exists — its draft status is not ours to change. - result.ValidationPassed = !shouldCreateDraft(session, exitCode, false) + result.ValidationPassed = validationPassed(session, exitCode) logger.Info("Feedback processed", zap.String("url", prDetails.URL), @@ -485,9 +493,24 @@ func (p *Pipeline) executeMultiRepoFeedback( p.clearFailureLabels(logger, job.TicketKey, settings.FailureLabels) + // Apply or clear PR validation labels only on repos that received a commit. + vlTarget := validationLabel(session, exitCode, settings.PRValidationLabels) + for _, ri := range repoInfos { + if repoSHAs[ri.repo.Name] == "" { + continue + } + if vlTarget != "" { + p.setPRValidationLabel(logger, ri.repo.Owner, ri.repo.Repo, + ri.pr.Number, settings.PRValidationLabels, vlTarget) + } else { + p.clearPRValidationLabels(logger, ri.repo.Owner, ri.repo.Repo, + ri.pr.Number, settings.PRValidationLabels) + } + } + result.PRURL = repoInfos[0].pr.URL result.PRNumber = repoInfos[0].pr.Number - result.ValidationPassed = !shouldCreateDraft(session, exitCode, false) + result.ValidationPassed = validationPassed(session, exitCode) logger.Info("Multi-repo feedback processed", zap.Int("repos_with_prs", len(repoInfos)), diff --git a/executor/labels.go b/executor/labels.go index 1cb7b2f..e27bf37 100644 --- a/executor/labels.go +++ b/executor/labels.go @@ -120,3 +120,56 @@ func (p *Pipeline) clearFailureLabels( } } } + +// setPRValidationLabel applies the given validation label to a GitHub +// PR and removes the other configured validation labels (mutual +// exclusivity). If targetLabel is empty, only clears the others. All +// operations are best-effort: errors are logged but never propagated. +func (p *Pipeline) setPRValidationLabel( + logger *zap.Logger, + owner, repo string, + prNumber int, + vl models.PRValidationLabels, + targetLabel string, +) { + for _, label := range vl.All() { + if label != "" && label != targetLabel { + if err := p.git.RemovePRLabel(owner, repo, prNumber, label); err != nil { + logger.Debug("Failed to remove PR validation label", + zap.String("owner", owner), zap.String("repo", repo), + zap.Int("pr", prNumber), zap.String("label", label), + zap.Error(err)) + } + } + } + + if targetLabel != "" { + if err := p.git.AddPRLabel(owner, repo, prNumber, targetLabel); err != nil { + logger.Warn("Failed to add PR validation label", + zap.String("owner", owner), zap.String("repo", repo), + zap.Int("pr", prNumber), zap.String("label", targetLabel), + zap.Error(err)) + } + } +} + +// clearPRValidationLabels removes all configured validation labels +// from a GitHub PR. Called when validation passes after a prior +// failure. All operations are best-effort. +func (p *Pipeline) clearPRValidationLabels( + logger *zap.Logger, + owner, repo string, + prNumber int, + vl models.PRValidationLabels, +) { + for _, label := range vl.All() { + if label != "" { + if err := p.git.RemovePRLabel(owner, repo, prNumber, label); err != nil { + logger.Debug("Failed to remove PR validation label", + zap.String("owner", owner), zap.String("repo", repo), + zap.Int("pr", prNumber), zap.String("label", label), + zap.Error(err)) + } + } + } +} diff --git a/executor/labels_test.go b/executor/labels_test.go index d1a60c7..b857186 100644 --- a/executor/labels_test.go +++ b/executor/labels_test.go @@ -445,6 +445,191 @@ func TestValidateForkMode(t *testing.T) { }) } +func TestValidationLabel(t *testing.T) { + vl := models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + } + + tests := []struct { + name string + validationPassed *bool + exitCode int + want string + }{ + {"all OK", nil, 0, ""}, + {"validation explicitly passed", boolPtr(true), 0, ""}, + {"validation failed", boolPtr(false), 0, "ai-validation-failed"}, + {"nonzero exit", nil, 1, "ai-nonzero-exit"}, + {"validation passed but nonzero exit", boolPtr(true), 1, "ai-nonzero-exit"}, + {"validation failed takes precedence over nonzero exit", boolPtr(false), 1, "ai-validation-failed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := executor.SessionOutput{ValidationPassed: tt.validationPassed} + got := executor.ValidationLabel(session, tt.exitCode, vl) + if got != tt.want { + t.Errorf("validationLabel() = %q, want %q", got, tt.want) + } + }) + } + + t.Run("falls through to nonzero_exit when validation_failed is disabled", func(t *testing.T) { + partial := models.PRValidationLabels{NonzeroExit: "ai-nonzero-exit"} + session := executor.SessionOutput{ValidationPassed: boolPtr(false)} + got := executor.ValidationLabel(session, 1, partial) + if got != "ai-nonzero-exit" { + t.Errorf("validationLabel() = %q, want ai-nonzero-exit", got) + } + }) +} + +func TestValidationPassed(t *testing.T) { + tests := []struct { + name string + validationPassed *bool + exitCode int + want bool + }{ + {"all OK", nil, 0, true}, + {"validation explicitly passed", boolPtr(true), 0, true}, + {"validation failed", boolPtr(false), 0, false}, + {"nonzero exit", nil, 1, false}, + {"validation passed but nonzero exit", boolPtr(true), 1, false}, + {"validation failed and nonzero exit", boolPtr(false), 1, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := executor.SessionOutput{ValidationPassed: tt.validationPassed} + got := executor.ValidationPassed(session, tt.exitCode) + if got != tt.want { + t.Errorf("validationPassed() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSetPRValidationLabel(t *testing.T) { + vl := models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + } + + t.Run("adds target and removes others", func(t *testing.T) { + 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, vl, "ai-validation-failed") + + if len(added) != 1 || added[0] != "ai-validation-failed" { + t.Errorf("added = %v, want [ai-validation-failed]", added) + } + if len(removed) != 1 || removed[0] != "ai-nonzero-exit" { + t.Errorf("removed = %v, want [ai-nonzero-exit]", removed) + } + }) + + t.Run("empty target only removes others", func(t *testing.T) { + 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, vl, "") + + if len(added) != 0 { + t.Errorf("added = %v, want empty", added) + } + if len(removed) != 2 { + t.Errorf("removed = %v, want 2 entries", removed) + } + }) + + t.Run("skips empty labels in config", func(t *testing.T) { + partial := models.PRValidationLabels{ValidationFailed: "ai-validation-failed"} + 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, partial, "ai-validation-failed") + + if len(added) != 1 || added[0] != "ai-validation-failed" { + t.Errorf("added = %v, want [ai-validation-failed]", added) + } + if len(removed) != 0 { + t.Errorf("removed = %v, want empty (no other labels configured)", removed) + } + }) + + t.Run("errors are swallowed", func(t *testing.T) { + d := newTestDeps(t) + d.git.AddPRLabelFunc = func(_, _ string, _ int, _ string) error { return fmt.Errorf("add failed") } + d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { return fmt.Errorf("remove failed") } + + p := d.pipeline(t) + executor.SetPRValidationLabel(p, zap.NewNop(), "org", "repo", 42, vl, "ai-validation-failed") + }) +} + +func TestClearPRValidationLabels(t *testing.T) { + t.Run("removes all configured labels", func(t *testing.T) { + vl := models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + } + var removed []string + d := newTestDeps(t) + d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { removed = append(removed, label); return nil } + + p := d.pipeline(t) + executor.ClearPRValidationLabels(p, zap.NewNop(), "org", "repo", 42, vl) + + if len(removed) != 2 { + t.Fatalf("removed = %v, want 2 entries", removed) + } + want := map[string]bool{"ai-validation-failed": true, "ai-nonzero-exit": true} + for _, l := range removed { + if !want[l] { + t.Errorf("unexpected removal of %q", l) + } + } + }) + + t.Run("skips empty labels", func(t *testing.T) { + vl := models.PRValidationLabels{ValidationFailed: "ai-validation-failed"} + var removed []string + d := newTestDeps(t) + d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { removed = append(removed, label); return nil } + + p := d.pipeline(t) + executor.ClearPRValidationLabels(p, zap.NewNop(), "org", "repo", 42, vl) + + if len(removed) != 1 || removed[0] != "ai-validation-failed" { + t.Errorf("removed = %v, want [ai-validation-failed]", removed) + } + }) + + t.Run("errors are swallowed", func(t *testing.T) { + vl := models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + } + d := newTestDeps(t) + d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { return fmt.Errorf("remove failed") } + + p := d.pipeline(t) + executor.ClearPRValidationLabels(p, zap.NewNop(), "org", "repo", 42, vl) + }) +} + func TestSetFailureLabel_ErrorsAreSwallowed(t *testing.T) { fl := models.FailureLabels{ CIFailing: "ci-fail", diff --git a/executor/pipeline.go b/executor/pipeline.go index 15aeef5..e23ec73 100644 --- a/executor/pipeline.go +++ b/executor/pipeline.go @@ -305,13 +305,6 @@ func (p *Pipeline) executeNewTicket(ctx context.Context, job *jobmanager.Job) (r } // --- Step 16: Create PR --- - draft := shouldCreateDraft(session, exitCode, repoCfg.PR.Draft) - if draft { - logger.Info("Creating draft PR", - zap.Int("exit_code", exitCode), - zap.Any("validation_passed", session.ValidationPassed), - zap.Bool("repo_config_draft", repoCfg.PR.Draft)) - } aiPR := readPRDescription(wsPath) prTitle, prBody := buildPRContent(workItem, job.TicketKey, repoCfg.PR.TitlePrefix, aiPR) @@ -322,7 +315,7 @@ func (p *Pipeline) executeNewTicket(ctx context.Context, job *jobmanager.Job) (r Body: prBody, Head: settings.PRHead(branchName), Base: settings.Repos[0].BaseBranch, - Draft: draft, + Draft: repoCfg.PR.Draft, Labels: repoCfg.PR.Labels, Assignees: assigneesFromSettings(settings), }) @@ -332,13 +325,20 @@ func (p *Pipeline) executeNewTicket(ctx context.Context, job *jobmanager.Job) (r result.PRURL = pr.URL result.PRNumber = pr.Number - result.Draft = draft - result.ValidationPassed = !draft + result.Draft = repoCfg.PR.Draft + result.ValidationPassed = validationPassed(session, exitCode) logger.Info("PR created", zap.String("url", pr.URL), zap.Int("number", pr.Number), - zap.Bool("draft", draft)) + zap.Bool("draft", repoCfg.PR.Draft)) + + // --- Step 16a: Apply validation labels --- + vlTarget := validationLabel(session, exitCode, settings.PRValidationLabels) + if vlTarget != "" { + p.setPRValidationLabel(logger, settings.Repos[0].Owner, settings.Repos[0].Repo, + pr.Number, settings.PRValidationLabels, vlTarget) + } // --- Step 17: Update ticket --- p.setPRURL(logger, job.TicketKey, settings, pr.URL) @@ -348,11 +348,9 @@ func (p *Pipeline) executeNewTicket(ctx context.Context, job *jobmanager.Job) (r settings.Repos[0].Owner, settings.Repos[0].Repo, pr.Number, result.CostUSD, "New ticket", 0) - if !draft { - p.setLifecycleLabel(logger, job.TicketKey, settings.LifecycleLabels, settings.LifecycleLabels.Review) - if err := p.tracker.TransitionStatus(job.TicketKey, settings.InReviewStatus); err != nil { - logger.Warn("Failed to transition to in-review", zap.Error(err)) - } + p.setLifecycleLabel(logger, job.TicketKey, settings.LifecycleLabels, settings.LifecycleLabels.Review) + if err := p.tracker.TransitionStatus(job.TicketKey, settings.InReviewStatus); err != nil { + logger.Warn("Failed to transition to in-review", zap.Error(err)) } return result, nil @@ -950,18 +948,18 @@ func (p *Pipeline) executeMultiRepoNewTicket( // --- Step 13–16: Per-repo fan-out (changes → commit → PR) --- importExcludes := collectExcludes(mergedImports) aiPR := readPRDescription(wsPath) - sessionDraft := shouldCreateDraft(session, exitCode, false) + vlTarget := validationLabel(session, exitCode, settings.PRValidationLabels) prs, err := p.fanOutCommitAndPR(logger, fanOutParams{ - settings: settings, - workItem: workItem, - wsPath: wsPath, - branchName: branchName, - ticketKey: job.TicketKey, - repoConfigs: repoConfigs, - excludes: importExcludes, - aiPR: aiPR, - sessionDraft: sessionDraft, + settings: settings, + workItem: workItem, + wsPath: wsPath, + branchName: branchName, + ticketKey: job.TicketKey, + repoConfigs: repoConfigs, + excludes: importExcludes, + aiPR: aiPR, + vlTarget: vlTarget, }) if err != nil { return result, err @@ -982,14 +980,12 @@ func (p *Pipeline) executeMultiRepoNewTicket( result.PRURL = prs[0].url result.PRNumber = prs[0].number - result.Draft = sessionDraft - result.ValidationPassed = !sessionDraft + result.Draft = prs[0].draft + result.ValidationPassed = validationPassed(session, exitCode) - if !sessionDraft { - p.setLifecycleLabel(logger, job.TicketKey, settings.LifecycleLabels, settings.LifecycleLabels.Review) - if err := p.tracker.TransitionStatus(job.TicketKey, settings.InReviewStatus); err != nil { - logger.Warn("Failed to transition to in-review", zap.Error(err)) - } + p.setLifecycleLabel(logger, job.TicketKey, settings.LifecycleLabels, settings.LifecycleLabels.Review) + if err := p.tracker.TransitionStatus(job.TicketKey, settings.InReviewStatus); err != nil { + logger.Warn("Failed to transition to in-review", zap.Error(err)) } return result, nil @@ -1050,15 +1046,15 @@ func (p *Pipeline) writeNewTicketFiles( } type fanOutParams struct { - settings *models.ProjectSettings - workItem *models.WorkItem - wsPath string - branchName string - ticketKey string - repoConfigs []*repoconfig.Config - excludes []string - aiPR *PRDescription - sessionDraft bool + settings *models.ProjectSettings + workItem *models.WorkItem + wsPath string + branchName string + ticketKey string + repoConfigs []*repoconfig.Config + excludes []string + aiPR *PRDescription + vlTarget string } type repoPR struct { @@ -1107,7 +1103,6 @@ func (p *Pipeline) fanOutCommitAndPR( return nil, fmt.Errorf("sync with remote for %s: %w", repo.Name, err) } - repoDraft := params.sessionDraft || params.repoConfigs[i].PR.Draft prTitle, prBody := buildPRContent( params.workItem, params.ticketKey, params.repoConfigs[i].PR.TitlePrefix, params.aiPR) @@ -1118,7 +1113,7 @@ func (p *Pipeline) fanOutCommitAndPR( Body: prBody, Head: params.settings.PRHead(params.branchName), Base: repo.BaseBranch, - Draft: repoDraft, + Draft: params.repoConfigs[i].PR.Draft, Labels: params.repoConfigs[i].PR.Labels, Assignees: assigneesFromSettings(params.settings), }) @@ -1126,12 +1121,17 @@ func (p *Pipeline) fanOutCommitAndPR( return nil, fmt.Errorf("create PR for %s: %w", repo.Name, err) } - prs = append(prs, repoPR{owner: repo.Owner, repo: repo.Repo, url: pr.URL, number: pr.Number, draft: repoDraft}) + if params.vlTarget != "" { + p.setPRValidationLabel(logger, repo.Owner, repo.Repo, + pr.Number, params.settings.PRValidationLabels, params.vlTarget) + } + + prs = append(prs, repoPR{owner: repo.Owner, repo: repo.Repo, url: pr.URL, number: pr.Number, draft: params.repoConfigs[i].PR.Draft}) logger.Info("PR created", zap.String("repo", repo.Name), zap.String("url", pr.URL), zap.Int("number", pr.Number), - zap.Bool("draft", repoDraft)) + zap.Bool("draft", params.repoConfigs[i].PR.Draft)) } return prs, nil @@ -1287,19 +1287,30 @@ func mergeMultiRepoImports( return result } -// shouldCreateDraft determines whether the PR should be created as a -// draft based on session output, exit code, and repo config. -func shouldCreateDraft(session SessionOutput, exitCode int, repoDraft bool) bool { - if repoDraft { - return true - } +// validationLabel returns the PR validation label to apply based on +// the AI session's output. Returns empty string when validation passed +// and exit code is zero. ValidationFailed takes precedence over +// NonzeroExit because it is the more specific signal. +func validationLabel(session SessionOutput, exitCode int, vl models.PRValidationLabels) string { if session.ValidationPassed != nil && !*session.ValidationPassed { - return true + if vl.ValidationFailed != "" { + return vl.ValidationFailed + } } if exitCode != 0 { - return true + return vl.NonzeroExit + } + return "" +} + +// validationPassed reports whether the AI session completed +// successfully: validation was not explicitly failed and the container +// exited with code zero. +func validationPassed(session SessionOutput, exitCode int) bool { + if session.ValidationPassed != nil && !*session.ValidationPassed { + return false } - return false + return exitCode == 0 } // buildPRContent generates the PR title and body from the work item. diff --git a/executor/pipeline_test.go b/executor/pipeline_test.go index 08574c4..81fb167 100644 --- a/executor/pipeline_test.go +++ b/executor/pipeline_test.go @@ -409,9 +409,9 @@ func TestExecuteNewTicket_PRCreationFails(t *testing.T) { } } -// --- Draft PR paths --- +// --- Validation label paths --- -func TestExecuteNewTicket_DraftPR_NonZeroExitCode(t *testing.T) { +func TestExecuteNewTicket_NonZeroExitCode_AppliesLabel(t *testing.T) { d := newTestDeps(t) d.containers.ExecFunc = func(ctx context.Context, ctr *container.Container, cmd []string) (string, int, error) { return "", 1, nil // non-zero exit, no exec error @@ -423,7 +423,12 @@ func TestExecuteNewTicket_DraftPR_NonZeroExitCode(t *testing.T) { return &models.PR{Number: 1, URL: "https://github.com/org/repo/pull/1"}, nil } - // Verify ticket is NOT transitioned to in-review for draft PRs. + var addedLabels []string + d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { + addedLabels = append(addedLabels, label) + return nil + } + var transitions []string d.tracker.TransitionStatusFunc = func(key, status string) error { transitions = append(transitions, status) @@ -436,27 +441,33 @@ func TestExecuteNewTicket_DraftPR_NonZeroExitCode(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !prDraft { - t.Error("expected draft PR") + if prDraft { + t.Error("expected non-draft PR") } - if !result.Draft { - t.Error("expected result.Draft = true") + if result.Draft { + t.Error("expected result.Draft = false") } if result.ValidationPassed { - t.Error("expected ValidationPassed = false for draft") + t.Error("expected ValidationPassed = false for nonzero exit") } - // Should only have "In Progress" transition, not "In Review". + if len(addedLabels) != 1 || addedLabels[0] != "ai-nonzero-exit" { + t.Errorf("addedLabels = %v, want [ai-nonzero-exit]", addedLabels) + } + // Should still transition to In Review. + found := false for _, s := range transitions { if s == "In Review" { - t.Error("draft PR should not transition to In Review") + found = true } } + if !found { + t.Errorf("transitions = %v, want In Review", transitions) + } } -func TestExecuteNewTicket_DraftPR_ValidationFailed(t *testing.T) { +func TestExecuteNewTicket_ValidationFailed_AppliesLabel(t *testing.T) { d := newTestDeps(t) - // Write session-output.json with validation_passed=false. d.containers.ExecFunc = func(ctx context.Context, ctr *container.Container, cmd []string) (string, int, error) { writeSessionOutput(t, d.wsDir, executor.SessionOutput{ ExitCode: 0, @@ -471,24 +482,32 @@ func TestExecuteNewTicket_DraftPR_ValidationFailed(t *testing.T) { return &models.PR{Number: 1, URL: "https://github.com/org/repo/pull/1"}, nil } + var addedLabels []string + d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { + addedLabels = append(addedLabels, label) + return nil + } + p := d.pipeline(t) result, err := p.Execute(context.Background(), newTicketJob("PROJ-1")) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !prDraft { - t.Error("expected draft PR for validation failure") + if prDraft { + t.Error("expected non-draft PR") } - if !result.Draft { - t.Error("expected result.Draft = true") + if result.ValidationPassed { + t.Error("expected ValidationPassed = false") + } + if len(addedLabels) != 1 || addedLabels[0] != "ai-validation-failed" { + t.Errorf("addedLabels = %v, want [ai-validation-failed]", addedLabels) } } -func TestExecuteNewTicket_DraftPR_RepoConfigForcesDraft(t *testing.T) { +func TestExecuteNewTicket_RepoConfigForcesDraft(t *testing.T) { d := newTestDeps(t) - // Write .ai-bot/config.yaml with pr.draft: true. cfgDir := filepath.Join(d.wsDir, ".ai-bot") if err := os.MkdirAll(cfgDir, 0o750); err != nil { t.Fatal(err) @@ -504,8 +523,14 @@ func TestExecuteNewTicket_DraftPR_RepoConfigForcesDraft(t *testing.T) { return &models.PR{Number: 1, URL: "https://github.com/org/repo/pull/1"}, nil } + var transitions []string + d.tracker.TransitionStatusFunc = func(key, status string) error { + transitions = append(transitions, status) + return nil + } + p := d.pipeline(t) - _, err := p.Execute(context.Background(), newTicketJob("PROJ-1")) + result, err := p.Execute(context.Background(), newTicketJob("PROJ-1")) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -513,6 +538,19 @@ func TestExecuteNewTicket_DraftPR_RepoConfigForcesDraft(t *testing.T) { if !prDraft { t.Error("expected draft PR from repo config") } + if !result.Draft { + t.Error("expected result.Draft = true") + } + // Should still transition to In Review even for repo-config drafts. + found := false + for _, s := range transitions { + if s == "In Review" { + found = true + } + } + if !found { + t.Errorf("transitions = %v, want In Review", transitions) + } } // --- Security-level tickets --- @@ -3036,6 +3074,137 @@ func TestFeedbackPipeline_NonForkMode_SkipsFetchRemote(t *testing.T) { } } +// --- Feedback validation label paths --- + +func TestFeedbackPipeline_NonZeroExit_AppliesLabel(t *testing.T) { + d := newTestDeps(t) + + d.git.GetPRForBranchFunc = func(owner, repo, head string) (*models.PRDetails, error) { + return &models.PRDetails{ + Number: 42, Title: "Fix a bug", + Branch: "ai-bot/PROJ-1", URL: "https://github.com/org/repo/pull/42", + }, nil + } + d.git.GetPRCommentsFunc = func(owner, repo string, number int, since time.Time) ([]models.PRComment, error) { + return []models.PRComment{ + {ID: 1, Author: models.Author{Username: "reviewer"}, Body: "Fix this"}, + }, nil + } + d.containers.ExecFunc = func(ctx context.Context, ctr *container.Container, cmd []string) (string, int, error) { + return "", 1, nil + } + + var addedLabels []string + d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { + addedLabels = append(addedLabels, label) + return nil + } + + p := d.pipeline(t) + result, err := p.Execute(context.Background(), &jobmanager.Job{ + ID: "j1", TicketKey: "PROJ-1", Type: jobmanager.JobTypeFeedback, + AttemptNum: 1, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ValidationPassed { + t.Error("expected ValidationPassed = false") + } + if len(addedLabels) != 1 || addedLabels[0] != "ai-nonzero-exit" { + t.Errorf("addedLabels = %v, want [ai-nonzero-exit]", addedLabels) + } +} + +func TestFeedbackPipeline_ValidationPassed_ClearsLabels(t *testing.T) { + d := newTestDeps(t) + + d.git.GetPRForBranchFunc = func(owner, repo, head string) (*models.PRDetails, error) { + return &models.PRDetails{ + Number: 42, Title: "Fix a bug", + Branch: "ai-bot/PROJ-1", URL: "https://github.com/org/repo/pull/42", + }, nil + } + d.git.GetPRCommentsFunc = func(owner, repo string, number int, since time.Time) ([]models.PRComment, error) { + return []models.PRComment{ + {ID: 1, Author: models.Author{Username: "reviewer"}, Body: "Fix this"}, + }, nil + } + + var removedLabels []string + d.git.RemovePRLabelFunc = func(_, _ string, _ int, label string) error { + removedLabels = append(removedLabels, label) + return nil + } + + p := d.pipeline(t) + result, err := p.Execute(context.Background(), &jobmanager.Job{ + ID: "j1", TicketKey: "PROJ-1", Type: jobmanager.JobTypeFeedback, + AttemptNum: 1, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.ValidationPassed { + t.Error("expected ValidationPassed = true") + } + // Should attempt to remove both validation labels (clearing prior-round labels). + want := map[string]bool{"ai-validation-failed": true, "ai-nonzero-exit": true} + for _, l := range removedLabels { + if want[l] { + delete(want, l) + } + } + if len(want) != 0 { + t.Errorf("missing label removals: %v", want) + } +} + +func TestFeedbackPipeline_NoChanges_LabelsUntouched(t *testing.T) { + d := newTestDeps(t) + + d.git.GetPRForBranchFunc = func(owner, repo, head string) (*models.PRDetails, error) { + return &models.PRDetails{ + Number: 42, Title: "Fix a bug", + Branch: "ai-bot/PROJ-1", URL: "https://github.com/org/repo/pull/42", + }, nil + } + d.git.GetPRCommentsFunc = func(owner, repo string, number int, since time.Time) ([]models.PRComment, error) { + return []models.PRComment{ + {ID: 1, Author: models.Author{Username: "reviewer"}, Body: "Fix this"}, + }, nil + } + d.git.HasChangesFunc = func(dir, baseBranch string) (bool, error) { + return false, nil + } + + labelTouched := false + d.git.AddPRLabelFunc = func(_, _ string, _ int, _ string) error { + labelTouched = true + return nil + } + d.git.RemovePRLabelFunc = func(_, _ string, _ int, _ string) error { + labelTouched = true + return nil + } + + p := d.pipeline(t) + _, err := p.Execute(context.Background(), &jobmanager.Job{ + ID: "j1", TicketKey: "PROJ-1", Type: jobmanager.JobTypeFeedback, + AttemptNum: 1, + }) + + // Non-final attempt with no changes returns an error — that's expected. + if err == nil { + t.Fatal("expected error for no-changes on non-final attempt, got nil") + } + if labelTouched { + t.Error("validation labels should not be touched when no changes are pushed") + } +} + // --- Multi-repo new ticket pipeline --- func newMultiRepoTestDeps(t *testing.T) *testDeps { @@ -3101,6 +3270,10 @@ func newMultiRepoTestDeps(t *testing.T) *testDeps { InProgressStatus: "In Progress", InReviewStatus: "In Review", TodoStatus: "To Do", + PRValidationLabels: models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + }, }, nil }, }, @@ -3363,23 +3536,31 @@ func TestMultiRepoNewTicket_CommitPerRepoWithChanges(t *testing.T) { } } -func TestMultiRepoNewTicket_DraftWhenValidationFails(t *testing.T) { +func TestMultiRepoNewTicket_ValidationFailed_AppliesLabels(t *testing.T) { d := newMultiRepoTestDeps(t) writeSessionOutput(t, d.wsDir, executor.SessionOutput{ ValidationPassed: boolPtr(false), }) - var prDrafts []bool + var prCount int d.git.CreatePRFunc = func(params models.PRParams) (*models.PR, error) { - prDrafts = append(prDrafts, params.Draft) + prCount++ + if params.Draft { + t.Errorf("PR[%d].Draft = true, want false", prCount-1) + } return &models.PR{ - Number: len(prDrafts), - URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", params.Owner, params.Repo, len(prDrafts)), + Number: prCount, + URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", params.Owner, params.Repo, prCount), }, nil } - // Don't expect in-review transition for draft PRs. + var addedLabels []string + d.git.AddPRLabelFunc = func(_, _ string, _ int, label string) error { + addedLabels = append(addedLabels, label) + return nil + } + var transitions []string d.tracker.TransitionStatusFunc = func(key, status string) error { transitions = append(transitions, status) @@ -3392,18 +3573,30 @@ func TestMultiRepoNewTicket_DraftWhenValidationFails(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - for i, draft := range prDrafts { - if !draft { - t.Errorf("PR[%d].Draft = false, want true", i) - } + if result.ValidationPassed { + t.Error("result.ValidationPassed should be false") } - if !result.Draft { - t.Error("result.Draft should be true") + + // Each of the 3 PRs should get the ai-validation-failed label. + wantLabels := 3 + if len(addedLabels) != wantLabels { + t.Fatalf("addedLabels = %v, want %d entries", addedLabels, wantLabels) + } + for i, l := range addedLabels { + if l != "ai-validation-failed" { + t.Errorf("addedLabels[%d] = %q, want ai-validation-failed", i, l) + } } - // Only in-progress transition, no in-review for drafts. - if len(transitions) != 1 || transitions[0] != "In Progress" { - t.Errorf("transitions = %v, want [In Progress] only", transitions) + // Should still transition to In Review. + found := false + for _, s := range transitions { + if s == "In Review" { + found = true + } + } + if !found { + t.Errorf("transitions = %v, want In Review", transitions) } } @@ -3722,6 +3915,10 @@ func newTestDeps(t *testing.T) *testDeps { InProgressStatus: "In Progress", InReviewStatus: "In Review", TodoStatus: "To Do", + PRValidationLabels: models.PRValidationLabels{ + ValidationFailed: "ai-validation-failed", + NonzeroExit: "ai-nonzero-exit", + }, }, nil }, }, diff --git a/jobmanager/manager.go b/jobmanager/manager.go index cc3219e..3b00a74 100644 --- a/jobmanager/manager.go +++ b/jobmanager/manager.go @@ -75,16 +75,17 @@ type JobResult struct { // PRNumber is the number of the created pull request. PRNumber int - // Draft indicates whether the PR was created as a draft - // (e.g., due to validation failures). + // Draft indicates whether the PR was created as a GitHub draft. + // Only true when the repo config has pr.draft: true; validation + // failures no longer produce drafts. Draft bool // CostUSD is the AI session cost reported by the provider. CostUSD float64 - // ValidationPassed indicates whether the AI's own validation - // succeeded. False when the AI reported failures or exited - // with a non-zero code. + // ValidationPassed indicates whether the AI session completed + // successfully: validation was not explicitly failed and the + // container exited with code zero. ValidationPassed bool } diff --git a/models/config.go b/models/config.go index d8aad61..26507b9 100644 --- a/models/config.go +++ b/models/config.go @@ -355,6 +355,13 @@ type ProjectConfig struct { // ticket progression through the autofix pipeline. Empty strings // disable the corresponding label. LifecycleLabels LifecycleLabels `yaml:"lifecycle_labels" mapstructure:"lifecycle_labels"` + + // PRValidationLabels configures GitHub PR labels applied when the + // AI session reports validation failure or exits with a non-zero + // code. Labels are mutually exclusive: at most one is set on a PR. + // Empty strings disable the corresponding label. Suggested values: + // "ai-validation-failed" and "ai-nonzero-exit". + PRValidationLabels PRValidationLabels `yaml:"pr_validation_labels" mapstructure:"pr_validation_labels"` } // FailureLabels holds optional Jira label names applied to tickets in @@ -409,6 +416,27 @@ func (ll LifecycleLabels) All() []string { return []string{ll.Queued, ll.Review, ll.Merged} } +// 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 +// strings disable the corresponding label. +type PRValidationLabels struct { + // ValidationFailed is applied when the AI session explicitly + // reports validation_passed: false. + ValidationFailed string `yaml:"validation_failed" mapstructure:"validation_failed"` + + // NonzeroExit is applied when the AI container exits with a + // non-zero code (and validation was not explicitly reported + // as failed). + NonzeroExit string `yaml:"nonzero_exit" mapstructure:"nonzero_exit"` +} + +// 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} +} + // ImportConfig declares an auxiliary repository to clone into the workspace. type ImportConfig struct { // Repo is the clone URL (e.g., "https://github.com/org/repo"). diff --git a/models/config_test.go b/models/config_test.go index 6a4f832..181eb51 100644 --- a/models/config_test.go +++ b/models/config_test.go @@ -1844,3 +1844,130 @@ workspaces: } }) } + +func TestLoadConfig_PRValidationLabels(t *testing.T) { + tmpKeyPath := createTempKeyFile(t) + defer func() { _ = os.Remove(tmpKeyPath) }() + + baseConfig := ` +ai_provider: claude +claude: + api_key: sk-test +jira: + base_url: https://test.atlassian.net + username: test-user + api_token: test-token + projects: + - project_keys: + - "PROJ1" + status_transitions: + bug: + todo: "To Do" + in_progress: "In Progress" + in_review: "In Review" + workspaces: + default: + repos: + - name: repo + url: "https://github.com/test/repo" + profile: default + components: + "comp": + workspace: default + profiles: + default: {} +github: + app_id: 123456 + private_key_path: "` + tmpKeyPath + `" + bot_username: "test-bot" +workspaces: + base_dir: /tmp/test-workspaces + ttl_days: 7 +` + + t.Run("empty when not configured", func(t *testing.T) { + tmpfile, err := os.CreateTemp("", "config_test_*.yaml") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpfile.Name()) }() + if _, err := tmpfile.WriteString(baseConfig); err != nil { + t.Fatal(err) + } + _ = tmpfile.Close() + + config, err := LoadConfig(tmpfile.Name()) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + vl := config.Jira.Projects[0].PRValidationLabels + if vl.ValidationFailed != "" { + t.Errorf("ValidationFailed = %q, want empty", vl.ValidationFailed) + } + if vl.NonzeroExit != "" { + t.Errorf("NonzeroExit = %q, want empty", vl.NonzeroExit) + } + }) + + t.Run("custom values", func(t *testing.T) { + customConfig := ` +ai_provider: claude +claude: + api_key: sk-test +jira: + base_url: https://test.atlassian.net + username: test-user + api_token: test-token + projects: + - project_keys: + - "PROJ1" + status_transitions: + bug: + todo: "To Do" + in_progress: "In Progress" + in_review: "In Review" + workspaces: + default: + repos: + - name: repo + url: "https://github.com/test/repo" + profile: default + components: + "comp": + workspace: default + profiles: + default: {} + pr_validation_labels: + validation_failed: "custom-vf" + nonzero_exit: "custom-nze" +github: + app_id: 123456 + private_key_path: "` + tmpKeyPath + `" + bot_username: "test-bot" +workspaces: + base_dir: /tmp/test-workspaces + ttl_days: 7 +` + tmpfile, err := os.CreateTemp("", "config_test_*.yaml") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpfile.Name()) }() + if _, err := tmpfile.WriteString(customConfig); err != nil { + t.Fatal(err) + } + _ = tmpfile.Close() + + config, err := LoadConfig(tmpfile.Name()) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + vl := config.Jira.Projects[0].PRValidationLabels + if vl.ValidationFailed != "custom-vf" { + t.Errorf("ValidationFailed = %q, want custom-vf", vl.ValidationFailed) + } + if vl.NonzeroExit != "custom-nze" { + t.Errorf("NonzeroExit = %q, want custom-nze", vl.NonzeroExit) + } + }) +} diff --git a/models/project_settings.go b/models/project_settings.go index 3e02a6f..9761c0b 100644 --- a/models/project_settings.go +++ b/models/project_settings.go @@ -98,6 +98,11 @@ type ProjectSettings struct { // Empty strings disable the corresponding label. LifecycleLabels LifecycleLabels + // PRValidationLabels holds configurable GitHub PR labels applied + // when the AI session reports validation failure or exits with a + // non-zero code. At most one is set on a PR at any time. + PRValidationLabels PRValidationLabels + // MergedStatus is the tracker status name to transition to when // all PRs are merged. Empty means no transition on merge. MergedStatus string diff --git a/projectresolver/resolver.go b/projectresolver/resolver.go index b524498..ccb952c 100644 --- a/projectresolver/resolver.go +++ b/projectresolver/resolver.go @@ -81,6 +81,7 @@ func (r *ConfigResolver) ResolveProject(workItem models.WorkItem) (*models.Proje Container: ws.Container, FailureLabels: pc.FailureLabels, LifecycleLabels: pc.LifecycleLabels, + PRValidationLabels: pc.PRValidationLabels, MergedStatus: transitions.Merged, ForkMode: pc.ForkMode, GitHubUsername: ghUsername, diff --git a/projectresolver/resolver_test.go b/projectresolver/resolver_test.go index c83570d..28f2e1c 100644 --- a/projectresolver/resolver_test.go +++ b/projectresolver/resolver_test.go @@ -1027,6 +1027,57 @@ func TestResolveProject_FailureLabels(t *testing.T) { }) } +func TestResolveProject_PRValidationLabels(t *testing.T) { + t.Run("passes through configured labels", func(t *testing.T) { + cfg := minimalConfig() + cfg.Jira.Projects[0].PRValidationLabels = models.PRValidationLabels{ + ValidationFailed: "custom-vf", + NonzeroExit: "custom-nze", + } + r, err := projectresolver.NewConfigResolver(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ps, err := r.ResolveProject(models.WorkItem{ + Key: "PROJ-1", + Type: "Bug", + Components: []string{"backend"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ps.PRValidationLabels.ValidationFailed != "custom-vf" { + t.Errorf("ValidationFailed = %q, want %q", ps.PRValidationLabels.ValidationFailed, "custom-vf") + } + if ps.PRValidationLabels.NonzeroExit != "custom-nze" { + t.Errorf("NonzeroExit = %q, want %q", ps.PRValidationLabels.NonzeroExit, "custom-nze") + } + }) + + t.Run("defaults to empty when not configured", func(t *testing.T) { + cfg := minimalConfig() + r, err := projectresolver.NewConfigResolver(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ps, err := r.ResolveProject(models.WorkItem{ + Key: "PROJ-1", + Type: "Bug", + Components: []string{"backend"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ps.PRValidationLabels != (models.PRValidationLabels{}) { + t.Errorf("expected zero PRValidationLabels, got %+v", ps.PRValidationLabels) + } + }) +} + func TestResolveFailureLabels(t *testing.T) { t.Run("returns labels for known project", func(t *testing.T) { cfg := minimalConfig()