fix: improve ci auto-fix delivery (#1560)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-7 items-center justify-between gap-3 px-1">
|
||||
<Label htmlFor={id} className="min-w-0 flex-1 cursor-pointer truncate text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Label htmlFor={id} className="min-w-0 cursor-pointer truncate text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
{help}
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
aria-label={label}
|
||||
@@ -208,7 +215,7 @@ function CIAutomationErrorRow({
|
||||
);
|
||||
}
|
||||
|
||||
function CIAutoFixRoundExplanation({
|
||||
function CIAutoFixRoundHelpButton({
|
||||
state,
|
||||
maxRounds,
|
||||
}: {
|
||||
@@ -216,17 +223,53 @@ function CIAutoFixRoundExplanation({
|
||||
maxRounds: number | null | undefined;
|
||||
}) {
|
||||
const round = autoFixRoundForState(state, maxRounds);
|
||||
return (
|
||||
<div
|
||||
data-testid="ci-auto-fix-round-explanation"
|
||||
className="rounded-md border border-border/70 bg-muted/30 px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground"
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid="ci-auto-fix-round-help"
|
||||
className="h-5 w-5 cursor-help text-muted-foreground hover:text-foreground"
|
||||
aria-label="Explain auto-fix rounds"
|
||||
>
|
||||
<IconInfoCircle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
);
|
||||
const explanation = (
|
||||
<span data-testid="ci-auto-fix-round-explanation">
|
||||
Auto-fix has used {round.current} of {round.max} rounds for this PR. A round is counted when
|
||||
Kandev sends or queues a CI auto-fix message. Updating an already queued auto-fix message does
|
||||
not use another round. When this is at {round.max}/{round.max} and there is no pending
|
||||
auto-fix message left to update, Kandev pauses auto-fix for this PR so it cannot loop forever.
|
||||
Disable and re-enable auto-fix after manual review to start over.
|
||||
</div>
|
||||
Kandev sends or queues a CI auto-fix message. Kandev waits for all PR checks to finish before
|
||||
starting a new CI auto-fix turn, so the agent gets the final failed checks and current
|
||||
comments together. Updating an already queued auto-fix message does not use another round.
|
||||
When this is at {round.max}/{round.max} and there is no pending auto-fix message left to
|
||||
update, Kandev pauses auto-fix for this PR so it cannot loop forever. Disable and re-enable
|
||||
auto-fix after manual review to start over.
|
||||
</span>
|
||||
);
|
||||
if (!isFinePointer) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
portal={false}
|
||||
className="max-w-[280px] text-xs leading-relaxed"
|
||||
>
|
||||
{explanation}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-[280px] text-xs leading-relaxed">
|
||||
{explanation}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,13 +347,15 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
checked={Boolean(options?.auto_fix_enabled)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
|
||||
help={
|
||||
options?.auto_fix_enabled ? (
|
||||
<CIAutoFixRoundHelpButton
|
||||
state={automationState}
|
||||
maxRounds={options.auto_fix_max_rounds}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{options?.auto_fix_enabled && (
|
||||
<CIAutoFixRoundExplanation
|
||||
state={automationState}
|
||||
maxRounds={options.auto_fix_max_rounds}
|
||||
/>
|
||||
)}
|
||||
<CIAutomationRow
|
||||
id={`task-ci-auto-merge-${pr.task_id}`}
|
||||
label="Auto-merge when ready"
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
|
||||
|
||||
const AUTO_FIX_BADGE_TESTID = "pr-status-auto-fix-chip";
|
||||
const CHIP_TESTID = "pr-status-chip";
|
||||
const ROUND_HELP_TESTID = "ci-auto-fix-round-help";
|
||||
const ROUND_EXPLANATION_TESTID = "ci-auto-fix-round-explanation";
|
||||
|
||||
const testConstants = vi.hoisted(() => ({
|
||||
@@ -187,12 +188,21 @@ describe("PRStatusChip auto-fix round display", () => {
|
||||
expect(screen.getByTestId(AUTO_FIX_BADGE_TESTID).textContent).toBe("Auto-fix 3/12");
|
||||
});
|
||||
|
||||
it("explains the auto-fix round count from the hover popover", async () => {
|
||||
it("explains the auto-fix round count only after hovering the round help icon", async () => {
|
||||
renderWithStore(stateWithAutoFix(1), <PRStatusChip taskId="task-1" />);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId(CHIP_TESTID));
|
||||
const explanation = await screen.findByTestId(ROUND_EXPLANATION_TESTID);
|
||||
expect(screen.queryByTestId(ROUND_EXPLANATION_TESTID)).toBeNull();
|
||||
|
||||
const roundHelp = await screen.findByTestId(ROUND_HELP_TESTID);
|
||||
fireEvent.pointerMove(roundHelp, { pointerType: "mouse" });
|
||||
fireEvent.pointerEnter(roundHelp, { pointerType: "mouse" });
|
||||
fireEvent.mouseMove(roundHelp);
|
||||
const [explanation] = await screen.findAllByTestId(ROUND_EXPLANATION_TESTID);
|
||||
expect(explanation.textContent).toContain("Auto-fix has used 1 of 10 rounds");
|
||||
expect(explanation.textContent).toContain(
|
||||
"Kandev waits for all PR checks to finish before starting a new CI auto-fix turn",
|
||||
);
|
||||
expect(explanation.textContent).toContain(
|
||||
"Updating an already queued auto-fix message does not use another round",
|
||||
);
|
||||
@@ -201,7 +211,7 @@ describe("PRStatusChip auto-fix round display", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the auto-fix round explanation in the mobile drawer", () => {
|
||||
it("opens the auto-fix round explanation from the mobile drawer help icon", async () => {
|
||||
responsiveMock.breakpoint = "mobile";
|
||||
responsiveMock.isFinePointer = false;
|
||||
renderWithStore(stateWithAutoFix(2), <PRStatusChip taskId="task-1" />);
|
||||
@@ -210,7 +220,13 @@ describe("PRStatusChip auto-fix round display", () => {
|
||||
fireEvent.click(screen.getByTestId(CHIP_TESTID));
|
||||
});
|
||||
|
||||
const explanation = screen.getByTestId(ROUND_EXPLANATION_TESTID);
|
||||
expect(screen.queryByTestId(ROUND_EXPLANATION_TESTID)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId(ROUND_HELP_TESTID));
|
||||
const explanation = await screen.findByTestId(ROUND_EXPLANATION_TESTID);
|
||||
expect(explanation.textContent).toContain("Auto-fix has used 2 of 10 rounds");
|
||||
expect(explanation.textContent).toContain(
|
||||
"Kandev waits for all PR checks to finish before starting a new CI auto-fix turn",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,14 +28,15 @@ Users can already see pull request CI/review status above the task chat input, b
|
||||
- The default `ci-auto-fix` prompt is editable from Settings > Prompts like other built-in prompts.
|
||||
- Emptying or resetting the task prompt override returns the task to the default `ci-auto-fix` prompt.
|
||||
- For tasks with multiple linked PRs, the controls are task-level and apply to every open linked PR. Dedupe, last-attempt, and error state are tracked per linked PR.
|
||||
- Kandev checks watched PRs through the existing lightweight PR watch poller, which runs once per minute. Automation wakeups sync the latest lightweight PR state before evaluating gates. When auto-fix is enabled, Kandev fetches full PR feedback so failing checks, requested changes, unresolved threads, and plain PR comments can trigger deduped prompts even when the persisted lightweight row was stale.
|
||||
- Kandev checks watched PRs through the existing lightweight PR watch poller, which runs once per minute. Automation wakeups sync the latest lightweight PR state before evaluating gates. When auto-fix is enabled, Kandev fetches full PR feedback so failing checks, requested changes, unresolved threads, and human PR conversation comments can trigger deduped prompts even when the persisted lightweight row was stale. Auto-fix waits until all PR checks have finished before sending or queueing a prompt, so the agent receives the final check set and current comments in one pass. Bot-authored PR conversation comments without failed checks or unresolved review threads are treated as non-actionable status chatter and do not send an agent prompt.
|
||||
- Saving CI automation options while `Auto-fix CI & address comments` or `Auto-merge when ready` is enabled immediately evaluates the task's current linked PRs instead of waiting for the next PR watch poll. This includes prompt edits made while automation is already enabled; unchanged feedback is still deduped by the per-PR checkpoint.
|
||||
- Every auto-fix attempt records the latest feedback snapshot it used, including non-actionable snapshots that were intentionally no-ops. Later fix rounds include only new or materially changed CI/review feedback since the last recorded round, with enough summary context for the agent to understand the PR.
|
||||
- Every auto-fix attempt records the latest actionable feedback snapshot it used. Later fix rounds include only new or materially changed CI/review feedback since the last recorded round, with enough summary context for the agent to understand the PR. If a previously recorded feedback snapshot becomes non-actionable after checks pass or review threads are cleared, Kandev can refresh the checkpoint without sending a prompt or counting another round.
|
||||
- The first auto-fix round targets the task's active primary session when one exists. Once a PR has an accepted auto-fix round, later auto-fix prompts for that task/repository/PR continue targeting the recorded `last_fix_session_id`. A newer active agent session for the same task must not steal auto-fix messages. Disabling and re-enabling auto-fix resets this binding with the rest of the per-PR auto-fix state.
|
||||
- Automation must not repeatedly prompt for the same failure/comment snapshot or repeatedly retry the same failed merge attempt on every poll.
|
||||
- When auto-fix is enabled and the task session is busy, Kandev keeps at most one pending CI auto-fix queue entry per task/repository/PR. Newer feedback replaces that pending entry instead of appending another queued `@ci-auto-fix` message.
|
||||
- Auto-fix is capped at 10 accepted rounds per task/repository/PR. A round is counted when Kandev sends a prompt directly or inserts a new queued auto-fix prompt. Replacing an already queued auto-fix prompt does not count as another round.
|
||||
- The auto-fix enabled chip above the chat input shows round progress as `Auto-fix N/10`; PRs paused by the backend after the cap is reached show `Auto-fix 10/10` with warning/paused styling.
|
||||
- Hovering the chip on desktop, or opening the same PR CI drawer on mobile, explains in plain language how many rounds have been used, what counts as a round, that queue replacement does not count again, and that Kandev pauses when 10/10 has no pending auto-fix message left to update.
|
||||
- Hovering the round-count help icon on desktop, or opening the same PR CI drawer on mobile and using the same help affordance, explains in plain language how many rounds have been used, what counts as a round, that queue replacement does not count again, and that Kandev pauses when 10/10 has no pending auto-fix message left to update.
|
||||
- Accepted round-count changes and exhausted-state changes are broadcast to open clients through the task CI options update event so the chip stays current without a reload.
|
||||
- Automation controls persist across Kandev restarts.
|
||||
|
||||
@@ -56,10 +57,10 @@ Users can already see pull request CI/review status above the task chat input, b
|
||||
- `task_id` string. References the Kandev task.
|
||||
- `repository_id` string. Identifies which linked repository/branch row produced the PR.
|
||||
- `pr_number` integer.
|
||||
- `last_fix_signature` string nullable. Deterministic hash of the latest feedback snapshot, actionable or non-actionable, that produced an auto-fix prompt.
|
||||
- `last_fix_checkpoint_json` string nullable. JSON snapshot of feedback used in the last fix round. This includes non-actionable no-op snapshots so identical bot summaries/status updates are not repeatedly sent.
|
||||
- `last_fix_signature` string, default `""`. Deterministic hash of the latest feedback snapshot that produced an auto-fix prompt, or a later prompt-free checkpoint refresh that pruned resolved feedback.
|
||||
- `last_fix_checkpoint_json` string, default `""`. JSON snapshot of feedback used in the last fix round or prompt-free checkpoint refresh.
|
||||
- `last_fix_enqueued_at` timestamp nullable.
|
||||
- `last_fix_session_id` string nullable.
|
||||
- `last_fix_session_id` string nullable. Pins later auto-fix rounds for this task/repository/PR to the same task session.
|
||||
- `auto_fix_round_count` integer, default `0`. Counts accepted auto-fix rounds for this task/repository/PR.
|
||||
- `auto_fix_exhausted_at` timestamp nullable. Set when Kandev pauses auto-fix after the 10-round cap.
|
||||
- `last_merge_signature` string nullable. Deterministic hash of the last readiness state used for a merge attempt.
|
||||
@@ -147,19 +148,21 @@ Auto-fix cycle for one task/PR:
|
||||
1. Existing PR watch poll, PR feedback event, or CI options save wakes automation.
|
||||
2. Kandev syncs the latest lightweight PR state for the task's linked PRs, including linked PR rows that do not currently have an active watch.
|
||||
3. Kandev fetches full PR feedback.
|
||||
4. Kandev compares the current feedback snapshot to `last_fix_checkpoint_json` and `last_fix_signature`.
|
||||
5. If there is no material change, the cycle ends without prompting.
|
||||
6. If there is new or materially changed feedback, Kandev renders the task override or default `ci-auto-fix` prompt and sends or queues it for the task session. The saved/shared `ci-auto-fix` instructions are hidden system context. If the rendered prompt contains `{{pr.feedback}}`, Kandev replaces it with visible PR snapshot details after `@ci-auto-fix`, before the agent output for that automation turn. If the placeholder is absent, no PR snapshot is included in the chat message.
|
||||
7. The default prompt instructs the agent to classify the new feedback before editing. If the
|
||||
4. If the latest lightweight PR state or fetched check list shows any queued, pending, or in-progress check, the cycle ends without prompting or counting a round.
|
||||
5. Kandev filters feedback down to prompt-worthy signals: failed, timed-out, cancelled, or action-required completed checks, unresolved review-thread comments, and human PR conversation comments. Bot-authored PR conversation comments without a failed check or unresolved thread are ignored before delta computation.
|
||||
6. Kandev compares the current feedback snapshot to `last_fix_checkpoint_json` and `last_fix_signature`.
|
||||
7. If there is no material change, the cycle ends without prompting.
|
||||
8. If there is new or materially changed prompt-worthy feedback, Kandev renders the task override or default `ci-auto-fix` prompt and sends or queues it for the task session. If `last_fix_session_id` is already set for this task/repository/PR, Kandev targets that same session instead of the newest active session for the task. Otherwise, Kandev targets the active primary session when one exists, falling back to the newest active session only when there is no primary active session. The saved/shared `ci-auto-fix` instructions are hidden system context. If the rendered prompt contains `{{pr.feedback}}`, Kandev replaces it with visible PR snapshot details after `@ci-auto-fix`, before the agent output for that automation turn. If the placeholder is absent, no PR snapshot is included in the chat message.
|
||||
9. The default prompt instructs the agent to classify the new feedback before editing. If the
|
||||
new feedback is only summaries, status updates, no-finding reports, duplicated or already
|
||||
addressed comments, rate-limit notices, or other non-actionable review diagnostics, the agent
|
||||
must not modify files, commit, or push; it should only report that there is nothing actionable
|
||||
to address. When the agent addresses actionable PR review comments, the default prompt instructs
|
||||
it to reply with a fix summary and resolve the addressed PR review threads so they do not keep
|
||||
the PR blocked.
|
||||
8. Once the prompt is queued or accepted by the agent runtime, Kandev records the new signature/checkpoint and attempt metadata for the latest feedback snapshot, actionable or non-actionable, so identical snapshots are not sent repeatedly while the agent is still working.
|
||||
9. If the task session is busy and a pending auto-fix entry for this task/repository/PR already exists, Kandev replaces that queued entry with the latest rendered prompt instead of appending a second queued message. The round count is unchanged.
|
||||
10. If a new prompt would require an 11th accepted auto-fix round for the same task/repository/PR, Kandev does not send or queue the prompt. It records a paused error and keeps the chip visible as `Auto-fix 10/10`. Disabling and re-enabling auto-fix resets the round count and paused state for the task's PR automation rows.
|
||||
10. Once the prompt is queued or accepted by the agent runtime, Kandev records the new signature/checkpoint and attempt metadata for the latest prompt-worthy feedback snapshot, so identical snapshots are not sent repeatedly while the agent is still working.
|
||||
11. If the task session is busy and a pending auto-fix entry for this task/repository/PR already exists, Kandev replaces that queued entry with the latest rendered prompt instead of appending a second queued message. The round count is unchanged.
|
||||
12. If a new prompt would require an 11th accepted auto-fix round for the same task/repository/PR, Kandev does not send or queue the prompt. It records a paused error and keeps the chip visible as `Auto-fix 10/10`. Disabling and re-enabling auto-fix resets the round count and paused state for the task's PR automation rows.
|
||||
|
||||
Auto-merge cycle for one task/PR:
|
||||
|
||||
@@ -178,19 +181,19 @@ Auto-merge cycle for one task/PR:
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Dependency / invariant | Behavior |
|
||||
|---|---|
|
||||
| GitHub auth is missing or invalid | Controls remain visible but saving/enabling or automation execution surfaces an error; no auto-fix prompt or merge is attempted. |
|
||||
| PR is closed or merged | Controls are disabled for that PR; no automation runs. |
|
||||
| Full PR feedback fetch fails | Auto-fix does not prompt; per-PR automation state records the error and the next materially changed lightweight status may retry. |
|
||||
| Task has no promptable session | Auto-fix records an error instead of creating a surprising new session. |
|
||||
| Task session is busy | Auto-fix queues the rendered prompt with workflow/automation metadata for later delivery; the visible `@ci-auto-fix` chat message, including PR snapshot details, is created when the queued prompt is delivered and before the agent's response for that turn. |
|
||||
| Task session is busy and a pending auto-fix already exists for that PR | Kandev replaces the pending queued prompt with the latest feedback snapshot; it does not append a second queued message or increment the round count. |
|
||||
| Same feedback snapshot repeats | Auto-fix does not send another prompt. |
|
||||
| Auto-fix reaches 10 rounds for a PR | Kandev pauses auto-fix for that task/repository/PR, records a visible error, and does not create an 11th round. Already exhausted PRs skip full feedback fetching on later watcher wakes. |
|
||||
| GitHub merge fails | Auto-merge records the error and does not retry until the readiness signature changes. |
|
||||
| Default prompt row is missing | Backend falls back to the embedded `ci-auto-fix.md` content. |
|
||||
| Kandev restarts while an automation prompt is queued | Queued message and automation options/checkpoints persist according to the existing message queue and new CI automation tables. |
|
||||
| Dependency / invariant | Behavior |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| GitHub auth is missing or invalid | Controls remain visible but saving/enabling or automation execution surfaces an error; no auto-fix prompt or merge is attempted. |
|
||||
| PR is closed or merged | Controls are disabled for that PR; no automation runs. |
|
||||
| Full PR feedback fetch fails | Auto-fix does not prompt; per-PR automation state records the error and the next materially changed lightweight status may retry. |
|
||||
| Task has no promptable session | Auto-fix records an error instead of creating a surprising new session. |
|
||||
| Task session is busy | Auto-fix queues the rendered prompt with workflow/automation metadata for later delivery; the visible `@ci-auto-fix` chat message, including PR snapshot details, is created when the queued prompt is delivered and before the agent's response for that turn. |
|
||||
| Task session is busy and a pending auto-fix already exists for that PR | Kandev replaces the pending queued prompt with the latest feedback snapshot; it does not append a second queued message or increment the round count. |
|
||||
| Same feedback snapshot repeats | Auto-fix does not send another prompt. |
|
||||
| Auto-fix reaches 10 rounds for a PR | Kandev pauses auto-fix for that task/repository/PR, records a visible error, and does not create an 11th round. Already exhausted PRs skip full feedback fetching on later watcher wakes. |
|
||||
| GitHub merge fails | Auto-merge records the error and does not retry until the readiness signature changes. |
|
||||
| Default prompt row is missing | Backend falls back to the embedded `ci-auto-fix.md` content. |
|
||||
| Kandev restarts while an automation prompt is queued | Queued message and automation options/checkpoints persist according to the existing message queue and new CI automation tables. |
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
@@ -211,13 +214,17 @@ Auto-merge cycle for one task/PR:
|
||||
- **GIVEN** a task with a custom auto-fix prompt, **WHEN** the user resets the prompt override, **THEN** the task uses the current default `ci-auto-fix` prompt.
|
||||
- **GIVEN** the default `ci-auto-fix` prompt is edited in Settings > Prompts, **WHEN** a task without an override later auto-fixes a PR, **THEN** the rendered prompt uses the edited default content.
|
||||
- **GIVEN** auto-fix is enabled and a watched PR transitions from passing to failing CI, **WHEN** the 1-minute PR watch poll observes the failure, **THEN** Kandev fetches full PR feedback and sends or queues one auto-fix prompt for that failure snapshot.
|
||||
- **GIVEN** auto-fix is enabled and a PR still has queued, pending, or in-progress checks, **WHEN** automation evaluates the PR, **THEN** Kandev does not send or queue an `@ci-auto-fix` prompt and does not count a round, even if some checks have already failed or comments are present.
|
||||
- **GIVEN** auto-fix already prompted for a failure snapshot, **WHEN** the same failure is observed again on a later poll, **THEN** no duplicate prompt is sent.
|
||||
- **GIVEN** auto-fix already prompted for a failure snapshot, **WHEN** a new failed check or new unresolved review comment appears, **THEN** Kandev sends or queues a new prompt containing the new or materially changed feedback.
|
||||
- **GIVEN** auto-fix is enabled and a PR has only pending checks plus a bot-authored PR conversation/status comment, **WHEN** automation evaluates the PR, **THEN** Kandev does not send or queue an `@ci-auto-fix` prompt and does not count a round.
|
||||
- **GIVEN** auto-fix is enabled and the task session is running, **WHEN** changed CI feedback appears multiple times for the same PR before the queue drains, **THEN** Kandev keeps one queued `@ci-auto-fix` entry for that PR and updates it with the latest feedback.
|
||||
- **GIVEN** auto-fix has used 1 of 10 rounds for a PR, **WHEN** the user views the auto-fix chip above the chat input, **THEN** the chip reads `Auto-fix 1/10` and the hover/drawer explanation states that one round out of ten has been used.
|
||||
- **GIVEN** auto-fix is enabled on a task with a primary active session and a newer non-primary active session, **WHEN** the first actionable PR feedback appears, **THEN** Kandev sends or queues the auto-fix prompt on the primary session, not the newer session.
|
||||
- **GIVEN** auto-fix has already accepted a round for one task session, **WHEN** another active session is created for the same task and new actionable PR feedback appears, **THEN** Kandev sends or queues the auto-fix prompt on the previously recorded session, not the newer session.
|
||||
- **GIVEN** auto-fix has used 1 of 10 rounds for a PR, **WHEN** the user views the auto-fix chip above the chat input and opens the round-count help affordance, **THEN** the chip reads `Auto-fix 1/10` and the hover/drawer explanation states that one round out of ten has been used.
|
||||
- **GIVEN** auto-fix has already used 10 rounds for a PR and no pending auto-fix queue entry exists, **WHEN** new actionable feedback appears, **THEN** Kandev does not send or queue another prompt and records the PR as paused at `Auto-fix 10/10`.
|
||||
- **GIVEN** auto-fix has already used 10 rounds for a PR and the 10th round is still queued, **WHEN** new actionable feedback appears, **THEN** Kandev replaces that pending queued prompt without incrementing the round count.
|
||||
- **GIVEN** auto-fix sends a prompt for a snapshot that contains only non-actionable automation summaries or status updates, **WHEN** the agent reviews that prompt, **THEN** the agent does not modify files, commit, or push and only reports that there is nothing actionable to address.
|
||||
- **GIVEN** auto-fix sends a prompt for feedback that the backend considered prompt-worthy but the agent determines is already addressed or otherwise non-actionable, **WHEN** the agent reviews that prompt, **THEN** the agent does not modify files, commit, or push and only reports that there is nothing actionable to address.
|
||||
- **GIVEN** auto-fix is enabled and the task session is running, **WHEN** new actionable PR feedback appears, **THEN** the prompt is queued and delivered after the current turn rather than interrupting the running session, and the chat history shows the `@ci-auto-fix` user message with visible PR snapshot details before the agent output for the queued turn.
|
||||
- **GIVEN** auto-merge is enabled and the PR has passing checks, required reviews, no unresolved threads, and clean mergeability, **WHEN** the PR watch poll observes the ready state, **THEN** Kandev merges the PR with the existing backend merge-method selection.
|
||||
- **GIVEN** auto-merge is enabled but the PR has requested changes, pending required review, failing checks, unresolved threads, or dirty mergeability, **WHEN** the PR watch poll observes the state, **THEN** Kandev does not merge.
|
||||
|
||||
Reference in New Issue
Block a user