diff --git a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go index 836c787402..c6102095f5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go @@ -24,7 +24,9 @@ const ( ciAutomationOrigin = "ci_automation" ciAutomationCheckSuccess = "success" ciAutomationCheckFailure = "failure" + ciAutomationCheckError = "error" ciAutomationCheckCompleted = "completed" + ciAutomationCheckPending = "pending" ciAutomationChangesRequested = "changes_requested" ciAutomationPRFeedbackToken = "{{pr.feedback}}" ciAutomationFixBlockWindow = time.Hour @@ -172,6 +174,9 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, if !ciAutomationCanAutoFixFromFeedbackPR(feedback) { return false } + if !ciAutomationChecksSettledForAutoFix(pr, feedback) { + return false + } feedback = ciAutomationFilterFeedbackForPR(pr, feedback) previous := decodeCIAutomationCheckpoint(state) delta := ciAutomationBuildDelta(feedback, previous) @@ -185,7 +190,7 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, } allowNewRound := !ciAutomationFixRoundsExhausted(state) prompt := ciAutomationRenderPrompt(options.EffectiveAutoFixPrompt, pr, delta) - session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, pr.TaskID) + session, err := s.resolveCIAutoFixSession(ctx, pr.TaskID, state) if err != nil || session == nil { if !allowNewRound { s.markCIAutoFixExhausted(ctx, pr) @@ -220,6 +225,52 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, return true } +func (s *Service) resolveCIAutoFixSession(ctx context.Context, taskID string, state *github.TaskCIPRAutomationState) (*models.TaskSession, error) { + if state != nil && state.LastFixSessionID != nil && strings.TrimSpace(*state.LastFixSessionID) != "" { + session, err := s.repo.GetTaskSession(ctx, *state.LastFixSessionID) + if err != nil && !errors.Is(err, models.ErrTaskSessionNotFound) { + return nil, err + } + if session != nil && session.TaskID != taskID { + return nil, fmt.Errorf("previous CI auto-fix session belongs to task %s", session.TaskID) + } + if ciAutomationSessionCanReceivePrompt(session) { + return session, nil + } + } + sessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, taskID) + if err != nil { + return nil, err + } + for _, session := range sessions { + if ciAutomationSessionCanReceivePrompt(session) && session.IsPrimary { + return session, nil + } + } + for _, session := range sessions { + if ciAutomationSessionCanReceivePrompt(session) { + return session, nil + } + } + return nil, fmt.Errorf("no active agent session for task: %s", taskID) +} + +func ciAutomationSessionCanReceivePrompt(session *models.TaskSession) bool { + if session == nil { + return false + } + switch session.State { + case models.TaskSessionStateCreated, + models.TaskSessionStateStarting, + models.TaskSessionStateRunning, + models.TaskSessionStateWaitingForInput, + models.TaskSessionStateIdle: + return true + default: + return false + } +} + func (s *Service) handleTaskPRCIAutoFixEmptyDelta(ctx context.Context, pr *github.TaskPR, state *github.TaskCIPRAutomationState, previous ciAutomationCheckpoint, signature, checkpointJSON string) bool { if state != nil && state.LastFixSignature == signature && ciAutomationDuplicateFixAttemptBlocksMerge(state) { return true @@ -258,7 +309,7 @@ func (s *Service) handleTaskPRCIAutoMerge(ctx context.Context, pr *github.TaskPR func (s *Service) dispatchCIAutomationPromptForPR(ctx context.Context, session *models.TaskSession, pr *github.TaskPR, prompt, signature string, allowNewRound bool) (ciAutomationDispatchResult, error) { chatPrompt := ciAutomationChatPrompt(prompt) switch session.State { - case models.TaskSessionStateRunning, models.TaskSessionStateStarting: + case models.TaskSessionStateCreated, models.TaskSessionStateRunning, models.TaskSessionStateStarting: return s.queueOrReplaceCIAutomationPromptForPR(ctx, session, pr, chatPrompt, signature, allowNewRound) case models.TaskSessionStateWaitingForInput, models.TaskSessionStateIdle: result, replaced, err := s.replacePendingCIAutomationPromptForPR(ctx, session, pr, chatPrompt, signature) @@ -401,20 +452,66 @@ func ciAutomationCanAutoFixFromFeedbackPR(feedback *github.PRFeedback) bool { return feedback.PR.State != "closed" && feedback.PR.State != "merged" } +func ciAutomationChecksSettledForAutoFix(pr *github.TaskPR, feedback *github.PRFeedback) bool { + if pr != nil && !ciAutomationChecksRollupSettled(pr.ChecksState) { + return false + } + if feedback == nil { + return true + } + for _, check := range feedback.Checks { + if check.Status != ciAutomationCheckCompleted { + return false + } + } + return true +} + +func ciAutomationChecksRollupSettled(state string) bool { + // GitHub GraphQL rollups can expose values like EXPECTED before concrete + // check runs exist. Keep the whitelist narrow so unknown rollup states wait. + switch strings.TrimSpace(strings.ToLower(state)) { + case "", ciAutomationCheckSuccess, ciAutomationCheckFailure, ciAutomationCheckError: + return true + default: + return false + } +} + func ciAutomationFilterFeedbackForPR(pr *github.TaskPR, feedback *github.PRFeedback) *github.PRFeedback { - if feedback == nil || pr == nil || pr.UnresolvedReviewThreads > 0 { + if feedback == nil || pr == nil { return feedback } filtered := *feedback filtered.Comments = make([]github.PRComment, 0, len(feedback.Comments)) + includeBotPRComments := ciAutomationFeedbackHasActionableSignal(pr, feedback) for _, comment := range feedback.Comments { - if comment.Path == "" && comment.Line == 0 { + if ciAutomationShouldIncludeFeedbackComment(pr, comment, includeBotPRComments) { filtered.Comments = append(filtered.Comments, comment) } } return &filtered } +func ciAutomationFeedbackHasActionableSignal(pr *github.TaskPR, feedback *github.PRFeedback) bool { + if pr.UnresolvedReviewThreads > 0 { + return true + } + for _, check := range feedback.Checks { + if check.Status == ciAutomationCheckCompleted && ciAutomationCheckConclusionNeedsFix(check.Conclusion) { + return true + } + } + return false +} + +func ciAutomationShouldIncludeFeedbackComment(pr *github.TaskPR, comment github.PRComment, includeBotPRComments bool) bool { + if comment.Path == "" && comment.Line == 0 { + return !comment.AuthorIsBot || includeBotPRComments + } + return pr.UnresolvedReviewThreads > 0 +} + func ciAutomationReadyToMerge(pr *github.TaskPR) bool { if pr == nil || pr.State != "open" { return false diff --git a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation_test.go b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation_test.go index 0b4e622705..7a165bcc5a 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation_test.go @@ -151,15 +151,26 @@ func TestCIAutomationFilterFeedbackForPRSkipsReviewCommentsWithoutUnresolvedThre Comments: []github.PRComment{ {ID: 1, Body: "resolved review comment", Path: "main.go", Line: 12}, {ID: 2, Body: "plain PR comment"}, + {ID: 3, Body: "bot status comment", AuthorIsBot: true}, }, } filtered := ciAutomationFilterFeedbackForPR(&github.TaskPR{}, feedback) if len(filtered.Comments) != 1 || filtered.Comments[0].ID != 2 { - t.Fatalf("expected only plain PR comment, got %+v", filtered.Comments) + t.Fatalf("expected only human plain PR comment, got %+v", filtered.Comments) } withThreads := ciAutomationFilterFeedbackForPR(&github.TaskPR{UnresolvedReviewThreads: 1}, feedback) - if len(withThreads.Comments) != 2 { - t.Fatalf("expected unresolved review threads to keep review comments, got %+v", withThreads.Comments) + if len(withThreads.Comments) != 3 || withThreads.Comments[0].ID != 1 || withThreads.Comments[1].ID != 2 || withThreads.Comments[2].ID != 3 { + t.Fatalf("expected unresolved review threads to keep review comments and bot context, got %+v", withThreads.Comments) + } + withFailedCheck := ciAutomationFilterFeedbackForPR(&github.TaskPR{}, &github.PRFeedback{ + Checks: []github.CheckRun{{Name: "unit", Status: "completed", Conclusion: "failure"}}, + Comments: []github.PRComment{ + {ID: 1, Body: "human summary"}, + {ID: 2, Body: "bot failure details", AuthorIsBot: true}, + }, + }) + if len(withFailedCheck.Comments) != 2 || withFailedCheck.Comments[0].ID != 1 || withFailedCheck.Comments[1].ID != 2 { + t.Fatalf("expected failed checks to keep bot PR comment context, got %+v", withFailedCheck.Comments) } } @@ -362,6 +373,142 @@ func TestHandleTaskPRCIAutomationAutoFixUsesFreshSyncAndFullFeedback(t *testing. } } +func TestHandleTaskPRCIAutomationAutoFixSkipsBotIssueCommentWithoutActionableFeedback(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + ChecksState: "success", + UnresolvedReviewThreads: 0, + } + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{{Name: "unit", Status: "completed", Conclusion: "success"}}, + Comments: []github.PRComment{{ + ID: 99, + Author: "coderabbitai[bot]", + AuthorIsBot: true, + Body: "Review in progress. No actionable comments posted yet.", + }}, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if ghSvc.prFeedbackCalls != 1 { + t.Fatalf("expected one feedback fetch, got %d", ghSvc.prFeedbackCalls) + } + if status := svc.messageQueue.GetStatus(ctx, "session-1"); status.Count != 0 { + t.Fatalf("expected no queued auto-fix prompt for bot status comment, got %+v", status) + } + if len(ghSvc.fixAttempts) != 0 { + t.Fatalf("expected no fix round for bot status comment, got %+v", ghSvc.fixAttempts) + } +} + +func TestHandleTaskPRCIAutomationAutoFixWaitsForPendingChecks(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + ChecksState: "pending", + UnresolvedReviewThreads: 1, + } + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{ + {Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + {Name: "integration", Status: "in_progress", HTMLURL: "https://ci/integration"}, + }, + Comments: []github.PRComment{{ID: 100, Body: "please address this", Path: "main.go", Line: 12}}, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if ghSvc.prFeedbackCalls != 1 { + t.Fatalf("expected one feedback fetch, got %d", ghSvc.prFeedbackCalls) + } + if status := svc.messageQueue.GetStatus(ctx, "session-1"); status.Count != 0 { + t.Fatalf("expected no queued auto-fix prompt while checks are pending, got %+v", status) + } + if len(ghSvc.fixAttempts) != 0 { + t.Fatalf("expected no fix round while checks are pending, got %+v", ghSvc.fixAttempts) + } +} + +func TestHandleTaskPRCIAutomationAutoFixWaitsForExpectedChecks(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + ChecksState: "expected", + UnresolvedReviewThreads: 1, + } + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Comments: []github.PRComment{{ID: 100, Body: "please address this", Path: "main.go", Line: 12}}, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if ghSvc.prFeedbackCalls != 1 { + t.Fatalf("expected one feedback fetch, got %d", ghSvc.prFeedbackCalls) + } + if status := svc.messageQueue.GetStatus(ctx, "session-1"); status.Count != 0 { + t.Fatalf("expected no queued auto-fix prompt while checks are expected, got %+v", status) + } + if len(ghSvc.fixAttempts) != 0 { + t.Fatalf("expected no fix round while checks are expected, got %+v", ghSvc.fixAttempts) + } +} + func TestHandleTaskPRCIAutomationAutoFixSkipsClosedFetchedPR(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -718,6 +865,260 @@ func TestHandleTaskPRCIAutomationCoalescesQueuedAutoFixForRunningSession(t *test } } +func TestHandleTaskPRCIAutomationStartsAutoFixOnPrimarySession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + if err := repo.SetSessionPrimary(ctx, "session-1"); err != nil { + t.Fatalf("set primary session: %v", err) + } + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-2", + TaskID: "task-1", + State: models.TaskSessionStateRunning, + StartedAt: now.Add(time.Minute), + UpdatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("create newer session: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + LastSyncedAt: &now, + } + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{ + {Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + }, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if status := svc.messageQueue.GetStatus(ctx, "session-2"); status.Count != 0 { + t.Fatalf("expected newer non-primary session not to receive first auto-fix, got %+v", status) + } + status := svc.messageQueue.GetStatus(ctx, "session-1") + if status.Count != 1 || !strings.Contains(status.Entries[0].Content, "unit") { + t.Fatalf("expected primary session to receive first auto-fix, got %+v", status) + } + if len(ghSvc.fixAttempts) != 1 || ghSvc.fixAttempts[0].SessionID != "session-1" { + t.Fatalf("expected first fix attempt to use primary session, got %+v", ghSvc.fixAttempts) + } +} + +func TestHandleTaskPRCIAutomationKeepsAutoFixOnPreviousSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-2", + TaskID: "task-1", + State: models.TaskSessionStateRunning, + StartedAt: now.Add(time.Minute), + UpdatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("create newer session: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + LastSyncedAt: &now, + } + previousCheckpoint := ciAutomationCheckpoint{ + FailedChecks: []ciAutomationCheckSnapshot{ + {Name: "unit", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + }, + } + previousJSON, previousSignature := encodeCIAutomationCheckpoint(previousCheckpoint) + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{ + {Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + {Name: "lint", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/lint"}, + }, + }, + ciPRState: &github.TaskCIPRAutomationState{ + TaskID: "task-1", + RepositoryID: "repo-1", + PRNumber: 42, + LastFixSignature: previousSignature, + LastFixCheckpointJSON: previousJSON, + LastFixEnqueuedAt: &now, + LastFixSessionID: ptrString("session-1"), + AutoFixRoundCount: 1, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if status := svc.messageQueue.GetStatus(ctx, "session-2"); status.Count != 0 { + t.Fatalf("expected newer session not to receive auto-fix, got %+v", status) + } + status := svc.messageQueue.GetStatus(ctx, "session-1") + if status.Count != 1 || !strings.Contains(status.Entries[0].Content, "lint") { + t.Fatalf("expected previous auto-fix session to receive latest feedback, got %+v", status) + } + if len(ghSvc.fixAttempts) != 1 || ghSvc.fixAttempts[0].SessionID != "session-1" { + t.Fatalf("expected fix attempt to stay on session-1, got %+v", ghSvc.fixAttempts) + } +} + +func TestHandleTaskPRCIAutomationFallsBackWhenPreviousSessionInactive(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateCompleted) + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-2", + TaskID: "task-1", + State: models.TaskSessionStateRunning, + StartedAt: now.Add(time.Minute), + UpdatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("create active fallback session: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + LastSyncedAt: &now, + } + previousCheckpoint := ciAutomationCheckpoint{ + FailedChecks: []ciAutomationCheckSnapshot{ + {Name: "unit", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + }, + } + previousJSON, previousSignature := encodeCIAutomationCheckpoint(previousCheckpoint) + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{ + {Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + {Name: "lint", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/lint"}, + }, + }, + ciPRState: &github.TaskCIPRAutomationState{ + TaskID: "task-1", + RepositoryID: "repo-1", + PRNumber: 42, + LastFixSignature: previousSignature, + LastFixCheckpointJSON: previousJSON, + LastFixSessionID: ptrString("session-1"), + AutoFixRoundCount: 1, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + if status := svc.messageQueue.GetStatus(ctx, "session-1"); status.Count != 0 { + t.Fatalf("expected inactive previous session not to receive auto-fix, got %+v", status) + } + status := svc.messageQueue.GetStatus(ctx, "session-2") + if status.Count != 1 || !strings.Contains(status.Entries[0].Content, "lint") { + t.Fatalf("expected active fallback session to receive auto-fix, got %+v", status) + } + if len(ghSvc.fixAttempts) != 1 || ghSvc.fixAttempts[0].SessionID != "session-2" { + t.Fatalf("expected fix attempt to use fallback session-2, got %+v", ghSvc.fixAttempts) + } +} + +func TestHandleTaskPRCIAutomationFallsBackWhenPreviousSessionMissing(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-2", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + pr := &github.TaskPR{ + TaskID: "task-1", + RepositoryID: "repo-1", + Owner: "acme", + Repo: "widget", + PRNumber: 42, + State: "open", + } + previousCheckpoint := ciAutomationCheckpoint{ + FailedChecks: []ciAutomationCheckSnapshot{ + {Name: "unit", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + }, + } + previousJSON, previousSignature := encodeCIAutomationCheckpoint(previousCheckpoint) + ghSvc := &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", + AutoFixEnabled: true, + EffectiveAutoFixPrompt: "Fix the PR\n\n{{pr.feedback}}", + }, + triggerPRSyncAllPRs: []*github.TaskPR{pr}, + prFeedback: &github.PRFeedback{ + Checks: []github.CheckRun{ + {Name: "unit", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/unit"}, + {Name: "lint", Status: "completed", Conclusion: "failure", HTMLURL: "https://ci/lint"}, + }, + }, + ciPRState: &github.TaskCIPRAutomationState{ + TaskID: "task-1", + RepositoryID: "repo-1", + PRNumber: 42, + LastFixSignature: previousSignature, + LastFixCheckpointJSON: previousJSON, + LastFixSessionID: ptrString("missing-session"), + AutoFixRoundCount: 1, + }, + } + svc.SetGitHubService(ghSvc) + + if err := svc.handleTaskPRCIAutomation(ctx, pr); err != nil { + t.Fatalf("handle auto-fix: %v", err) + } + status := svc.messageQueue.GetStatus(ctx, "session-2") + if status.Count != 1 || !strings.Contains(status.Entries[0].Content, "lint") { + t.Fatalf("expected active fallback session to receive auto-fix, got %+v", status) + } + if len(ghSvc.fixAttempts) != 1 || ghSvc.fixAttempts[0].SessionID != "session-2" { + t.Fatalf("expected fix attempt to use fallback session-2, got %+v", ghSvc.fixAttempts) + } +} + func TestHandleTaskPRCIAutomationStopsBeforeEleventhAutoFixRound(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -1222,6 +1623,30 @@ func TestDispatchCIAutomationPromptForPRQueuesWhenRunningUserMessageCannotBeReco } } +func TestDispatchCIAutomationPromptForPRQueuesCreatedSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateCreated) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + session, err := repo.GetTaskSession(ctx, "session-1") + if err != nil { + t.Fatalf("load session: %v", err) + } + pr := &github.TaskPR{TaskID: "task-1", RepositoryID: "repo-1", Owner: "acme", Repo: "widget", PRNumber: 42} + + result, err := svc.dispatchCIAutomationPromptForPR(ctx, session, pr, "Fix the PR", "signature", true) + if err != nil { + t.Fatalf("expected queued CI automation prompt, got %v", err) + } + if !result.consumesRound() { + t.Fatalf("expected created session queue insert to consume a round, got %+v", result) + } + status := svc.messageQueue.GetStatus(ctx, "session-1") + if status.Count != 1 || !strings.Contains(status.Entries[0].Content, "Fix the PR") { + t.Fatalf("expected created session to receive queued prompt, got %+v", status) + } +} + func TestDispatchCIAutomationPromptForPRRecordsUserMessageBeforeDirectPrompt(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 9575260c82..1f8e8335d8 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -152,6 +152,7 @@ type sessionExecutorStore interface { // Session GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) + ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) SetSessionPrimary(ctx context.Context, sessionID string) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error diff --git a/apps/web/components/github/pr-ci-automation-controls.tsx b/apps/web/components/github/pr-ci-automation-controls.tsx index f9d420df32..c0c0f30dec 100644 --- a/apps/web/components/github/pr-ci-automation-controls.tsx +++ b/apps/web/components/github/pr-ci-automation-controls.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { IconEdit, IconInfoCircle, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { @@ -12,11 +12,13 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { Label } from "@kandev/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Switch } from "@kandev/ui/switch"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useToast } from "@/components/toast-provider"; import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-options"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { autoFixRoundForState, findCIAutomationStateForPR } from "@/lib/github/ci-automation"; import type { TaskCIAutomationPatch, TaskCIPRAutomationState, TaskPR } from "@/lib/types/github"; @@ -158,18 +160,23 @@ function CIAutomationRow({ checked, disabled, onCheckedChange, + help, }: { id: string; label: string; checked: boolean; disabled: boolean; onCheckedChange: (checked: boolean) => void; + help?: ReactNode; }) { return (