mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-21 14:58:12 +00:00
add ci-fixer
Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
parent
01320dea88
commit
9f63f1f524
16 changed files with 4179 additions and 61 deletions
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"chainguard.dev/driftlessaf/agents/executor/claudeexecutor"
|
||||
"chainguard.dev/driftlessaf/reconcilers/githubreconciler"
|
||||
"chainguard.dev/driftlessaf/reconcilers/githubreconciler/statusmanager"
|
||||
"chainguard.dev/driftlessaf/workqueue"
|
||||
|
|
@ -22,29 +23,72 @@ import (
|
|||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/imjasonh/terraform-playground/driftlessaf/internal/autolabeler"
|
||||
"github.com/imjasonh/terraform-playground/driftlessaf/internal/cifixer"
|
||||
)
|
||||
|
||||
const reconcilerIdentity = "pr-auto-labeler"
|
||||
const reconcilerIdentity = "pr-reconciler"
|
||||
|
||||
// AutoLabelDetails contains the state tracked by the status manager.
|
||||
type AutoLabelDetails struct {
|
||||
LabelsApplied []string `json:"labelsApplied"`
|
||||
FilesAnalyzed int `json:"filesAnalyzed"`
|
||||
TotalChanges int `json:"totalChanges"`
|
||||
// ReconcilerDetails contains the state tracked by the status manager.
|
||||
type ReconcilerDetails struct {
|
||||
// Auto-labeler results
|
||||
LabelsApplied []string `json:"labelsApplied,omitempty"`
|
||||
FilesAnalyzed int `json:"filesAnalyzed,omitempty"`
|
||||
TotalChanges int `json:"totalChanges,omitempty"`
|
||||
|
||||
// CI fixer results
|
||||
CIFixAttempted bool `json:"ciFixAttempted,omitempty"`
|
||||
CIFixSuccess bool `json:"ciFixSuccess,omitempty"`
|
||||
CIFixFiles []string `json:"ciFixFiles,omitempty"`
|
||||
CIFixReasoning string `json:"ciFixReasoning,omitempty"`
|
||||
CIFixTurns int `json:"ciFixTurns,omitempty"`
|
||||
CIFixNeedsHuman bool `json:"ciFixNeedsHuman,omitempty"`
|
||||
CIFixCommit string `json:"ciFixCommit,omitempty"`
|
||||
}
|
||||
|
||||
// Markdown renders the details for display in the GitHub Check Run.
|
||||
func (d AutoLabelDetails) Markdown() string {
|
||||
func (d ReconcilerDetails) Markdown() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Analyzed **%d files** with **%d lines** changed.\n\n", d.FilesAnalyzed, d.TotalChanges))
|
||||
|
||||
if len(d.LabelsApplied) > 0 {
|
||||
sb.WriteString("### Labels Applied\n\n")
|
||||
for _, label := range d.LabelsApplied {
|
||||
sb.WriteString(fmt.Sprintf("- `%s`\n", label))
|
||||
// Auto-labeler section
|
||||
if d.FilesAnalyzed > 0 {
|
||||
sb.WriteString("## Auto-Labeler\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Analyzed **%d files** with **%d lines** changed.\n\n", d.FilesAnalyzed, d.TotalChanges))
|
||||
|
||||
if len(d.LabelsApplied) > 0 {
|
||||
sb.WriteString("### Labels Applied\n\n")
|
||||
for _, label := range d.LabelsApplied {
|
||||
sb.WriteString(fmt.Sprintf("- `%s`\n", label))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("_No labels matched the changed files._\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// CI fixer section
|
||||
if d.CIFixAttempted {
|
||||
sb.WriteString("## CI Fixer\n\n")
|
||||
|
||||
if d.CIFixNeedsHuman {
|
||||
sb.WriteString("⚠️ **Human intervention requested**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Reason: %s\n\n", d.CIFixReasoning))
|
||||
} else if d.CIFixSuccess {
|
||||
sb.WriteString("✅ **Fix applied successfully**\n\n")
|
||||
if len(d.CIFixFiles) > 0 {
|
||||
sb.WriteString("### Files Modified\n\n")
|
||||
for _, f := range d.CIFixFiles {
|
||||
sb.WriteString(fmt.Sprintf("- `%s`\n", f))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if d.CIFixCommit != "" {
|
||||
sb.WriteString(fmt.Sprintf("Commit: `%s`\n\n", d.CIFixCommit))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Turns used: %d\n\n", d.CIFixTurns))
|
||||
} else {
|
||||
sb.WriteString("❌ **Could not fix CI failure**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Reason: %s\n\n", d.CIFixReasoning))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("_No labels matched the changed files._\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
|
@ -54,6 +98,14 @@ type envConfig struct {
|
|||
Port int `env:"PORT, default=8080"`
|
||||
GithubAppID int64 `env:"GITHUB_APP_ID, required"`
|
||||
GithubPrivateKey string `env:"GITHUB_PRIVATE_KEY, required"`
|
||||
|
||||
// CI Fixer configuration
|
||||
EnableCIFixer bool `env:"ENABLE_CI_FIXER, default=false"`
|
||||
GCPProjectID string `env:"GCP_PROJECT_ID"`
|
||||
GCPRegion string `env:"GCP_REGION, default=us-central1"`
|
||||
ClaudeModel string `env:"CLAUDE_MODEL, default=claude-sonnet-4-20250514"`
|
||||
MaxTurns int `env:"MAX_TURNS, default=3"`
|
||||
CIFixerLabel string `env:"CI_FIXER_LABEL, default=ci-autofix"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -77,18 +129,39 @@ func main() {
|
|||
appClient := github.NewClient(&http.Client{Transport: appTransport})
|
||||
|
||||
// Create status manager for tracking reconciliation state
|
||||
statusMgr, err := statusmanager.NewStatusManager[AutoLabelDetails](ctx, reconcilerIdentity)
|
||||
statusMgr, err := statusmanager.NewStatusManager[ReconcilerDetails](ctx, reconcilerIdentity)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create status manager: %v", err)
|
||||
}
|
||||
|
||||
reconciler := &PRReconciler{
|
||||
appID: cfg.GithubAppID,
|
||||
privateKey: privateKey,
|
||||
appClient: appClient,
|
||||
clients: make(map[int64]*github.Client),
|
||||
statusMgr: statusMgr,
|
||||
labeler: autolabeler.NewWithDefaults(),
|
||||
appID: cfg.GithubAppID,
|
||||
privateKey: privateKey,
|
||||
appClient: appClient,
|
||||
clients: make(map[int64]*github.Client),
|
||||
transports: make(map[int64]*ghinstallation.Transport),
|
||||
statusMgr: statusMgr,
|
||||
labeler: autolabeler.NewWithDefaults(),
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
// Initialize CI fixer agent if enabled
|
||||
if cfg.EnableCIFixer {
|
||||
if cfg.GCPProjectID == "" {
|
||||
log.Fatalf("GCP_PROJECT_ID is required when ENABLE_CI_FIXER=true")
|
||||
}
|
||||
|
||||
agent, err := cifixer.NewAgent(ctx, cifixer.AgentConfig{
|
||||
GCPProjectID: cfg.GCPProjectID,
|
||||
GCPRegion: cfg.GCPRegion,
|
||||
Model: cfg.ClaudeModel,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create CI fixer agent: %v", err)
|
||||
}
|
||||
reconciler.ciFixerAgent = agent
|
||||
|
||||
log.Infof("CI fixer agent enabled with model %s, max turns %d", cfg.ClaudeModel, cfg.MaxTurns)
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port))
|
||||
|
|
@ -115,11 +188,16 @@ type PRReconciler struct {
|
|||
appID int64
|
||||
privateKey []byte
|
||||
appClient *github.Client
|
||||
statusMgr *statusmanager.StatusManager[AutoLabelDetails]
|
||||
statusMgr *statusmanager.StatusManager[ReconcilerDetails]
|
||||
labeler *autolabeler.Labeler
|
||||
cfg envConfig
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[int64]*github.Client
|
||||
// CI fixer components (nil if disabled)
|
||||
ciFixerAgent claudeexecutor.Interface[*cifixer.CIContext, *cifixer.CIFixResult]
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[int64]*github.Client
|
||||
transports map[int64]*ghinstallation.Transport
|
||||
}
|
||||
|
||||
// getClientForRepo returns a GitHub client authenticated for the installation
|
||||
|
|
@ -164,11 +242,29 @@ func (r *PRReconciler) getClientForRepo(ctx context.Context, owner, repo string)
|
|||
|
||||
client = github.NewClient(&http.Client{Transport: itr})
|
||||
r.clients[installationID] = client
|
||||
r.transports[installationID] = itr
|
||||
|
||||
log.Infof("created new client for installation %d", installationID)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// getTransportForRepo returns the ghinstallation transport for a repo.
|
||||
func (r *PRReconciler) getTransportForRepo(ctx context.Context, owner, repo string) (*ghinstallation.Transport, error) {
|
||||
// Ensure the client exists (this populates the transport cache)
|
||||
if _, err := r.getClientForRepo(ctx, owner, repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
installation, _, err := r.appClient.Apps.FindRepositoryInstallation(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding installation: %w", err)
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.transports[installation.GetID()], nil
|
||||
}
|
||||
|
||||
// prURLRegex matches GitHub PR URLs like https://github.com/owner/repo/pull/123
|
||||
var prURLRegex = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)$`)
|
||||
|
||||
|
|
@ -229,30 +325,105 @@ func (r *PRReconciler) Process(ctx context.Context, req *workqueue.ProcessReques
|
|||
}
|
||||
session := r.statusMgr.NewSession(gh, resource, sha)
|
||||
|
||||
// Check if we've already processed this SHA
|
||||
// Check existing state for this SHA
|
||||
existingStatus, err := session.ObservedState(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("failed to get observed state: %v", err)
|
||||
// Continue anyway - we'll create a new check run
|
||||
}
|
||||
|
||||
if existingStatus != nil && existingStatus.ObservedGeneration == sha {
|
||||
log.Infof("already processed, skipping")
|
||||
// Get previous turn count from existing state (for CI fixer continuity)
|
||||
previousTurn := 0
|
||||
if existingStatus != nil {
|
||||
previousTurn = existingStatus.Details.CIFixTurns
|
||||
}
|
||||
|
||||
// Determine if we should skip this reconciliation
|
||||
// For auto-labeler: skip if we've already processed this SHA
|
||||
// For CI fixer: only skip if CI is passing, max turns reached, or needs human
|
||||
skipAutoLabeler := existingStatus != nil && existingStatus.ObservedGeneration == sha
|
||||
skipCIFixer := existingStatus != nil && existingStatus.ObservedGeneration == sha &&
|
||||
(existingStatus.Details.CIFixSuccess ||
|
||||
existingStatus.Details.CIFixNeedsHuman ||
|
||||
existingStatus.Details.CIFixTurns >= r.cfg.MaxTurns)
|
||||
|
||||
if skipAutoLabeler && skipCIFixer {
|
||||
log.Infof("already processed SHA %s, skipping", sha[:8])
|
||||
return &workqueue.ProcessResponse{}, nil
|
||||
}
|
||||
|
||||
// Set in-progress status
|
||||
if err := session.SetActualState(ctx, "Analyzing changed files...", &statusmanager.Status[AutoLabelDetails]{
|
||||
if err := session.SetActualState(ctx, "Analyzing PR...", &statusmanager.Status[ReconcilerDetails]{
|
||||
Status: "in_progress",
|
||||
}); err != nil {
|
||||
log.Warnf("failed to set in-progress status: %v", err)
|
||||
}
|
||||
|
||||
details := ReconcilerDetails{}
|
||||
|
||||
// Run auto-labeler (only if not already processed for this SHA)
|
||||
if !skipAutoLabeler {
|
||||
if err := r.runAutoLabeler(ctx, gh, owner, repo, number, pr, &details); err != nil {
|
||||
log.Errorf("auto-labeler failed: %v", err)
|
||||
// Continue to CI fixer even if labeler fails
|
||||
}
|
||||
} else {
|
||||
// Preserve previous labeler results
|
||||
details.LabelsApplied = existingStatus.Details.LabelsApplied
|
||||
details.FilesAnalyzed = existingStatus.Details.FilesAnalyzed
|
||||
details.TotalChanges = existingStatus.Details.TotalChanges
|
||||
}
|
||||
|
||||
// Run CI fixer if enabled and the label is present
|
||||
if r.ciFixerAgent != nil && hasLabel(pr.Labels, r.cfg.CIFixerLabel) && !skipCIFixer {
|
||||
if err := r.runCIFixer(ctx, gh, owner, repo, number, pr, &details, previousTurn); err != nil {
|
||||
log.Errorf("CI fixer failed: %v", err)
|
||||
}
|
||||
} else if existingStatus != nil {
|
||||
// Preserve previous CI fixer results if we're skipping
|
||||
details.CIFixAttempted = existingStatus.Details.CIFixAttempted
|
||||
details.CIFixSuccess = existingStatus.Details.CIFixSuccess
|
||||
details.CIFixFiles = existingStatus.Details.CIFixFiles
|
||||
details.CIFixReasoning = existingStatus.Details.CIFixReasoning
|
||||
details.CIFixTurns = existingStatus.Details.CIFixTurns
|
||||
details.CIFixNeedsHuman = existingStatus.Details.CIFixNeedsHuman
|
||||
details.CIFixCommit = existingStatus.Details.CIFixCommit
|
||||
}
|
||||
|
||||
// Determine conclusion
|
||||
conclusion := "success"
|
||||
summary := "Reconciliation complete"
|
||||
if details.CIFixAttempted && !details.CIFixSuccess && !details.CIFixNeedsHuman {
|
||||
conclusion = "failure"
|
||||
summary = "CI fix attempted but failed"
|
||||
} else if details.CIFixNeedsHuman {
|
||||
conclusion = "action_required"
|
||||
summary = "Human intervention requested"
|
||||
}
|
||||
|
||||
// Set final status
|
||||
if err := session.SetActualState(ctx, summary, &statusmanager.Status[ReconcilerDetails]{
|
||||
ObservedGeneration: sha,
|
||||
Status: "completed",
|
||||
Conclusion: conclusion,
|
||||
Details: details,
|
||||
}); err != nil {
|
||||
log.Warnf("failed to set final status: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("reconciliation complete: labels=%v ci_fix=%v",
|
||||
details.LabelsApplied, details.CIFixSuccess)
|
||||
|
||||
return &workqueue.ProcessResponse{}, nil
|
||||
}
|
||||
|
||||
func (r *PRReconciler) runAutoLabeler(ctx context.Context, gh *github.Client, owner, repo string, number int, pr *github.PullRequest, details *ReconcilerDetails) error {
|
||||
log := clog.FromContext(ctx)
|
||||
|
||||
// Get changed files
|
||||
files, _, err := gh.PullRequests.ListFiles(ctx, owner, repo, number, &github.ListOptions{PerPage: 100})
|
||||
if err != nil {
|
||||
log.Errorf("failed to fetch changed files: %v", err)
|
||||
return nil, err
|
||||
return fmt.Errorf("listing files: %w", err)
|
||||
}
|
||||
|
||||
// Calculate labels using the autolabeler package
|
||||
|
|
@ -261,47 +432,249 @@ func (r *PRReconciler) Process(ctx context.Context, req *workqueue.ProcessReques
|
|||
// Filter to only labels we need to add
|
||||
newLabels := autolabeler.FilterNewLabels(result.Labels, pr.Labels)
|
||||
|
||||
details := AutoLabelDetails{
|
||||
LabelsApplied: result.Labels,
|
||||
FilesAnalyzed: result.FilesAnalyzed,
|
||||
TotalChanges: result.TotalChanges,
|
||||
}
|
||||
details.LabelsApplied = result.Labels
|
||||
details.FilesAnalyzed = result.FilesAnalyzed
|
||||
details.TotalChanges = result.TotalChanges
|
||||
|
||||
// Apply new labels if any
|
||||
if len(newLabels) > 0 {
|
||||
log.Infof("adding labels: %v", newLabels)
|
||||
_, _, err = gh.Issues.AddLabelsToIssue(ctx, owner, repo, number, newLabels)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add labels: %v", err)
|
||||
// Set failure status
|
||||
if statusErr := session.SetActualState(ctx, "Failed to apply labels", &statusmanager.Status[AutoLabelDetails]{
|
||||
ObservedGeneration: sha,
|
||||
Status: "completed",
|
||||
Conclusion: "failure",
|
||||
Details: details,
|
||||
}); statusErr != nil {
|
||||
log.Warnf("failed to set failure status: %v", statusErr)
|
||||
}
|
||||
return nil, err
|
||||
return fmt.Errorf("adding labels: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Infof("no new labels to add (calculated: %v)", result.Labels)
|
||||
}
|
||||
|
||||
// Set success status
|
||||
if err := session.SetActualState(ctx, "Labels applied successfully", &statusmanager.Status[AutoLabelDetails]{
|
||||
ObservedGeneration: sha,
|
||||
Status: "completed",
|
||||
Conclusion: "success",
|
||||
Details: details,
|
||||
}); err != nil {
|
||||
log.Warnf("failed to set success status: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PRReconciler) runCIFixer(ctx context.Context, gh *github.Client, owner, repo string, number int, pr *github.PullRequest, details *ReconcilerDetails, previousTurn int) error {
|
||||
log := clog.FromContext(ctx)
|
||||
|
||||
details.CIFixAttempted = true
|
||||
|
||||
// Get check runs for the PR head
|
||||
sha := pr.GetHead().GetSHA()
|
||||
checks, _, err := gh.Checks.ListCheckRunsForRef(ctx, owner, repo, sha, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing check runs: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("auto-labeling complete: applied=%v files=%d changes=%d",
|
||||
result.Labels, result.FilesAnalyzed, result.TotalChanges)
|
||||
// Count check states to determine if we should act
|
||||
var pending, failed, passed int
|
||||
var failures []cifixer.CheckFailure
|
||||
|
||||
return &workqueue.ProcessResponse{}, nil
|
||||
for _, check := range checks.CheckRuns {
|
||||
// Skip our own check run
|
||||
if check.GetName() == reconcilerIdentity {
|
||||
continue
|
||||
}
|
||||
|
||||
status := check.GetStatus()
|
||||
conclusion := check.GetConclusion()
|
||||
|
||||
if status != "completed" {
|
||||
pending++
|
||||
} else if conclusion == "success" || conclusion == "skipped" || conclusion == "neutral" {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
// Collect failure details
|
||||
logs := r.getCheckLogs(ctx, gh, owner, repo, check.GetID())
|
||||
failures = append(failures, cifixer.CheckFailure{
|
||||
Name: check.GetName(),
|
||||
Conclusion: conclusion,
|
||||
Logs: logs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("CI status: pending=%d passed=%d failed=%d", pending, passed, failed)
|
||||
|
||||
// If checks are still pending, skip this reconciliation.
|
||||
// We'll be triggered again when they complete.
|
||||
if pending > 0 {
|
||||
log.Infof("CI still pending (%d checks), skipping CI fixer", pending)
|
||||
details.CIFixReasoning = fmt.Sprintf("Waiting for %d CI checks to complete", pending)
|
||||
details.CIFixTurns = previousTurn
|
||||
return nil
|
||||
}
|
||||
|
||||
// If all checks passing, we're done!
|
||||
if failed == 0 {
|
||||
log.Infof("All %d checks passing", passed)
|
||||
details.CIFixSuccess = true
|
||||
details.CIFixTurns = previousTurn
|
||||
if previousTurn > 0 {
|
||||
details.CIFixReasoning = fmt.Sprintf("CI passed after %d fix attempt(s)", previousTurn)
|
||||
} else {
|
||||
details.CIFixReasoning = "CI is passing"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate current turn
|
||||
currentTurn := previousTurn + 1
|
||||
if currentTurn > r.cfg.MaxTurns {
|
||||
log.Warnf("Max turns (%d) reached without fixing CI", r.cfg.MaxTurns)
|
||||
details.CIFixTurns = previousTurn
|
||||
details.CIFixReasoning = fmt.Sprintf("Max turns (%d) reached without fixing CI", r.cfg.MaxTurns)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("Running CI fixer turn %d/%d with %d failing checks: %v",
|
||||
currentTurn, r.cfg.MaxTurns, len(failures), getFailureNames(failures))
|
||||
|
||||
// Get changed files for context
|
||||
prFiles, _, err := gh.PullRequests.ListFiles(ctx, owner, repo, number, &github.ListOptions{PerPage: 100})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing PR files: %w", err)
|
||||
}
|
||||
changedFiles := make([]string, 0, len(prFiles))
|
||||
for _, f := range prFiles {
|
||||
changedFiles = append(changedFiles, f.GetFilename())
|
||||
}
|
||||
|
||||
// Get the installation transport for git operations
|
||||
transport, err := r.getTransportForRepo(ctx, owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting transport: %w", err)
|
||||
}
|
||||
|
||||
// Create GitManager with token source
|
||||
tokenSource := cifixer.NewGitHubAppTokenSource(func() (string, error) {
|
||||
return transport.Token(ctx)
|
||||
})
|
||||
|
||||
gitMgr, err := cifixer.NewGitManager(tokenSource, reconcilerIdentity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating git manager: %w", err)
|
||||
}
|
||||
|
||||
// Clone the PR branch
|
||||
branch := pr.GetHead().GetRef()
|
||||
clone, err := gitMgr.ClonePRBranch(ctx, owner, repo, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cloning PR branch: %w", err)
|
||||
}
|
||||
defer clone.Close()
|
||||
|
||||
// Create real filesystem for the clone
|
||||
fs := cifixer.NewRealFileSystem(clone.Dir())
|
||||
|
||||
// Run a single turn of the agent
|
||||
ciCtx := &cifixer.CIContext{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
PRNumber: number,
|
||||
Branch: branch,
|
||||
Turn: currentTurn,
|
||||
MaxTurns: r.cfg.MaxTurns,
|
||||
Failures: failures,
|
||||
ChangedFiles: changedFiles,
|
||||
}
|
||||
|
||||
result, err := cifixer.ExecuteWithFS(ctx, r.ciFixerAgent, fs, ciCtx)
|
||||
if err != nil {
|
||||
details.CIFixReasoning = fmt.Sprintf("Agent execution failed: %v", err)
|
||||
details.CIFixTurns = currentTurn
|
||||
return fmt.Errorf("agent execution: %w", err)
|
||||
}
|
||||
|
||||
details.CIFixTurns = currentTurn
|
||||
|
||||
if result.NeedsHuman {
|
||||
details.CIFixNeedsHuman = true
|
||||
details.CIFixReasoning = result.Reasoning
|
||||
log.Warnf("agent requests human intervention: %s", result.Reasoning)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !result.Success || len(result.FilesChanged) == 0 {
|
||||
details.CIFixReasoning = result.Reasoning
|
||||
log.Warnf("agent made no changes on turn %d: %s", currentTurn, result.Reasoning)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit and push the fix
|
||||
details.CIFixFiles = result.FilesChanged
|
||||
commitMsg := result.CommitMessage
|
||||
if commitMsg == "" {
|
||||
commitMsg = "fix: automated CI fix"
|
||||
}
|
||||
|
||||
log.Infof("committing fix: %s (files: %v)", commitMsg, result.FilesChanged)
|
||||
|
||||
if err := clone.CommitAndPush(ctx, commitMsg); err != nil {
|
||||
details.CIFixReasoning = fmt.Sprintf("Failed to push fix: %v", err)
|
||||
return fmt.Errorf("commit and push: %w", err)
|
||||
}
|
||||
|
||||
details.CIFixCommit = clone.SHA()
|
||||
details.CIFixReasoning = result.Reasoning
|
||||
log.Infof("pushed commit %s - returning immediately (next reconcile will check CI)", details.CIFixCommit[:8])
|
||||
|
||||
// Return immediately - don't wait for CI!
|
||||
// The workqueue will trigger a new reconciliation when GitHub workflow
|
||||
// events fire on the new commit.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PRReconciler) getCheckLogs(ctx context.Context, gh *github.Client, owner, repo string, checkRunID int64) string {
|
||||
// GitHub doesn't provide a direct API to get check run logs.
|
||||
// We can get annotations which often contain error details.
|
||||
annotations, _, err := gh.Checks.ListCheckRunAnnotations(ctx, owner, repo, checkRunID, nil)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(failed to get annotations: %v)", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, a := range annotations {
|
||||
sb.WriteString(fmt.Sprintf("%s:%d: %s - %s\n",
|
||||
a.GetPath(), a.GetStartLine(), a.GetAnnotationLevel(), a.GetMessage()))
|
||||
}
|
||||
|
||||
// Also try to get the check run output
|
||||
checkRun, _, err := gh.Checks.GetCheckRun(ctx, owner, repo, checkRunID)
|
||||
if err == nil && checkRun.Output != nil {
|
||||
if summary := checkRun.Output.GetSummary(); summary != "" {
|
||||
sb.WriteString("\n--- Summary ---\n")
|
||||
sb.WriteString(summary)
|
||||
}
|
||||
if text := checkRun.Output.GetText(); text != "" {
|
||||
sb.WriteString("\n--- Details ---\n")
|
||||
// Truncate very long output
|
||||
if len(text) > 5000 {
|
||||
text = text[:5000] + "\n... (truncated)"
|
||||
}
|
||||
sb.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
if sb.Len() == 0 {
|
||||
return "(no logs available)"
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func getFailureNames(failures []cifixer.CheckFailure) []string {
|
||||
names := make([]string, len(failures))
|
||||
for i, f := range failures {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func hasLabel(labels []*github.Label, name string) bool {
|
||||
for _, l := range labels {
|
||||
if l.GetName() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *PRReconciler) GetKeyState(ctx context.Context, req *workqueue.GetKeyStateRequest) (*workqueue.KeyState, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue