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
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions executor/executortest/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions executor/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
31 changes: 27 additions & 4 deletions executor/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)),
Expand Down
53 changes: 53 additions & 0 deletions executor/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
adalton marked this conversation as resolved.
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))
}
}
}
}
Loading
Loading