1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-06 23:12:22 +00:00
terraform-playground/driftlessaf/cmd/pr-reconciler/main.go
copilot-swe-agent[bot] 6e73e0d463 Fix golangci-lint staticcheck issues in pr-reconciler main.go
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
2026-02-19 02:17:05 +00:00

784 lines
No EOL
24 KiB
Go

package main
import (
"context"
"fmt"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"chainguard.dev/driftlessaf/agents/executor/claudeexecutor"
"chainguard.dev/driftlessaf/reconcilers/githubreconciler"
"chainguard.dev/driftlessaf/reconcilers/githubreconciler/statusmanager"
"chainguard.dev/driftlessaf/workqueue"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/chainguard-dev/clog"
_ "github.com/chainguard-dev/clog/gcp/init"
"github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics"
"github.com/google/go-github/v75/github"
"github.com/sethvargo/go-envconfig"
"google.golang.org/grpc"
"github.com/imjasonh/terraform-playground/driftlessaf/internal/autolabeler"
"github.com/imjasonh/terraform-playground/driftlessaf/internal/cifixer"
)
const reconcilerIdentity = "pr-reconciler"
// 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"`
CIFixPending bool `json:"ciFixPending,omitempty"` // CI checks still running
}
// Markdown renders the details for display in the GitHub Check Run.
func (d ReconcilerDetails) Markdown() string {
var sb strings.Builder
// Auto-labeler section
if d.FilesAnalyzed > 0 {
sb.WriteString("## Auto-Labeler\n\n")
fmt.Fprintf(&sb, "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 {
fmt.Fprintf(&sb, "- `%s`\n", label)
}
sb.WriteString("\n")
} else {
sb.WriteString("_No labels matched the changed files._\n\n")
}
}
// CI fixer section - show if any CI fixer activity occurred
if d.CIFixAttempted || d.CIFixPending || d.CIFixSuccess {
sb.WriteString("## CI Fixer\n\n")
if d.CIFixPending {
// CI is still running, waiting to evaluate
sb.WriteString("⏳ **Waiting for CI to complete**\n\n")
fmt.Fprintf(&sb, "%s\n\n", d.CIFixReasoning)
} else if d.CIFixNeedsHuman {
// Agent determined human intervention is needed
sb.WriteString("⚠️ **Human intervention requested**\n\n")
fmt.Fprintf(&sb, "Reason: %s\n\n", d.CIFixReasoning)
if d.CIFixTurns > 0 {
fmt.Fprintf(&sb, "Turns attempted: %d\n\n", d.CIFixTurns)
}
} else if d.CIFixSuccess {
// CI is passing
if d.CIFixTurns > 0 {
// We made fixes and CI now passes
sb.WriteString("✅ **CI passing after fix**\n\n")
if len(d.CIFixFiles) > 0 {
sb.WriteString("### Files Modified\n\n")
for _, f := range d.CIFixFiles {
fmt.Fprintf(&sb, "- `%s`\n", f)
}
sb.WriteString("\n")
}
if d.CIFixCommit != "" {
fmt.Fprintf(&sb, "Commit: `%s`\n\n", d.CIFixCommit)
}
fmt.Fprintf(&sb, "Turns used: %d\n\n", d.CIFixTurns)
} else {
// CI was already passing, no fixes needed
sb.WriteString("✅ **CI is passing**\n\n")
sb.WriteString("No fixes were needed.\n\n")
}
} else if d.CIFixAttempted {
// We attempted fixes but CI still failing
sb.WriteString("❌ **Could not fix CI failure**\n\n")
fmt.Fprintf(&sb, "Reason: %s\n\n", d.CIFixReasoning)
if d.CIFixTurns > 0 {
fmt.Fprintf(&sb, "Turns attempted: %d\n\n", d.CIFixTurns)
}
}
}
return sb.String()
}
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() {
ctx := context.Background()
log := clog.FromContext(ctx)
var cfg envConfig
envconfig.MustProcess(ctx, &cfg)
privateKey := []byte(cfg.GithubPrivateKey)
// Create an App-level transport for looking up installations
appTransport, err := ghinstallation.NewAppsTransport(
httpmetrics.WrapTransport(http.DefaultTransport),
cfg.GithubAppID,
privateKey,
)
if err != nil {
log.Fatalf("failed to create GitHub app transport: %v", err)
}
appClient := github.NewClient(&http.Client{Transport: appTransport})
// Create status manager for tracking reconciliation state
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),
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))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
workqueue.RegisterWorkqueueServiceServer(grpcServer, reconciler)
// ServeMetrics must run in a goroutine - it blocks!
go httpmetrics.ServeMetrics()
defer httpmetrics.SetupTracer(ctx)()
log.Infof("starting gRPC server on port %d", cfg.Port)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
type PRReconciler struct {
workqueue.UnimplementedWorkqueueServiceServer
appID int64
privateKey []byte
appClient *github.Client
statusMgr *statusmanager.StatusManager[ReconcilerDetails]
labeler *autolabeler.Labeler
cfg envConfig
// 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
// that has access to the given owner/repo.
func (r *PRReconciler) getClientForRepo(ctx context.Context, owner, repo string) (*github.Client, error) {
log := clog.FromContext(ctx)
// Look up which installation has access to this repo
installation, _, err := r.appClient.Apps.FindRepositoryInstallation(ctx, owner, repo)
if err != nil {
return nil, fmt.Errorf("finding installation for %s/%s: %w", owner, repo, err)
}
installationID := installation.GetID()
// Check cache first
r.mu.RLock()
client, ok := r.clients[installationID]
r.mu.RUnlock()
if ok {
return client, nil
}
// Create new client for this installation
r.mu.Lock()
defer r.mu.Unlock()
// Double-check after acquiring write lock
if client, ok := r.clients[installationID]; ok {
return client, nil
}
itr, err := ghinstallation.New(
httpmetrics.WrapTransport(http.DefaultTransport),
r.appID,
installationID,
r.privateKey,
)
if err != nil {
return nil, fmt.Errorf("creating installation transport: %w", err)
}
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+)$`)
// ciState represents the evaluated state of CI checks
type ciState int
const (
ciStateNoChecks ciState = iota // No checks exist yet (workflow not started)
ciStatePending // Checks are running
ciStatePassing // All checks passed
ciStateFailing // One or more checks failed
)
// ciStatusResult holds the result of CI status evaluation
type ciStatusResult struct {
State ciState
Reasoning string
}
// evaluateCIStatus determines the CI state based on check counts.
// This is extracted to enable unit testing of the CI evaluation logic.
func evaluateCIStatus(pending, passed, failed, previousTurn int) ciStatusResult {
// No checks at all - workflow hasn't started yet
if pending == 0 && passed == 0 && failed == 0 {
return ciStatusResult{
State: ciStateNoChecks,
Reasoning: "Waiting for CI checks to start",
}
}
// Checks still running
if pending > 0 {
return ciStatusResult{
State: ciStatePending,
Reasoning: fmt.Sprintf("Waiting for %d CI checks to complete", pending),
}
}
// All checks completed and none failed
if failed == 0 {
if previousTurn > 0 {
attemptWord := "attempt"
if previousTurn > 1 {
attemptWord = "attempts"
}
return ciStatusResult{
State: ciStatePassing,
Reasoning: fmt.Sprintf("CI passed after %d fix %s", previousTurn, attemptWord),
}
}
return ciStatusResult{
State: ciStatePassing,
Reasoning: "CI is passing",
}
}
// Some checks failed
return ciStatusResult{
State: ciStateFailing,
Reasoning: fmt.Sprintf("%d CI checks failed", failed),
}
}
func parsePRURL(url string) (owner, repo string, number int, err error) {
matches := prURLRegex.FindStringSubmatch(url)
if matches == nil {
return "", "", 0, fmt.Errorf("invalid PR URL: %s", url)
}
number, err = strconv.Atoi(matches[3])
if err != nil {
return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", url, err)
}
return matches[1], matches[2], number, nil
}
func (r *PRReconciler) Process(ctx context.Context, req *workqueue.ProcessRequest) (*workqueue.ProcessResponse, error) {
log := clog.FromContext(ctx).With("key", req.Key)
owner, repo, number, err := parsePRURL(req.Key)
if err != nil {
log.Warnf("skipping unknown key format: %v", err)
return &workqueue.ProcessResponse{}, nil
}
log = log.With("owner", owner, "repo", repo, "number", number)
ctx = clog.WithLogger(ctx, log)
gh, err := r.getClientForRepo(ctx, owner, repo)
if err != nil {
log.Errorf("failed to get GitHub client: %v", err)
return nil, err
}
pr, _, err := gh.PullRequests.Get(ctx, owner, repo, number)
if err != nil {
log.Errorf("failed to fetch PR: %v", err)
return nil, err
}
// Add SHA to logger context for Cloud Logging filtering
sha := pr.GetHead().GetSHA()
log = log.With("sha", sha)
ctx = clog.WithLogger(ctx, log)
// Skip closed PRs
if pr.GetState() != "open" {
log.Infof("skipping closed PR")
return &workqueue.ProcessResponse{}, nil
}
log.Infof("processing PR: title=%q state=%s author=%s",
pr.GetTitle(), pr.GetState(), pr.GetUser().GetLogin())
// Create status manager session for this PR
resource := &githubreconciler.Resource{
Type: githubreconciler.ResourceTypePullRequest,
Owner: owner,
Repo: repo,
URL: req.Key,
Ref: pr.GetBase().GetRef(),
}
session := r.statusMgr.NewSession(gh, resource, 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
}
// 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 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 and status
status := "completed"
conclusion := "success"
summary := "Reconciliation complete"
if details.CIFixPending {
status = "in_progress"
conclusion = ""
summary = "Waiting for CI checks to complete"
} else 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: status,
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)
// If CI is pending, requeue to check again in 60 seconds
if details.CIFixPending {
log.Infof("CI pending, requeueing to check again in 60 seconds")
return &workqueue.ProcessResponse{RequeueAfterSeconds: 60}, nil
}
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 {
return fmt.Errorf("listing files: %w", err)
}
// Calculate labels using the autolabeler package
result := r.labeler.CalculateLabels(files)
// Filter to only labels we need to add
newLabels := autolabeler.FilterNewLabels(result.Labels, pr.Labels)
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 {
return fmt.Errorf("adding labels: %w", err)
}
} else {
log.Infof("no new labels to add (calculated: %v)", result.Labels)
}
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)
}
// Count check states to determine if we should act
var pending, failed, passed int
var failures []cifixer.CheckFailure
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)
// Evaluate CI status and update details accordingly
status := evaluateCIStatus(pending, passed, failed, previousTurn)
switch status.State {
case ciStateNoChecks:
log.Infof("No CI checks found yet, treating as pending")
details.CIFixPending = true
details.CIFixReasoning = status.Reasoning
details.CIFixTurns = previousTurn
return nil
case ciStatePending:
log.Infof("CI still pending (%d checks), skipping CI fixer", pending)
details.CIFixPending = true
details.CIFixReasoning = status.Reasoning
details.CIFixTurns = previousTurn
return nil
case ciStatePassing:
log.Infof("All %d checks passing", passed)
details.CIFixSuccess = true
details.CIFixTurns = previousTurn
details.CIFixReasoning = status.Reasoning
return nil
case ciStateFailing:
// Continue to attempt fix
}
// 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 func() {
if err := clone.Close(); err != nil {
log.Warnf("failed to close clone: %v", err)
}
}()
// 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 {
fmt.Fprintf(&sb, "%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) {
return &workqueue.KeyState{}, nil
}