From 4dbc7afc537a5adc2848d64c5f8e28614c0de522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 26 Aug 2026 10:06:41 +0000 Subject: [PATCH] feat(github): recover pull requests removed from merge queues (#3042) --- .../backend/internal/github/client_helpers.go | 21 +- apps/backend/internal/github/graphql.go | 86 ++++-- .../graphql_merge_queue_recovery_test.go | 68 +++++ .../github/graphql_merge_queue_status_test.go | 9 +- apps/backend/internal/github/mock_client.go | 54 ++++ .../internal/github/mock_controller.go | 188 +++++++++--- apps/backend/internal/github/models.go | 211 ++++++++----- .../internal/github/service_ci_automation.go | 9 + .../service_pr_merge_queue_recovery_test.go | 115 +++++++ .../service_pr_merge_queue_status_test.go | 4 +- .../internal/github/service_pr_watch.go | 120 ++++++-- apps/backend/internal/github/store.go | 258 ++++++++++++---- .../github/store_ci_automation_test.go | 45 +++ .../github/store_merge_queue_recovery_test.go | 171 +++++++++++ .../github/store_merge_queue_status_test.go | 4 +- .../orchestrator/event_handlers_github.go | 1 + .../event_handlers_github_ci_automation.go | 244 +++++++++++++-- ...ers_github_ci_merge_queue_recovery_test.go | 281 ++++++++++++++++++ .../event_handlers_github_test.go | 14 + .../github/pr-ci-automation-controls.tsx | 9 +- .../github/pr-ci-automation-rows.test.tsx | 39 +++ .../github/pr-ci-automation-rows.tsx | 176 ++++++++++- .../github/pr-ci-popover.automation.test.tsx | 73 ++++- .../components/github/pr-status-chip.test.tsx | 2 +- apps/web/e2e/helpers/api-client.ts | 43 +++ .../tests/pr/ci-automation-options.spec.ts | 204 ++++++++++++- .../pr/mobile-ci-automation-options.spec.ts | 204 ++++++++++++- .../e2e/tests/pr/mobile-pr-ci-chip.spec.ts | 4 +- apps/web/lib/github/ci-automation.test.ts | 93 ++++++ apps/web/lib/github/ci-automation.ts | 221 +++++++++++++- apps/web/lib/types/github.ts | 19 ++ apps/web/src/locales/en/github.json | 33 +- apps/web/src/locales/pseudo/github.json | 33 +- apps/web/src/locales/pt-pt/github.json | 33 +- apps/web/src/locales/zh-cn/github.json | 33 +- apps/web/src/locales/zh-hk/github.json | 33 +- apps/web/src/locales/zh-tw/github.json | 33 +- .../github-pr-merge-queue-recovery/plan.md | 165 ++++++++++ ...task-01-persist-queue-recovery-evidence.md | 91 ++++++ .../task-02-evaluate-repair-and-requeue.md | 118 ++++++++ ...-03-expose-responsive-recovery-controls.md | 136 +++++++++ ...sk-04-prove-desktop-and-mobile-recovery.md | 117 ++++++++ docs/public/integrations.md | 4 +- docs/specs/integrations/README.md | 2 + .../github-pr-merge-queue-recovery.md | 124 ++++++++ .../github-pr-merge-queue-recovery.md | 203 +++++++++++++ docs/specs/ui/README.md | 2 + .../ci-pr-merge-queue-recovery-controls.md | 78 +++++ .../ci-pr-merge-queue-recovery-controls.md | 173 +++++++++++ 49 files changed, 4097 insertions(+), 304 deletions(-) create mode 100644 apps/backend/internal/github/graphql_merge_queue_recovery_test.go create mode 100644 apps/backend/internal/github/service_pr_merge_queue_recovery_test.go create mode 100644 apps/backend/internal/github/store_merge_queue_recovery_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_github_ci_merge_queue_recovery_test.go create mode 100644 apps/web/components/github/pr-ci-automation-rows.test.tsx create mode 100644 docs/plans/github-pr-merge-queue-recovery/plan.md create mode 100644 docs/plans/github-pr-merge-queue-recovery/task-01-persist-queue-recovery-evidence.md create mode 100644 docs/plans/github-pr-merge-queue-recovery/task-02-evaluate-repair-and-requeue.md create mode 100644 docs/plans/github-pr-merge-queue-recovery/task-03-expose-responsive-recovery-controls.md create mode 100644 docs/plans/github-pr-merge-queue-recovery/task-04-prove-desktop-and-mobile-recovery.md create mode 100644 docs/specs/integrations/requirements/github-pr-merge-queue-recovery.md create mode 100644 docs/specs/integrations/system-design/github-pr-merge-queue-recovery.md create mode 100644 docs/specs/ui/requirements/ci-pr-merge-queue-recovery-controls.md create mode 100644 docs/specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index fd556ba8b3..01377f7883 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -143,10 +143,19 @@ func newPRStatus(pr *PR, reviews []PRReview, checks []CheckRun) *PRStatus { reviewState, pendingReviewCount := deriveReviewSyncState(pr, reviews) total, passing := countCheckResults(checks) return &PRStatus{ - PR: pr, - ReviewState: reviewState, - ChecksState: computeOverallCheckStatus(checks), - MergeableState: pr.MergeableState, + PR: pr, + ReviewState: reviewState, + ChecksState: computeOverallCheckStatus(checks), + MergeableState: pr.MergeableState, + MergeQueueState: pr.MergeQueueState, + MergeQueuePosition: pr.MergeQueuePosition, + MergeQueueEntryID: pr.MergeQueueEntryID, + MergeQueueEntryHeadSHA: pr.MergeQueueEntryHeadSHA, + MergeQueueEstimatedTimeToMergeSeconds: pr.MergeQueueEstimatedTimeToMergeSeconds, + MergeQueueLastRemovalID: pr.MergeQueueLastRemovalID, + MergeQueueLastRemovedAt: pr.MergeQueueLastRemovedAt, + MergeQueueLastRemovalReason: pr.MergeQueueLastRemovalReason, + MergeQueueLastRemovalBeforeSHA: pr.MergeQueueLastRemovalBeforeSHA, // ReviewCount is the number of distinct reviewers whose latest review // state is APPROVED — it's the value the popover renders as // "Approved (N)" / "Approved N / M required". Counting raw review @@ -165,7 +174,9 @@ func newPRStatus(pr *PR, reviews []PRReview, checks []CheckRun) *PRStatus { // "I didn't look" (AC-10). ClosureAttributionPopulated stays false: // neither REST caller can see the closing actor — that's GraphQL-only // (AC-15). - OutcomeFieldsPopulated: true, + OutcomeFieldsPopulated: true, + mergeQueuePopulated: pr.mergeQueuePopulated, + mergeQueueRecoveryPopulated: pr.mergeQueueRecoveryPopulated, } } diff --git a/apps/backend/internal/github/graphql.go b/apps/backend/internal/github/graphql.go index bb1230df59..3afeb62f67 100644 --- a/apps/backend/internal/github/graphql.go +++ b/apps/backend/internal/github/graphql.go @@ -175,6 +175,25 @@ type timelineClosedEventNode struct { Actor *timelineActor `json:"actor"` } +type mergeQueueRemovalEventNode struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Reason string `json:"reason"` + BeforeCommit *struct { + OID string `json:"oid"` + } `json:"beforeCommit"` +} + +type batchedMergeQueueEntry struct { + ID string `json:"id"` + State string `json:"state"` + Position *int `json:"position"` + EstimatedTimeToMerge *int `json:"estimatedTimeToMerge"` + HeadCommit *struct { + OID string `json:"oid"` + } `json:"headCommit"` +} + // batchedPRResult is the decoded shape of one aliased pullRequest block. type batchedPRResult struct { State string `json:"state"` @@ -183,19 +202,15 @@ type batchedPRResult struct { // IsDraft is a pointer: AC-12a requires distinguishing an upstream // response that omits or nulls isDraft from one that genuinely reports // false, and a plain bool can't tell the two apart after decode. - IsDraft *bool `json:"isDraft"` - Mergeable string `json:"mergeable"` - MergeStatus string `json:"mergeStateStatus"` - MergeQueueEntry *struct { - State string `json:"state"` - Position *int `json:"position"` - EstimatedTimeToMerge *int `json:"estimatedTimeToMerge"` - } `json:"mergeQueueEntry"` - HeadRefName string `json:"headRefName"` - BaseRefName string `json:"baseRefName"` - HeadRefOid string `json:"headRefOid"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` + IsDraft *bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + MergeStatus string `json:"mergeStateStatus"` + MergeQueueEntry *batchedMergeQueueEntry `json:"mergeQueueEntry"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + HeadRefOid string `json:"headRefOid"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` // ChangedFiles is a pointer for the same reason as IsDraft (AC-12a): 0 is // a legitimate observation and must stay distinguishable from absent/null. ChangedFiles *int `json:"changedFiles"` @@ -243,6 +258,9 @@ type batchedPRResult struct { TimelineItems struct { Nodes []timelineClosedEventNode `json:"nodes"` } `json:"timelineItems"` + MergeQueueRemovalEvents struct { + Nodes []mergeQueueRemovalEventNode `json:"nodes"` + } `json:"mergeQueueRemovalEvents"` } type batchedBranchPRNode struct { @@ -418,14 +436,15 @@ func prFieldsBlock() string { return `state title url isDraft mergeable mergeStateStatus ` + `headRefName baseRefName headRefOid additions deletions changedFiles ` + `author { login } mergedBy { login } autoMergeRequest { enabledAt } ` + - `mergeQueueEntry { state position estimatedTimeToMerge } ` + + `mergeQueueEntry { id state position estimatedTimeToMerge headCommit { oid } } ` + `createdAt updatedAt mergedAt closedAt ` + `reviews(last: 100) { nodes { state author { login } submittedAt } } ` + `reviewRequests(first: 0) { totalCount } ` + fmt.Sprintf(`reviewThreads(first: %d) { totalCount nodes { isResolved } pageInfo { hasNextPage endCursor } } `, graphQLReviewThreadPageSize) + `commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } ` + - `timelineItems(last: 1, itemTypes: CLOSED_EVENT) { nodes { ... on ClosedEvent { actor { login } } } }` + `timelineItems(last: 1, itemTypes: CLOSED_EVENT) { nodes { ... on ClosedEvent { actor { login } } } } ` + + `mergeQueueRemovalEvents: timelineItems(last: 1, itemTypes: REMOVED_FROM_MERGE_QUEUE_EVENT) { nodes { ... on RemovedFromMergeQueueEvent { id createdAt reason beforeCommit { oid } } } }` } // buildBatchedBranchQuery emits one aliased pullRequests(headRefName:) block @@ -496,7 +515,8 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int } } closedByLogin, closureAttributionPopulated := closedEventActor(raw.TimelineItems.Nodes) - mergeQueueState, mergeQueuePosition, mergeQueueEstimate := convertMergeQueueEntry(raw.MergeQueueEntry) + mergeQueueState, mergeQueuePosition, mergeQueueEstimate, mergeQueueEntryID, mergeQueueEntryHeadSHA := convertMergeQueueEntry(raw.MergeQueueEntry) + removalID, removalAt, removalReason, removalBeforeSHA := convertMergeQueueRemoval(raw.MergeQueueRemovalEvents.Nodes) return &PRStatus{ PR: pr, ReviewState: reviewState, @@ -512,20 +532,40 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int ClosureAttributionPopulated: closureAttributionPopulated, MergeQueueState: mergeQueueState, MergeQueuePosition: mergeQueuePosition, + MergeQueueEntryID: mergeQueueEntryID, + MergeQueueEntryHeadSHA: mergeQueueEntryHeadSHA, MergeQueueEstimatedTimeToMergeSeconds: mergeQueueEstimate, + MergeQueueLastRemovalID: removalID, + MergeQueueLastRemovedAt: removalAt, + MergeQueueLastRemovalReason: removalReason, + MergeQueueLastRemovalBeforeSHA: removalBeforeSHA, mergeQueuePopulated: true, + mergeQueueRecoveryPopulated: true, } } -func convertMergeQueueEntry(entry *struct { - State string `json:"state"` - Position *int `json:"position"` - EstimatedTimeToMerge *int `json:"estimatedTimeToMerge"` -}) (string, *int, *int) { +func convertMergeQueueEntry(entry *batchedMergeQueueEntry) (string, *int, *int, string, string) { if entry == nil { - return "", nil, nil + return "", nil, nil, "", "" } - return normalizeMergeQueueState(entry.State), positiveIntPtr(entry.Position), nonNegativeIntPtr(entry.EstimatedTimeToMerge) + headSHA := "" + if entry.HeadCommit != nil { + headSHA = entry.HeadCommit.OID + } + return normalizeMergeQueueState(entry.State), positiveIntPtr(entry.Position), nonNegativeIntPtr(entry.EstimatedTimeToMerge), entry.ID, headSHA +} + +func convertMergeQueueRemoval(nodes []mergeQueueRemovalEventNode) (string, *time.Time, string, string) { + if len(nodes) == 0 { + return "", nil, "", "" + } + node := nodes[0] + var beforeSHA string + if node.BeforeCommit != nil { + beforeSHA = node.BeforeCommit.OID + } + removedAt := node.CreatedAt + return node.ID, &removedAt, node.Reason, beforeSHA } func normalizeMergeQueueState(raw string) string { diff --git a/apps/backend/internal/github/graphql_merge_queue_recovery_test.go b/apps/backend/internal/github/graphql_merge_queue_recovery_test.go new file mode 100644 index 0000000000..5ad2dd70b2 --- /dev/null +++ b/apps/backend/internal/github/graphql_merge_queue_recovery_test.go @@ -0,0 +1,68 @@ +package github + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.1 +func TestPRFieldsBlockRequestsMergeQueueRecovery(t *testing.T) { + block := prFieldsBlock() + for _, want := range []string{ + "headRefOid", + "mergeQueueEntry { id state position estimatedTimeToMerge headCommit { oid } }", + "mergeQueueRemovalEvents: timelineItems(last: 1, itemTypes: REMOVED_FROM_MERGE_QUEUE_EVENT)", + "beforeCommit { oid }", + } { + if !strings.Contains(block, want) { + t.Errorf("prFieldsBlock() missing %q: %s", want, block) + } + } +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.1 +func TestConvertBatchedPRResultPreservesMergeQueueRecovery(t *testing.T) { + raw := &batchedPRResult{} + payload := []byte(`{ + "state":"OPEN", + "headRefOid":"head-a", + "mergeQueueEntry":{ + "id":"entry-a", + "state":"QUEUED", + "position":2, + "estimatedTimeToMerge":90, + "headCommit":{"oid":"head-a"} + }, + "mergeQueueRemovalEvents":{"nodes":[{ + "id":"removal-a", + "createdAt":"2026-08-24T18:00:00Z", + "reason":"CI checks failed", + "beforeCommit":{"oid":"before-a"} + }]} + }`) + if err := json.Unmarshal(payload, raw); err != nil { + t.Fatalf("decode GraphQL payload: %v", err) + } + + status := convertBatchedPRResult(raw, "owner", "repo", 42) + if status.PR.HeadSHA != "head-a" { + t.Fatalf("head SHA = %q, want head-a", status.PR.HeadSHA) + } + if status.MergeQueueEntryID != "entry-a" || status.MergeQueueEntryHeadSHA != "head-a" { + t.Fatalf("active queue observation = %#v, want entry-a/head-a", status) + } + if status.MergeQueueLastRemovalID != "removal-a" { + t.Fatalf("removal ID = %q, want removal-a", status.MergeQueueLastRemovalID) + } + if status.MergeQueueLastRemovedAt == nil || !status.MergeQueueLastRemovedAt.Equal(time.Date(2026, 8, 24, 18, 0, 0, 0, time.UTC)) { + t.Fatalf("removal time = %v, want 2026-08-24T18:00:00Z", status.MergeQueueLastRemovedAt) + } + if status.MergeQueueLastRemovalReason != "CI checks failed" || status.MergeQueueLastRemovalBeforeSHA != "before-a" { + t.Fatalf("removal evidence = %#v, want reason and before SHA", status) + } + if !status.mergeQueueRecoveryPopulated { + t.Fatal("merge queue recovery observation was not marked populated") + } +} diff --git a/apps/backend/internal/github/graphql_merge_queue_status_test.go b/apps/backend/internal/github/graphql_merge_queue_status_test.go index 2f7c78ced5..735ade6d94 100644 --- a/apps/backend/internal/github/graphql_merge_queue_status_test.go +++ b/apps/backend/internal/github/graphql_merge_queue_status_test.go @@ -8,7 +8,7 @@ import ( func TestPRFieldsBlockRequestsMergeQueueEntry(t *testing.T) { block := prFieldsBlock() - if !strings.Contains(block, "mergeQueueEntry { state position estimatedTimeToMerge }") { + if !strings.Contains(block, "mergeQueueEntry { id state position estimatedTimeToMerge headCommit { oid } }") { t.Fatalf("prFieldsBlock() = %s, want merge queue fields", block) } } @@ -24,7 +24,7 @@ func TestConvertBatchedPRResultPreservesMergeQueueObservation(t *testing.T) { }{ { name: "queued with metadata", - entry: `{"state":"QUEUED","position":3,"estimatedTimeToMerge":125}`, + entry: `{"id":"entry-a","state":"QUEUED","position":3,"estimatedTimeToMerge":125,"headCommit":{"oid":"head-a"}}`, wantState: "queued", wantPosition: intPtr(3), wantEstimate: intPtr(125), @@ -69,6 +69,11 @@ func TestConvertBatchedPRResultPreservesMergeQueueObservation(t *testing.T) { if !intPtrEqual(status.MergeQueueEstimatedTimeToMergeSeconds, test.wantEstimate) { t.Errorf("MergeQueueEstimatedTimeToMergeSeconds = %v, want %v", status.MergeQueueEstimatedTimeToMergeSeconds, test.wantEstimate) } + if test.name == "queued with metadata" { + if status.MergeQueueEntryID != "entry-a" || status.MergeQueueEntryHeadSHA != "head-a" { + t.Errorf("merge queue entry = %q/%q, want entry-a/head-a", status.MergeQueueEntryID, status.MergeQueueEntryHeadSHA) + } + } if status.mergeQueuePopulated != test.wantObserved { t.Errorf("mergeQueuePopulated = %v, want %v", status.mergeQueuePopulated, test.wantObserved) } diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index 3852ac249e..4590bcd7a6 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -64,6 +64,24 @@ type mergedPR struct { MergeMethod string `json:"merge_method"` } +// mockPRMergeQueueState is the provider-side queue snapshot used by the E2E +// controller. The in-memory client exposes it through GetPRStatus so the +// normal TaskPR sync and automation event flow remains under test. +type mockPRMergeQueueState struct { + HeadSHA string + State string + Position *int + EntryID string + EntryHeadSHA string + EstimatedTimeToMergeSeconds *int + LastRemovalID string + LastRemovedAt *time.Time + LastRemovalReason string + LastRemovalBeforeSHA string + QueueObserved bool + RecoveryObserved bool +} + // repoKey is a composite key for per-repo lookups by owner/repo. type repoKey struct { Owner string @@ -870,12 +888,48 @@ func (m *MockClient) SetReposUnavailable(unavailable bool) { func (m *MockClient) AddPR(pr *PR) { m.mu.Lock() defer m.mu.Unlock() + if pr.MergeQueueState != "" || pr.MergeQueuePosition != nil || + pr.MergeQueueEntryID != "" || pr.MergeQueueEntryHeadSHA != "" { + pr.mergeQueuePopulated = true + } + if pr.MergeQueueLastRemovalID != "" || pr.MergeQueueLastRemovedAt != nil || + pr.MergeQueueLastRemovalReason != "" || pr.MergeQueueLastRemovalBeforeSHA != "" { + pr.mergeQueueRecoveryPopulated = true + } m.prs[prKey{pr.RepoOwner, pr.RepoName, pr.Number}] = pr if pr.HeadBranch != "" { m.prsByBranch[branchKey{pr.RepoOwner, pr.RepoName, pr.HeadBranch}] = pr } } +// SetPRMergeQueue replaces the provider-side queue snapshot for a PR and can +// advance its head. It is intentionally separate from AddPR so E2E tests can +// drive removal and requeue transitions without replacing the whole fixture. +func (m *MockClient) SetPRMergeQueue(owner, repo string, number int, state mockPRMergeQueueState) error { + m.mu.Lock() + defer m.mu.Unlock() + pr, ok := m.prs[prKey{owner, repo, number}] + if !ok { + return fmt.Errorf("mock: PR %s/%s#%d not found", owner, repo, number) + } + if state.HeadSHA != "" { + pr.HeadSHA = state.HeadSHA + } + pr.MergeQueueState = state.State + pr.MergeQueuePosition = state.Position + pr.MergeQueueEntryID = state.EntryID + pr.MergeQueueEntryHeadSHA = state.EntryHeadSHA + pr.MergeQueueEstimatedTimeToMergeSeconds = state.EstimatedTimeToMergeSeconds + pr.MergeQueueLastRemovalID = state.LastRemovalID + pr.MergeQueueLastRemovedAt = state.LastRemovedAt + pr.MergeQueueLastRemovalReason = state.LastRemovalReason + pr.MergeQueueLastRemovalBeforeSHA = state.LastRemovalBeforeSHA + pr.mergeQueuePopulated = state.QueueObserved + pr.mergeQueueRecoveryPopulated = state.RecoveryObserved + pr.UpdatedAt = time.Now().UTC() + return nil +} + func (m *MockClient) AddIssue(issue *Issue) { m.mu.Lock() defer m.mu.Unlock() diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index a65895ae6f..eeb60d8fa2 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -52,6 +52,8 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) { api.POST("/commits", c.addPRCommits) api.PUT("/pr-commits-failures", c.setPRCommitsFailures) api.PUT("/merge-outcomes", c.setMergeOutcome) + api.PUT("/merge-queue", c.transitionMergeQueue) + api.GET("/merge-attempts", c.listMergeAttempts) api.POST("/commit-details", c.addPRCommitDetail) api.POST("/branches", c.addBranches) api.POST("/repo-files", c.addRepoFiles) @@ -536,6 +538,85 @@ func (c *MockController) setMergeOutcome(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"outcome": req.Outcome}) } +type mockMergeQueueTransitionRequest struct { + TaskID string `json:"task_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PRNumber int `json:"pr_number"` + HeadSHA string `json:"head_sha,omitempty"` + MergeQueueState string `json:"merge_queue_state"` + MergeQueuePosition *int `json:"merge_queue_position,omitempty"` + MergeQueueEntryID string `json:"merge_queue_entry_id"` + MergeQueueEntryHeadSHA string `json:"merge_queue_entry_head_sha"` + MergeQueueEstimatedTimeToMerge *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` + MergeQueueLastRemovalID string `json:"merge_queue_last_removal_id"` + MergeQueueLastRemovedAt *time.Time `json:"merge_queue_last_removed_at,omitempty"` + MergeQueueLastRemovalReason string `json:"merge_queue_last_removal_reason"` + MergeQueueLastRemovalBeforeSHA string `json:"merge_queue_last_removal_before_sha"` + Checks *[]CheckRun `json:"checks,omitempty"` +} + +// transitionMergeQueue updates the mock provider and immediately runs the +// ordinary TaskPR sync path. This keeps E2E transitions causal: the response +// is not returned until the durable row and its update event exist. +func (c *MockController) transitionMergeQueue(ctx *gin.Context) { + var req mockMergeQueueTransitionRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.TaskID == "" || + strings.TrimSpace(req.Owner) == "" || strings.TrimSpace(req.Repo) == "" || req.PRNumber <= 0 { + respondInvalidPayload(ctx) + return + } + if req.MergeQueueLastRemovalID != "" && req.MergeQueueLastRemovedAt == nil { + now := time.Now().UTC() + req.MergeQueueLastRemovedAt = &now + } + if err := c.mock.SetPRMergeQueue(req.Owner, req.Repo, req.PRNumber, mockPRMergeQueueState{ + HeadSHA: req.HeadSHA, + State: req.MergeQueueState, + Position: req.MergeQueuePosition, + EntryID: req.MergeQueueEntryID, + EntryHeadSHA: req.MergeQueueEntryHeadSHA, + EstimatedTimeToMergeSeconds: req.MergeQueueEstimatedTimeToMerge, + LastRemovalID: req.MergeQueueLastRemovalID, + LastRemovedAt: req.MergeQueueLastRemovedAt, + LastRemovalReason: req.MergeQueueLastRemovalReason, + LastRemovalBeforeSHA: req.MergeQueueLastRemovalBeforeSHA, + QueueObserved: true, + RecoveryObserved: true, + }); err != nil { + ctx.JSON(http.StatusNotFound, gin.H{errKey: err.Error()}) + return + } + pr, err := c.mock.GetPR(ctx.Request.Context(), req.Owner, req.Repo, req.PRNumber) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{errKey: err.Error()}) + return + } + if req.Checks != nil { + c.mock.ReplaceCheckRuns(req.Owner, req.Repo, pr.HeadSHA, *req.Checks) + } + if c.service != nil { + status, statusErr := c.mock.GetPRStatus(ctx.Request.Context(), req.Owner, req.Repo, req.PRNumber) + if statusErr != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: statusErr.Error()}) + return + } + if syncErr := c.service.SyncTaskPR(ctx.Request.Context(), req.TaskID, status); syncErr != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{errKey: syncErr.Error()}) + return + } + } + ctx.JSON(http.StatusOK, gin.H{ + "head_sha": pr.HeadSHA, + "merge_queue_state": req.MergeQueueState, + "merge_queue_last_removal_id": req.MergeQueueLastRemovalID, + }) +} + +func (c *MockController) listMergeAttempts(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{"attempts": c.mock.MergedPRs()}) +} + func (c *MockController) addPRCommitDetail(ctx *gin.Context) { var req struct { Owner string `json:"owner"` @@ -604,32 +685,37 @@ func (c *MockController) addRepoFiles(ctx *gin.Context) { // associateTaskPR endpoint. Pointer fields are optional — leave them nil // to skip the corresponding TaskPR column update. type associateTaskPRRequest struct { - TaskID string `json:"task_id"` - WorkspaceID string `json:"workspace_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - Owner string `json:"owner"` - Repo string `json:"repo"` - PRNumber int `json:"pr_number"` - PRURL string `json:"pr_url"` - PRTitle string `json:"pr_title"` - HeadBranch string `json:"head_branch"` - BaseBranch string `json:"base_branch"` - AuthorLogin string `json:"author_login"` - State string `json:"state"` - ReviewState string `json:"review_state"` - ChecksState string `json:"checks_state"` - MergeableState string `json:"mergeable_state"` - MergeQueueState string `json:"merge_queue_state"` - MergeQueuePosition *int `json:"merge_queue_position,omitempty"` - MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - ReviewCount *int `json:"review_count,omitempty"` - PendingReviewCount *int `json:"pending_review_count,omitempty"` - RequiredReviews *int `json:"required_reviews,omitempty"` - ChecksTotal *int `json:"checks_total,omitempty"` - ChecksPassing *int `json:"checks_passing,omitempty"` - UnresolvedReviewThreads *int `json:"unresolved_review_threads,omitempty"` + TaskID string `json:"task_id"` + WorkspaceID string `json:"workspace_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PRNumber int `json:"pr_number"` + PRURL string `json:"pr_url"` + PRTitle string `json:"pr_title"` + HeadBranch string `json:"head_branch"` + BaseBranch string `json:"base_branch"` + AuthorLogin string `json:"author_login"` + State string `json:"state"` + HeadSHA string `json:"head_sha,omitempty"` + ReviewState string `json:"review_state"` + ChecksState string `json:"checks_state"` + MergeableState string `json:"mergeable_state"` + MergeQueueState string `json:"merge_queue_state"` + MergeQueuePosition *int `json:"merge_queue_position,omitempty"` + MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` + MergeQueueLastRemovalID string `json:"merge_queue_last_removal_id,omitempty"` + MergeQueueLastRemovedAt *time.Time `json:"merge_queue_last_removed_at,omitempty"` + MergeQueueLastRemovalReason string `json:"merge_queue_last_removal_reason,omitempty"` + MergeQueueLastRemovalBeforeSHA string `json:"merge_queue_last_removal_before_sha,omitempty"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ReviewCount *int `json:"review_count,omitempty"` + PendingReviewCount *int `json:"pending_review_count,omitempty"` + RequiredReviews *int `json:"required_reviews,omitempty"` + ChecksTotal *int `json:"checks_total,omitempty"` + ChecksPassing *int `json:"checks_passing,omitempty"` + UnresolvedReviewThreads *int `json:"unresolved_review_threads,omitempty"` } // associateTaskPR directly creates (or replaces) a github_task_prs record for @@ -684,12 +770,17 @@ func buildTaskPRFromRequest(req *associateTaskPRRequest, now time.Time) *TaskPR BaseBranch: req.BaseBranch, AuthorLogin: req.AuthorLogin, State: req.State, + HeadSHA: req.HeadSHA, ReviewState: req.ReviewState, ChecksState: req.ChecksState, MergeableState: req.MergeableState, MergeQueueState: req.MergeQueueState, MergeQueuePosition: req.MergeQueuePosition, MergeQueueEstimatedTimeToMergeSeconds: req.MergeQueueEstimatedTimeToMergeSeconds, + MergeQueueLastRemovalID: req.MergeQueueLastRemovalID, + MergeQueueLastRemovedAt: req.MergeQueueLastRemovedAt, + MergeQueueLastRemovalReason: req.MergeQueueLastRemovalReason, + MergeQueueLastRemovalBeforeSHA: req.MergeQueueLastRemovalBeforeSHA, Additions: req.Additions, Deletions: req.Deletions, CreatedAt: now, @@ -724,23 +815,34 @@ func (c *MockController) ensureMockPRForRequest(ctx context.Context, req *associ if existing, _ := c.mock.GetPR(ctx, req.Owner, req.Repo, req.PRNumber); existing != nil { return } + headSHA := req.HeadSHA + if headSHA == "" { + headSHA = mockHeadSHA(req.Owner, req.Repo, req.PRNumber) + } c.mock.AddPR(&PR{ - Number: req.PRNumber, - Title: req.PRTitle, - URL: req.PRURL, - HTMLURL: req.PRURL, - State: req.State, - HeadBranch: req.HeadBranch, - HeadSHA: mockHeadSHA(req.Owner, req.Repo, req.PRNumber), - BaseBranch: req.BaseBranch, - AuthorLogin: req.AuthorLogin, - MergeableState: req.MergeableState, - RepoOwner: req.Owner, - RepoName: req.Repo, - Additions: req.Additions, - Deletions: req.Deletions, - CreatedAt: now, - UpdatedAt: now, + Number: req.PRNumber, + Title: req.PRTitle, + URL: req.PRURL, + HTMLURL: req.PRURL, + State: req.State, + HeadBranch: req.HeadBranch, + HeadSHA: headSHA, + BaseBranch: req.BaseBranch, + AuthorLogin: req.AuthorLogin, + MergeableState: req.MergeableState, + RepoOwner: req.Owner, + RepoName: req.Repo, + Additions: req.Additions, + Deletions: req.Deletions, + MergeQueueState: req.MergeQueueState, + MergeQueuePosition: req.MergeQueuePosition, + MergeQueueEstimatedTimeToMergeSeconds: req.MergeQueueEstimatedTimeToMergeSeconds, + MergeQueueLastRemovalID: req.MergeQueueLastRemovalID, + MergeQueueLastRemovedAt: req.MergeQueueLastRemovedAt, + MergeQueueLastRemovalReason: req.MergeQueueLastRemovalReason, + MergeQueueLastRemovalBeforeSHA: req.MergeQueueLastRemovalBeforeSHA, + CreatedAt: now, + UpdatedAt: now, }) } @@ -778,6 +880,8 @@ func (c *MockController) seedPRFeedback(ctx *gin.Context) { CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }) + } else if pr.HeadSHA != "" { + headSHA = pr.HeadSHA } // Replace prior seeded checks/reviews/comments so a follow-up call gives // deterministic state; helpful for tests that drive a "then a check diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index fa50773bcd..6c13a55342 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -246,40 +246,52 @@ const TaskCIAutoFixMaxRounds = 10 // PR represents a GitHub Pull Request. type PR struct { - ID int64 `json:"id"` - NodeID string `json:"node_id"` - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - State string `json:"state"` // open, closed, merged - HeadBranch string `json:"head_branch"` - HeadSHA string `json:"head_sha"` - BaseBranch string `json:"base_branch"` - AuthorLogin string `json:"author_login"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - HeadRepoID int64 `json:"head_repo_id,omitempty"` - HeadRepoNodeID string `json:"head_repo_node_id,omitempty"` - HeadRepoOwner string `json:"head_repo_owner,omitempty"` - HeadRepoName string `json:"head_repo_name,omitempty"` - HeadRepoCloneURL string `json:"head_repo_clone_url,omitempty"` - BaseRepoID int64 `json:"base_repo_id,omitempty"` - BaseRepoOwner string `json:"base_repo_owner,omitempty"` - BaseRepoName string `json:"base_repo_name,omitempty"` - BaseDefaultBranch string `json:"base_default_branch,omitempty"` - MaintainerCanModify bool `json:"maintainer_can_modify"` - Body string `json:"body"` - Draft bool `json:"draft"` - Mergeable bool `json:"mergeable"` - MergeableState string `json:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, "" - Additions int `json:"additions"` - Deletions int `json:"deletions"` - RequestedReviewers []RequestedReviewer `json:"requested_reviewers"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - MergedAt *time.Time `json:"merged_at,omitempty"` - ClosedAt *time.Time `json:"closed_at,omitempty"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // open, closed, merged + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + BaseBranch string `json:"base_branch"` + AuthorLogin string `json:"author_login"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + HeadRepoID int64 `json:"head_repo_id,omitempty"` + HeadRepoNodeID string `json:"head_repo_node_id,omitempty"` + HeadRepoOwner string `json:"head_repo_owner,omitempty"` + HeadRepoName string `json:"head_repo_name,omitempty"` + HeadRepoCloneURL string `json:"head_repo_clone_url,omitempty"` + BaseRepoID int64 `json:"base_repo_id,omitempty"` + BaseRepoOwner string `json:"base_repo_owner,omitempty"` + BaseRepoName string `json:"base_repo_name,omitempty"` + BaseDefaultBranch string `json:"base_default_branch,omitempty"` + MaintainerCanModify bool `json:"maintainer_can_modify"` + Body string `json:"body"` + Draft bool `json:"draft"` + Mergeable bool `json:"mergeable"` + MergeableState string `json:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, "" + // The mock provider uses these optional fields to reproduce GraphQL merge + // queue observations through its REST-shaped status path. Production REST + // payloads leave them empty; GraphQL remains the authoritative queue source. + MergeQueueState string `json:"merge_queue_state,omitempty"` + MergeQueuePosition *int `json:"merge_queue_position,omitempty"` + MergeQueueEntryID string `json:"merge_queue_entry_id,omitempty"` + MergeQueueEntryHeadSHA string `json:"merge_queue_entry_head_sha,omitempty"` + MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` + MergeQueueLastRemovalID string `json:"merge_queue_last_removal_id,omitempty"` + MergeQueueLastRemovedAt *time.Time `json:"merge_queue_last_removed_at,omitempty"` + MergeQueueLastRemovalReason string `json:"merge_queue_last_removal_reason,omitempty"` + MergeQueueLastRemovalBeforeSHA string `json:"merge_queue_last_removal_before_sha,omitempty"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + RequestedReviewers []RequestedReviewer `json:"requested_reviewers"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` // ChangedFiles is the number of files touched by the PR. 0 is a real // observation, distinct from "never observed" — see ChangedFilesObserved, // which is what actually carries that distinction (AC-12a). @@ -303,6 +315,10 @@ type PR struct { // from "changedFiles was absent or null" (AC-12a) — ChangedFiles alone // cannot, since both cases decode to the Go zero value. ChangedFilesObserved bool `json:"-"` + // These flags are only used by the in-memory E2E provider to distinguish an + // observed empty queue entry/event from a REST payload with no queue data. + mergeQueuePopulated bool `json:"-"` + mergeQueueRecoveryPopulated bool `json:"-"` } // RequestedReviewer represents a pending reviewer request on a PR. @@ -361,19 +377,25 @@ type PRFeedback struct { // PRStatus contains lightweight PR state used by the background poller. // Unlike PRFeedback, it skips comments to reduce API calls. type PRStatus struct { - PR *PR `json:"pr"` - ReviewState string `json:"review_state"` // "approved", "changes_requested", "pending", "" - ChecksState string `json:"checks_state"` // "success", "failure", "pending", "" - MergeableState string `json:"mergeable_state"` // "clean", "blocked", "behind", "dirty", "has_hooks", "unstable", "draft", "unknown", "" - MergeQueueState string `json:"merge_queue_state"` - MergeQueuePosition *int `json:"merge_queue_position,omitempty"` + PR *PR `json:"pr"` + ReviewState string `json:"review_state"` // "approved", "changes_requested", "pending", "" + ChecksState string `json:"checks_state"` // "success", "failure", "pending", "" + MergeableState string `json:"mergeable_state"` // "clean", "blocked", "behind", "dirty", "has_hooks", "unstable", "draft", "unknown", "" + MergeQueueState string `json:"merge_queue_state"` + MergeQueuePosition *int `json:"merge_queue_position,omitempty"` + MergeQueueEntryID string `json:"merge_queue_entry_id,omitempty"` + MergeQueueEntryHeadSHA string `json:"merge_queue_entry_head_sha,omitempty"` // A nil estimate is an observed absence when mergeQueuePopulated is true. - MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` - ReviewCount int `json:"review_count"` - PendingReviewCount int `json:"pending_review_count"` - RequiredReviews *int `json:"required_reviews,omitempty"` // nil when no branch protection rule found - ChecksTotal int `json:"checks_total"` - ChecksPassing int `json:"checks_passing"` + MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds,omitempty"` + MergeQueueLastRemovalID string `json:"merge_queue_last_removal_id,omitempty"` + MergeQueueLastRemovedAt *time.Time `json:"merge_queue_last_removed_at,omitempty"` + MergeQueueLastRemovalReason string `json:"merge_queue_last_removal_reason,omitempty"` + MergeQueueLastRemovalBeforeSHA string `json:"merge_queue_last_removal_before_sha,omitempty"` + ReviewCount int `json:"review_count"` + PendingReviewCount int `json:"pending_review_count"` + RequiredReviews *int `json:"required_reviews,omitempty"` // nil when no branch protection rule found + ChecksTotal int `json:"checks_total"` + ChecksPassing int `json:"checks_passing"` // ChecksPopulated reports whether the sync path actually computed // ChecksTotal / ChecksPassing. The batched GraphQL poller doesn't (it // only carries the rollup state), so SyncTaskPR uses this flag to @@ -410,6 +432,9 @@ type PRStatus struct { // mergeQueuePopulated distinguishes a GraphQL observation (including a // null mergeQueueEntry) from REST and gh CLI reads that have no queue data. mergeQueuePopulated bool + // mergeQueueRecoveryPopulated distinguishes a GraphQL observation of the + // latest removal event (including no event) from REST and gh CLI reads. + mergeQueueRecoveryPopulated bool } // PRSearchPage is a paginated slice of PR search results, with the total @@ -454,27 +479,34 @@ type PRWatch struct { // repository this PR belongs to (multi-repo tasks can have one PR per repo). // Empty for legacy rows persisted before multi-repo support. type TaskPR struct { - ID string `json:"id" db:"id"` - WorkspaceID string `json:"workspace_id" db:"workspace_id"` - TaskID string `json:"task_id" db:"task_id"` - RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` - Owner string `json:"owner" db:"owner"` - Repo string `json:"repo" db:"repo"` - PRNumber int `json:"pr_number" db:"pr_number"` - PRURL string `json:"pr_url" db:"pr_url"` - PRTitle string `json:"pr_title" db:"pr_title"` - HeadBranch string `json:"head_branch" db:"head_branch"` - BaseBranch string `json:"base_branch" db:"base_branch"` - AuthorLogin string `json:"author_login" db:"author_login"` - State string `json:"state" db:"state"` // open, closed, merged - ReviewState string `json:"review_state" db:"review_state"` // approved, changes_requested, pending, "" - ChecksState string `json:"checks_state" db:"checks_state"` // success, failure, pending, "" - MergeableState string `json:"mergeable_state" db:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, "" - MergeQueueState string `json:"merge_queue_state" db:"merge_queue_state"` - MergeQueuePosition *int `json:"merge_queue_position" db:"merge_queue_position"` - MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds" db:"merge_queue_estimated_time_to_merge_seconds"` - ReviewCount int `json:"review_count" db:"review_count"` - PendingReviewCount int `json:"pending_review_count" db:"pending_review_count"` + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` + Owner string `json:"owner" db:"owner"` + Repo string `json:"repo" db:"repo"` + PRNumber int `json:"pr_number" db:"pr_number"` + PRURL string `json:"pr_url" db:"pr_url"` + PRTitle string `json:"pr_title" db:"pr_title"` + HeadBranch string `json:"head_branch" db:"head_branch"` + BaseBranch string `json:"base_branch" db:"base_branch"` + AuthorLogin string `json:"author_login" db:"author_login"` + State string `json:"state" db:"state"` // open, closed, merged + ReviewState string `json:"review_state" db:"review_state"` // approved, changes_requested, pending, "" + ChecksState string `json:"checks_state" db:"checks_state"` // success, failure, pending, "" + MergeableState string `json:"mergeable_state" db:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, "" + HeadSHA string `json:"head_sha" db:"head_sha"` + MergeQueueState string `json:"merge_queue_state" db:"merge_queue_state"` + MergeQueuePosition *int `json:"merge_queue_position" db:"merge_queue_position"` + MergeQueueEntryID string `json:"merge_queue_entry_id" db:"merge_queue_entry_id"` + MergeQueueEntryHeadSHA string `json:"merge_queue_entry_head_sha" db:"merge_queue_entry_head_sha"` + MergeQueueEstimatedTimeToMergeSeconds *int `json:"merge_queue_estimated_time_to_merge_seconds" db:"merge_queue_estimated_time_to_merge_seconds"` + MergeQueueLastRemovalID string `json:"merge_queue_last_removal_id" db:"merge_queue_last_removal_id"` + MergeQueueLastRemovedAt *time.Time `json:"merge_queue_last_removed_at,omitempty" db:"merge_queue_last_removed_at"` + MergeQueueLastRemovalReason string `json:"merge_queue_last_removal_reason" db:"merge_queue_last_removal_reason"` + MergeQueueLastRemovalBeforeSHA string `json:"merge_queue_last_removal_before_sha" db:"merge_queue_last_removal_before_sha"` + ReviewCount int `json:"review_count" db:"review_count"` + PendingReviewCount int `json:"pending_review_count" db:"pending_review_count"` // RequiredReviews is the branch protection's required_approving_review_count. // Nil when no protection rule exists or the token lacks scope to read it. RequiredReviews *int `json:"required_reviews,omitempty" db:"required_reviews"` @@ -693,6 +725,9 @@ type TaskCIPRAutomationState struct { AutoFixExhaustedAt *time.Time `json:"auto_fix_exhausted_at" db:"auto_fix_exhausted_at"` LastMergeSignature string `json:"last_merge_signature" db:"last_merge_signature"` LastMergeAttemptAt *time.Time `json:"last_merge_attempt_at,omitempty" db:"last_merge_attempt_at"` + LastQueueAttemptHeadSHA string `json:"last_queue_attempt_head_sha" db:"last_queue_attempt_head_sha"` + LastQueueFixEventID string `json:"last_queue_fix_event_id" db:"last_queue_fix_event_id"` + LastQueueRemovalCause string `json:"last_queue_removal_cause" db:"last_queue_removal_cause"` ReviewRequestInitialized bool `json:"review_request_initialized" db:"review_request_initialized"` LastReviewRequested bool `json:"last_review_requested" db:"last_review_requested"` LastObservedPRState string `json:"last_observed_pr_state" db:"last_observed_pr_state"` @@ -706,23 +741,39 @@ type TaskCIPRAutomationState struct { // TaskCIFixAttempt records an auto-fix prompt attempt for a task PR. type TaskCIFixAttempt struct { - TaskID string - RepositoryID string - PRNumber int - Signature string - CheckpointJSON string - SessionID string - EnqueuedAt time.Time - IncrementRound bool + TaskID string + RepositoryID string + PRNumber int + Signature string + CheckpointJSON string + SessionID string + EnqueuedAt time.Time + IncrementRound bool + QueueRemovalEventID string + QueueRemovalCause string } // TaskCIMergeAttempt records an auto-merge attempt for a task PR. type TaskCIMergeAttempt struct { - TaskID string - RepositoryID string - PRNumber int - Signature string - AttemptedAt time.Time + TaskID string + RepositoryID string + PRNumber int + Signature string + AttemptedAt time.Time + AttemptedHeadSHA string +} + +// TaskCIMergeQueueObservation records queue membership and removal evidence +// observed by the PR status poller so automation survives process restarts. +type TaskCIMergeQueueObservation struct { + TaskID string + RepositoryID string + PRNumber int + ActiveQueueHeadSHA string + MergeSignature string + RemovalEventID string + RemovalCause string + RemovalObservedHeadSHA string } // TaskPRLifecyclePrompt records an accepted lifecycle prompt checkpoint. diff --git a/apps/backend/internal/github/service_ci_automation.go b/apps/backend/internal/github/service_ci_automation.go index dc3715c1b3..f075a9326e 100644 --- a/apps/backend/internal/github/service_ci_automation.go +++ b/apps/backend/internal/github/service_ci_automation.go @@ -240,6 +240,15 @@ func (s *Service) RecordTaskCIMergeAttempt(ctx context.Context, attempt TaskCIMe return s.store.RecordTaskCIMergeAttempt(ctx, attempt) } +// RecordTaskCIMergeQueueObservation persists active queue membership and the +// latest removal cause so automation can recover safely after a restart. +func (s *Service) RecordTaskCIMergeQueueObservation(ctx context.Context, observation TaskCIMergeQueueObservation) error { + if s.store == nil { + return errStoreUnavailable + } + return s.store.RecordTaskCIMergeQueueObservation(ctx, observation) +} + // RecordTaskCIError records a CI automation error. func (s *Service) RecordTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error { if s.store == nil { diff --git a/apps/backend/internal/github/service_pr_merge_queue_recovery_test.go b/apps/backend/internal/github/service_pr_merge_queue_recovery_test.go new file mode 100644 index 0000000000..94449b1587 --- /dev/null +++ b/apps/backend/internal/github/service_pr_merge_queue_recovery_test.go @@ -0,0 +1,115 @@ +package github + +import ( + "context" + "testing" + "time" +) + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3 +func TestAssociatePRWithTaskPersistsHeadSHAImmediately(t *testing.T) { + svc, store, _ := setupSyncTest(t) + ctx := context.Background() + pr := &PR{ + Number: 42, HTMLURL: "https://example.test/42", Title: "queued", HeadBranch: "feature", + BaseBranch: "main", State: "open", RepoOwner: "owner", RepoName: "repo", HeadSHA: "head-a", + } + + associated, err := svc.AssociatePRWithTask(ctx, "task-queue-recovery-association", "", pr) + if err != nil { + t.Fatalf("associate PR: %v", err) + } + if associated.HeadSHA != "head-a" { + t.Fatalf("associated head SHA = %q, want head-a", associated.HeadSHA) + } + + persisted := mustGetTaskPR(t, store, ctx, associated.ID) + if persisted.HeadSHA != "head-a" { + t.Fatalf("persisted head SHA = %q, want head-a", persisted.HeadSHA) + } +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.2 +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.4 +func TestSyncTaskPRPreservesRecoveryAfterQueueEntryDisappears(t *testing.T) { + svc, store, _ := setupSyncTest(t) + ctx := context.Background() + seed := &TaskPR{ + TaskID: "task-queue-recovery-sync", Owner: "owner", Repo: "repo", PRNumber: 42, + PRURL: "https://example.test/42", PRTitle: "queued", State: "open", HeadSHA: "head-a", + MergeQueueState: "queued", MergeQueueEntryID: "entry-a", MergeQueueEntryHeadSHA: "head-a", + CreatedAt: nowUTC(), + } + if err := store.CreateTaskPR(ctx, seed); err != nil { + t.Fatalf("seed TaskPR: %v", err) + } + removedAt := time.Date(2026, 8, 24, 18, 0, 0, 0, time.UTC) + if err := svc.SyncTaskPR(ctx, seed.TaskID, &PRStatus{ + PR: &PR{Number: 42, State: "open", RepoOwner: "owner", RepoName: "repo", HeadSHA: "head-a"}, + MergeQueueState: "queued", MergeQueueEntryID: "entry-a", MergeQueueEntryHeadSHA: "head-a", + MergeQueueLastRemovalID: "removal-a", MergeQueueLastRemovedAt: &removedAt, + MergeQueueLastRemovalReason: "CI checks failed", MergeQueueLastRemovalBeforeSHA: "before-a", + mergeQueuePopulated: true, mergeQueueRecoveryPopulated: true, + }); err != nil { + t.Fatalf("initial queue sync: %v", err) + } + + if err := svc.SyncTaskPR(ctx, seed.TaskID, &PRStatus{ + PR: &PR{Number: 42, State: "open", RepoOwner: "owner", RepoName: "repo", HeadSHA: "head-a"}, + mergeQueuePopulated: true, mergeQueueRecoveryPopulated: true, + }); err != nil { + t.Fatalf("queue removal sync: %v", err) + } + got := mustGetTaskPR(t, store, ctx, seed.ID) + if got.MergeQueueState != "" || got.MergeQueueEntryID != "" || got.MergeQueueEntryHeadSHA != "" { + t.Fatalf("active queue snapshot = %#v, want cleared after null entry", got) + } + if got.MergeQueueLastRemovalID != "removal-a" || got.MergeQueueLastRemovalReason != "CI checks failed" || got.MergeQueueLastRemovalBeforeSHA != "before-a" { + t.Fatalf("removal evidence was lost after queue entry disappeared: %#v", got) + } + + if err := svc.SyncTaskPR(ctx, seed.TaskID, &PRStatus{ + PR: &PR{Number: 42, State: "open", RepoOwner: "owner", RepoName: "repo"}, + }); err != nil { + t.Fatalf("queue-unaware sync: %v", err) + } + got = mustGetTaskPR(t, store, ctx, seed.ID) + if got.MergeQueueLastRemovalID != "removal-a" || got.HeadSHA != "head-a" { + t.Fatalf("queue-unaware sync clobbered recovery state: %#v", got) + } +} + +func TestResolveTaskPRMergeQueueStateRejectsOlderOrUntimestampedRemoval(t *testing.T) { + newerAt := time.Date(2026, 8, 24, 18, 0, 0, 0, time.UTC) + current := &TaskPR{ + MergeQueueLastRemovalID: "removal-new", + MergeQueueLastRemovedAt: &newerAt, + MergeQueueLastRemovalReason: "new reason", + } + + for _, test := range []struct { + name string + removalID string + removedAt *time.Time + }{ + { + name: "older event", + removalID: "removal-old", + removedAt: func() *time.Time { value := newerAt.Add(-time.Minute); return &value }(), + }, + {name: "missing timestamp", removalID: "removal-unknown"}, + } { + t.Run(test.name, func(t *testing.T) { + queue := resolveTaskPRMergeQueueState(current, &PRStatus{ + PR: &PR{State: "open"}, + MergeQueueLastRemovalID: test.removalID, + MergeQueueLastRemovedAt: test.removedAt, + MergeQueueLastRemovalReason: "old reason", + mergeQueueRecoveryPopulated: true, + }) + if queue.lastRemovalID != "removal-new" || queue.lastRemovalReason != "new reason" || !timeEqual(queue.lastRemovedAt, &newerAt) { + t.Fatalf("queue removal regressed: %+v", queue) + } + }) + } +} diff --git a/apps/backend/internal/github/service_pr_merge_queue_status_test.go b/apps/backend/internal/github/service_pr_merge_queue_status_test.go index 3b2c7f21ba..6396474bb8 100644 --- a/apps/backend/internal/github/service_pr_merge_queue_status_test.go +++ b/apps/backend/internal/github/service_pr_merge_queue_status_test.go @@ -34,7 +34,7 @@ func TestSyncTaskPRPersistsMergeQueueObservationAndPublishesChange(t *testing.T) } } -func TestSyncTaskPRPreservesQueueOnQueueUnawareRead(t *testing.T) { +func TestSyncTaskPRClearsQueueOnQueueUnawareRead(t *testing.T) { svc, store, _ := setupSyncTest(t) ctx := context.Background() position := 6 @@ -53,7 +53,7 @@ func TestSyncTaskPRPreservesQueueOnQueueUnawareRead(t *testing.T) { }); err != nil { t.Fatalf("queue-unaware SyncTaskPR: %v", err) } - assertTaskPRMergeQueueFields(t, "queue-unaware SyncTaskPR", mustGetTaskPR(t, store, ctx, taskPR.ID), "queued", &position, &estimate) + assertTaskPRMergeQueueFields(t, "queue-unaware SyncTaskPR", mustGetTaskPR(t, store, ctx, taskPR.ID), "", nil, nil) } func TestSyncTaskPRClearsQueueOnAuthoritativeExitAndTerminalState(t *testing.T) { diff --git a/apps/backend/internal/github/service_pr_watch.go b/apps/backend/internal/github/service_pr_watch.go index c2179f8bbf..0b10b2106e 100644 --- a/apps/backend/internal/github/service_pr_watch.go +++ b/apps/backend/internal/github/service_pr_watch.go @@ -371,6 +371,7 @@ func (s *Service) associatePRWithTask( PRTitle: pr.Title, HeadBranch: pr.HeadBranch, BaseBranch: pr.BaseBranch, + HeadSHA: pr.HeadSHA, AuthorLogin: pr.AuthorLogin, State: pr.State, Additions: pr.Additions, @@ -737,37 +738,84 @@ func (s *Service) findTaskPRForStatus(ctx context.Context, taskID string, pr *PR } type taskPRSyncState struct { - checksTotal, checksPassing int - unresolved, reviewCount, pendingReviewCount int - requiredReviews *int - baseBranch, mergeableState, state string - mergeQueueState string - mergeQueuePosition, mergeQueueEstimate *int - mergedAt, closedAt *time.Time - isDraft *bool - changedFiles *int - mergedByLogin, closedByLogin *string - autoMergeObservedAt *time.Time + checksTotal, checksPassing int + unresolved, reviewCount, pendingReviewCount int + requiredReviews *int + baseBranch, mergeableState, state string + mergeQueueState string + mergeQueuePosition, mergeQueueEstimate *int + mergeQueueEntryID, mergeQueueEntryHeadSHA string + mergeQueueLastRemovalID, mergeQueueLastRemovalReason string + mergeQueueLastRemovalBeforeSHA string + mergeQueueLastRemovedAt *time.Time + headSHA string + mergedAt, closedAt *time.Time + isDraft *bool + changedFiles *int + mergedByLogin, closedByLogin *string + autoMergeObservedAt *time.Time } -func resolveTaskPRMergeQueueFields(tp *TaskPR, status *PRStatus) (string, *int, *int) { - var state string - var position, estimate *int +type taskPRMergeQueueState struct { + state, entryID, entryHeadSHA string + position, estimate *int + lastRemovalID, lastRemovalReason, lastRemovalBeforeSHA string + lastRemovedAt *time.Time +} + +func resolveTaskPRMergeQueueState(tp *TaskPR, status *PRStatus) taskPRMergeQueueState { + var queue taskPRMergeQueueState if tp != nil { - state = tp.MergeQueueState - position = tp.MergeQueuePosition - estimate = tp.MergeQueueEstimatedTimeToMergeSeconds + queue.state = tp.MergeQueueState + queue.position = tp.MergeQueuePosition + queue.estimate = tp.MergeQueueEstimatedTimeToMergeSeconds + queue.entryID = tp.MergeQueueEntryID + queue.entryHeadSHA = tp.MergeQueueEntryHeadSHA + queue.lastRemovalID = tp.MergeQueueLastRemovalID + queue.lastRemovedAt = tp.MergeQueueLastRemovedAt + queue.lastRemovalReason = tp.MergeQueueLastRemovalReason + queue.lastRemovalBeforeSHA = tp.MergeQueueLastRemovalBeforeSHA } if status == nil || status.PR == nil { - return state, position, estimate + return queue } if strings.EqualFold(status.PR.State, prStateMerged) || strings.EqualFold(status.PR.State, prStateClosed) { - return "", nil, nil + queue.state, queue.position, queue.estimate = "", nil, nil + queue.entryID, queue.entryHeadSHA = "", "" + } else if status.mergeQueuePopulated { + queue.state = normalizeMergeQueueState(status.MergeQueueState) + queue.position = positiveIntPtr(status.MergeQueuePosition) + queue.estimate = nonNegativeIntPtr(status.MergeQueueEstimatedTimeToMergeSeconds) + queue.entryID = status.MergeQueueEntryID + queue.entryHeadSHA = status.MergeQueueEntryHeadSHA + } else { + // REST and gh CLI status reads do not expose merge-queue fields. They + // are still authoritative for the absence of an active entry once the + // PR itself was fetched, so do not let an old GraphQL entry identifier + // keep auto-merge blocked indefinitely. + queue.state, queue.position, queue.estimate = "", nil, nil + queue.entryID, queue.entryHeadSHA = "", "" } - if !status.mergeQueuePopulated { - return state, position, estimate + if status.mergeQueueRecoveryPopulated && shouldApplyTaskPRMergeQueueRemoval(queue, status) { + queue.lastRemovalID = status.MergeQueueLastRemovalID + queue.lastRemovedAt = status.MergeQueueLastRemovedAt + queue.lastRemovalReason = status.MergeQueueLastRemovalReason + queue.lastRemovalBeforeSHA = status.MergeQueueLastRemovalBeforeSHA } - return normalizeMergeQueueState(status.MergeQueueState), positiveIntPtr(status.MergeQueuePosition), nonNegativeIntPtr(status.MergeQueueEstimatedTimeToMergeSeconds) + return queue +} + +func shouldApplyTaskPRMergeQueueRemoval(current taskPRMergeQueueState, status *PRStatus) bool { + if status == nil || status.MergeQueueLastRemovalID == "" || current.lastRemovalID == status.MergeQueueLastRemovalID { + return false + } + if current.lastRemovalID == "" || current.lastRemovedAt == nil { + return true + } + if status.MergeQueueLastRemovedAt == nil { + return false + } + return status.MergeQueueLastRemovedAt.After(*current.lastRemovedAt) } // resolveTaskPROutcomeFields applies the populated/preserve dance for the @@ -895,16 +943,24 @@ func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status if status.PR.Draft { nextMergeableState = "draft" } - mergeQueueState, mergeQueuePosition, mergeQueueEstimate := resolveTaskPRMergeQueueFields(tp, status) + queue := resolveTaskPRMergeQueueState(tp, status) nextState, nextMergedAt, nextClosedAt := resolveTerminalMergeState(tp, status.PR) nextIsDraft, nextChangedFiles, nextMergedByLogin, nextClosedByLogin, nextAutoMergeObservedAt := resolveTaskPROutcomeFields(tp, status) + nextHeadSHA := tp.HeadSHA + if status.PR.HeadSHA != "" { + nextHeadSHA = status.PR.HeadSHA + } return taskPRSyncState{ checksTotal: nextChecksTotal, checksPassing: nextChecksPassing, unresolved: nextUnresolved, reviewCount: nextReviewCount, pendingReviewCount: nextPendingReviewCount, requiredReviews: nextRequiredReviews, baseBranch: nextBaseBranch, mergeableState: nextMergeableState, - mergeQueueState: mergeQueueState, mergeQueuePosition: mergeQueuePosition, mergeQueueEstimate: mergeQueueEstimate, - state: nextState, mergedAt: nextMergedAt, closedAt: nextClosedAt, + mergeQueueState: queue.state, mergeQueuePosition: queue.position, mergeQueueEstimate: queue.estimate, + mergeQueueEntryID: queue.entryID, mergeQueueEntryHeadSHA: queue.entryHeadSHA, + mergeQueueLastRemovalID: queue.lastRemovalID, mergeQueueLastRemovalReason: queue.lastRemovalReason, + mergeQueueLastRemovalBeforeSHA: queue.lastRemovalBeforeSHA, mergeQueueLastRemovedAt: queue.lastRemovedAt, + headSHA: nextHeadSHA, + state: nextState, mergedAt: nextMergedAt, closedAt: nextClosedAt, isDraft: nextIsDraft, changedFiles: nextChangedFiles, mergedByLogin: nextMergedByLogin, closedByLogin: nextClosedByLogin, autoMergeObservedAt: nextAutoMergeObservedAt, @@ -916,6 +972,7 @@ func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []s return nil } changed := make([]string, 0, 16) + changed = appendChangedField(changed, "head_sha", tp.HeadSHA != next.headSHA) changed = appendChangedField(changed, "state", tp.State != next.state) changed = appendChangedField(changed, "pr_title", tp.PRTitle != status.PR.Title) changed = appendChangedField(changed, "additions", tp.Additions != status.PR.Additions) @@ -925,7 +982,13 @@ func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []s changed = appendChangedField(changed, "mergeable_state", tp.MergeableState != next.mergeableState) changed = appendChangedField(changed, "merge_queue_state", tp.MergeQueueState != next.mergeQueueState) changed = appendChangedField(changed, "merge_queue_position", !intPtrEqual(tp.MergeQueuePosition, next.mergeQueuePosition)) + changed = appendChangedField(changed, "merge_queue_entry_id", tp.MergeQueueEntryID != next.mergeQueueEntryID) + changed = appendChangedField(changed, "merge_queue_entry_head_sha", tp.MergeQueueEntryHeadSHA != next.mergeQueueEntryHeadSHA) changed = appendChangedField(changed, "merge_queue_estimated_time_to_merge_seconds", !intPtrEqual(tp.MergeQueueEstimatedTimeToMergeSeconds, next.mergeQueueEstimate)) + changed = appendChangedField(changed, "merge_queue_last_removal_id", tp.MergeQueueLastRemovalID != next.mergeQueueLastRemovalID) + changed = appendChangedField(changed, "merge_queue_last_removed_at", !timeEqual(tp.MergeQueueLastRemovedAt, next.mergeQueueLastRemovedAt)) + changed = appendChangedField(changed, "merge_queue_last_removal_reason", tp.MergeQueueLastRemovalReason != next.mergeQueueLastRemovalReason) + changed = appendChangedField(changed, "merge_queue_last_removal_before_sha", tp.MergeQueueLastRemovalBeforeSHA != next.mergeQueueLastRemovalBeforeSHA) changed = appendChangedField(changed, "review_count", tp.ReviewCount != next.reviewCount) changed = appendChangedField( changed, @@ -988,6 +1051,7 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu changed := len(changedFields) > 0 tp.State = next.state + tp.HeadSHA = next.headSHA tp.PRTitle = status.PR.Title tp.Additions = status.PR.Additions tp.Deletions = status.PR.Deletions @@ -998,7 +1062,13 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu tp.MergeableState = next.mergeableState tp.MergeQueueState = next.mergeQueueState tp.MergeQueuePosition = next.mergeQueuePosition + tp.MergeQueueEntryID = next.mergeQueueEntryID + tp.MergeQueueEntryHeadSHA = next.mergeQueueEntryHeadSHA tp.MergeQueueEstimatedTimeToMergeSeconds = next.mergeQueueEstimate + tp.MergeQueueLastRemovalID = next.mergeQueueLastRemovalID + tp.MergeQueueLastRemovedAt = next.mergeQueueLastRemovedAt + tp.MergeQueueLastRemovalReason = next.mergeQueueLastRemovalReason + tp.MergeQueueLastRemovalBeforeSHA = next.mergeQueueLastRemovalBeforeSHA tp.ReviewCount = next.reviewCount tp.PendingReviewCount = next.pendingReviewCount tp.RequiredReviews = next.requiredReviews diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 3867b1fa5c..2b9eda9d09 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -117,6 +117,7 @@ const createTablesSQL = ` pr_title TEXT NOT NULL, head_branch TEXT NOT NULL, base_branch TEXT NOT NULL, + head_sha TEXT NOT NULL DEFAULT '', author_login TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'open', review_state TEXT NOT NULL DEFAULT '', @@ -124,7 +125,13 @@ const createTablesSQL = ` mergeable_state TEXT NOT NULL DEFAULT '', merge_queue_state TEXT NOT NULL DEFAULT '', merge_queue_position INTEGER, + merge_queue_entry_id TEXT NOT NULL DEFAULT '', + merge_queue_entry_head_sha TEXT NOT NULL DEFAULT '', merge_queue_estimated_time_to_merge_seconds INTEGER, + merge_queue_last_removal_id TEXT NOT NULL DEFAULT '', + merge_queue_last_removed_at DATETIME, + merge_queue_last_removal_reason TEXT NOT NULL DEFAULT '', + merge_queue_last_removal_before_sha TEXT NOT NULL DEFAULT '', review_count INTEGER DEFAULT 0, pending_review_count INTEGER DEFAULT 0, required_reviews INTEGER, @@ -411,6 +418,9 @@ const createTablesSQL = ` auto_fix_exhausted_at DATETIME, last_merge_signature TEXT NOT NULL DEFAULT '', last_merge_attempt_at DATETIME, + last_queue_attempt_head_sha TEXT NOT NULL DEFAULT '', + last_queue_fix_event_id TEXT NOT NULL DEFAULT '', + last_queue_removal_cause TEXT NOT NULL DEFAULT '', review_request_initialized BOOLEAN NOT NULL DEFAULT 0, last_review_requested BOOLEAN NOT NULL DEFAULT 0, last_observed_pr_state TEXT NOT NULL DEFAULT '', @@ -606,9 +616,16 @@ var taskPRMergeQueueColumnDDL = []struct { name string ddl string }{ + {"head_sha", "TEXT NOT NULL DEFAULT ''"}, {"merge_queue_state", "TEXT NOT NULL DEFAULT ''"}, {"merge_queue_position", "INTEGER"}, + {"merge_queue_entry_id", "TEXT NOT NULL DEFAULT ''"}, + {"merge_queue_entry_head_sha", "TEXT NOT NULL DEFAULT ''"}, {"merge_queue_estimated_time_to_merge_seconds", "INTEGER"}, + {"merge_queue_last_removal_id", "TEXT NOT NULL DEFAULT ''"}, + {"merge_queue_last_removed_at", "DATETIME"}, + {"merge_queue_last_removal_reason", "TEXT NOT NULL DEFAULT ''"}, + {"merge_queue_last_removal_before_sha", "TEXT NOT NULL DEFAULT ''"}, } func (s *Store) addTaskPRMergeQueueColumns() error { @@ -1055,6 +1072,9 @@ func (s *Store) addTaskPRAgentAutomationColumns() error { {"closed_prompt_override", "ALTER TABLE github_task_ci_options ADD COLUMN closed_prompt_override TEXT"}, }, "github_task_ci_pr_state": { + {"last_queue_attempt_head_sha", "ALTER TABLE github_task_ci_pr_state ADD COLUMN last_queue_attempt_head_sha TEXT NOT NULL DEFAULT ''"}, + {"last_queue_fix_event_id", "ALTER TABLE github_task_ci_pr_state ADD COLUMN last_queue_fix_event_id TEXT NOT NULL DEFAULT ''"}, + {"last_queue_removal_cause", "ALTER TABLE github_task_ci_pr_state ADD COLUMN last_queue_removal_cause TEXT NOT NULL DEFAULT ''"}, {"review_request_initialized", "ALTER TABLE github_task_ci_pr_state ADD COLUMN review_request_initialized BOOLEAN NOT NULL DEFAULT 0"}, {"last_review_requested", "ALTER TABLE github_task_ci_pr_state ADD COLUMN last_review_requested BOOLEAN NOT NULL DEFAULT 0"}, {"last_observed_pr_state", "ALTER TABLE github_task_ci_pr_state ADD COLUMN last_observed_pr_state TEXT NOT NULL DEFAULT ''"}, @@ -1358,6 +1378,7 @@ func (s *Store) migratePRTablesForMultiRepo() error { pr_title TEXT NOT NULL, head_branch TEXT NOT NULL, base_branch TEXT NOT NULL, + head_sha TEXT NOT NULL DEFAULT '', author_login TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'open', review_state TEXT NOT NULL DEFAULT '', @@ -1365,7 +1386,13 @@ func (s *Store) migratePRTablesForMultiRepo() error { mergeable_state TEXT NOT NULL DEFAULT '', merge_queue_state TEXT NOT NULL DEFAULT '', merge_queue_position INTEGER, + merge_queue_entry_id TEXT NOT NULL DEFAULT '', + merge_queue_entry_head_sha TEXT NOT NULL DEFAULT '', merge_queue_estimated_time_to_merge_seconds INTEGER, + merge_queue_last_removal_id TEXT NOT NULL DEFAULT '', + merge_queue_last_removed_at DATETIME, + merge_queue_last_removal_reason TEXT NOT NULL DEFAULT '', + merge_queue_last_removal_before_sha TEXT NOT NULL DEFAULT '', review_count INTEGER DEFAULT 0, pending_review_count INTEGER DEFAULT 0, required_reviews INTEGER, @@ -1395,15 +1422,19 @@ func (s *Store) migratePRTablesForMultiRepo() error { // would have aborted otherwise). `INSERT INTO github_task_prs_new ( id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, - head_branch, base_branch, author_login, state, review_state, checks_state, - mergeable_state, merge_queue_state, merge_queue_position, merge_queue_estimated_time_to_merge_seconds, + head_branch, base_branch, head_sha, author_login, state, review_state, checks_state, + mergeable_state, merge_queue_state, merge_queue_position, merge_queue_entry_id, merge_queue_entry_head_sha, + merge_queue_estimated_time_to_merge_seconds, merge_queue_last_removal_id, merge_queue_last_removed_at, + merge_queue_last_removal_reason, merge_queue_last_removal_before_sha, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, detached_at, updated_at, is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at ) SELECT id, COALESCE(workspace_id, ''), task_id, COALESCE(repository_id, ''), owner, repo, pr_number, pr_url, pr_title, - head_branch, base_branch, author_login, state, review_state, checks_state, - mergeable_state, merge_queue_state, merge_queue_position, merge_queue_estimated_time_to_merge_seconds, + head_branch, base_branch, COALESCE(head_sha, ''), author_login, state, review_state, checks_state, + mergeable_state, merge_queue_state, merge_queue_position, COALESCE(merge_queue_entry_id, ''), COALESCE(merge_queue_entry_head_sha, ''), + merge_queue_estimated_time_to_merge_seconds, COALESCE(merge_queue_last_removal_id, ''), merge_queue_last_removed_at, + COALESCE(merge_queue_last_removal_reason, ''), COALESCE(merge_queue_last_removal_before_sha, ''), review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, detached_at, updated_at, is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at @@ -1720,22 +1751,7 @@ func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { } now := time.Now().UTC() tp.UpdatedAt = now - _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, - state, review_state, checks_state, mergeable_state, merge_queue_state, merge_queue_position, merge_queue_estimated_time_to_merge_seconds, review_count, pending_review_count, required_reviews, comment_count, - unresolved_review_threads, checks_total, checks_passing, additions, deletions, - created_at, merged_at, closed_at, last_synced_at, detached_at, updated_at, - is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at) - VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - )`, - tp.ID, tp.WorkspaceID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, - tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEstimatedTimeToMergeSeconds, tp.ReviewCount, tp.PendingReviewCount, tp.RequiredReviews, tp.CommentCount, - tp.UnresolvedReviewThreads, tp.ChecksTotal, tp.ChecksPassing, tp.Additions, tp.Deletions, - tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.DetachedAt, tp.UpdatedAt, - tp.IsDraft, tp.ChangedFiles, tp.MergedByLogin, tp.ClosedByLogin, tp.AutoMergeObservedAt) - return err + return insertTaskPR(ctx, s.db, tp) } // taskPRColumns is the explicit column list for every github_task_prs read. @@ -1752,7 +1768,9 @@ const taskPRColumns = `id, workspace_id, task_id, repository_id, owner, repo, pr mergeable_state, merge_queue_state, merge_queue_position, merge_queue_estimated_time_to_merge_seconds, review_count, pending_review_count, required_reviews, comment_count, unresolved_review_threads, checks_total, checks_passing, additions, deletions, created_at, merged_at, closed_at, last_synced_at, detached_at, updated_at, - is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at` + is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at, + head_sha, merge_queue_entry_id, merge_queue_entry_head_sha, merge_queue_last_removal_id, + merge_queue_last_removed_at, merge_queue_last_removal_reason, merge_queue_last_removal_before_sha` // taskPRColumnsQualified is taskPRColumns with each column qualified by the // `gtp` alias, for queries that join github_task_prs against another table. @@ -1762,7 +1780,52 @@ const taskPRColumnsQualified = `gtp.id, gtp.workspace_id, gtp.task_id, gtp.repos gtp.pending_review_count, gtp.required_reviews, gtp.comment_count, gtp.unresolved_review_threads, gtp.checks_total, gtp.checks_passing, gtp.additions, gtp.deletions, gtp.created_at, gtp.merged_at, gtp.closed_at, gtp.last_synced_at, gtp.detached_at, gtp.updated_at, - gtp.is_draft, gtp.changed_files, gtp.merged_by_login, gtp.closed_by_login, gtp.auto_merge_observed_at` + gtp.is_draft, gtp.changed_files, gtp.merged_by_login, gtp.closed_by_login, gtp.auto_merge_observed_at, + gtp.head_sha, gtp.merge_queue_entry_id, gtp.merge_queue_entry_head_sha, gtp.merge_queue_last_removal_id, + gtp.merge_queue_last_removed_at, gtp.merge_queue_last_removal_reason, gtp.merge_queue_last_removal_before_sha` + +type taskPRWriter interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func insertTaskPR(ctx context.Context, writer taskPRWriter, tp *TaskPR) error { + values := taskPRValues(tp) + columnCount := taskPRColumnCount() + if len(values) != columnCount { + return fmt.Errorf("task PR insert arity mismatch: %d columns, %d values", columnCount, len(values)) + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", columnCount), ",") + _, err := writer.ExecContext(ctx, + `INSERT INTO github_task_prs (`+taskPRColumns+`) VALUES (`+placeholders+`)`, + values..., + ) + return err +} + +func taskPRColumnCount() int { + count := 0 + for _, column := range strings.Split(taskPRColumns, ",") { + if strings.TrimSpace(column) != "" { + count++ + } + } + return count +} + +func taskPRValues(tp *TaskPR) []any { + return []any{ + tp.ID, tp.WorkspaceID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, + tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, + tp.ChecksState, tp.MergeableState, tp.MergeQueueState, tp.MergeQueuePosition, + tp.MergeQueueEstimatedTimeToMergeSeconds, tp.ReviewCount, tp.PendingReviewCount, + tp.RequiredReviews, tp.CommentCount, tp.UnresolvedReviewThreads, tp.ChecksTotal, + tp.ChecksPassing, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, + tp.LastSyncedAt, tp.DetachedAt, tp.UpdatedAt, tp.IsDraft, tp.ChangedFiles, + tp.MergedByLogin, tp.ClosedByLogin, tp.AutoMergeObservedAt, tp.HeadSHA, + tp.MergeQueueEntryID, tp.MergeQueueEntryHeadSHA, tp.MergeQueueLastRemovalID, + tp.MergeQueueLastRemovedAt, tp.MergeQueueLastRemovalReason, tp.MergeQueueLastRemovalBeforeSHA, + } +} // GetTaskPR returns the first PR association for a task. For multi-repo tasks // the result is non-deterministic across repos — use ListTaskPRsByTask instead. @@ -1923,18 +1986,25 @@ func (s *Store) RestoreTaskPR(ctx context.Context, taskID, repositoryID string, isDraft, changedFiles, mergedByLogin, closedByLogin, autoMergeObservedAt := resolveTaskPROutcomeFields(&outgoing, status) - mergeQueueState, mergeQueuePosition, mergeQueueEstimate := resolveTaskPRMergeQueueFields(&outgoing, status) + queue := resolveTaskPRMergeQueueState(&outgoing, status) + headSHA := outgoing.HeadSHA + if pr.HeadSHA != "" { + headSHA = pr.HeadSHA + } if _, err := tx.ExecContext(ctx, `UPDATE github_task_prs SET owner = ?, repo = ?, pr_url = ?, pr_title = ?, - head_branch = ?, base_branch = ?, author_login = ?, state = ?, mergeable_state = ?, - merge_queue_state = ?, merge_queue_position = ?, merge_queue_estimated_time_to_merge_seconds = ?, + head_branch = ?, base_branch = ?, head_sha = ?, author_login = ?, state = ?, mergeable_state = ?, + merge_queue_state = ?, merge_queue_position = ?, merge_queue_entry_id = ?, merge_queue_entry_head_sha = ?, merge_queue_estimated_time_to_merge_seconds = ?, + merge_queue_last_removal_id = ?, merge_queue_last_removed_at = ?, merge_queue_last_removal_reason = ?, merge_queue_last_removal_before_sha = ?, additions = ?, deletions = ?, merged_at = ?, closed_at = ?, detached_at = NULL, updated_at = ?, is_draft = ?, changed_files = ?, merged_by_login = ?, closed_by_login = ?, auto_merge_observed_at = COALESCE(auto_merge_observed_at, ?) WHERE task_id = ? AND repository_id = ? AND pr_number = ?`, - pr.RepoOwner, pr.RepoName, pr.HTMLURL, pr.Title, pr.HeadBranch, pr.BaseBranch, pr.AuthorLogin, - pr.State, pr.MergeableState, mergeQueueState, mergeQueuePosition, mergeQueueEstimate, pr.Additions, pr.Deletions, pr.MergedAt, pr.ClosedAt, time.Now().UTC(), + pr.RepoOwner, pr.RepoName, pr.HTMLURL, pr.Title, pr.HeadBranch, pr.BaseBranch, headSHA, pr.AuthorLogin, + pr.State, pr.MergeableState, queue.state, queue.position, queue.entryID, queue.entryHeadSHA, queue.estimate, + queue.lastRemovalID, queue.lastRemovedAt, queue.lastRemovalReason, queue.lastRemovalBeforeSHA, + pr.Additions, pr.Deletions, pr.MergedAt, pr.ClosedAt, time.Now().UTC(), isDraft, changedFiles, mergedByLogin, closedByLogin, autoMergeObservedAt, taskID, repositoryID, pr.Number); err != nil { return nil, err @@ -2091,8 +2161,16 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR, status *PRStatus) if outgoing.ID != "" { queueSource = outgoing } - tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEstimatedTimeToMergeSeconds = - resolveTaskPRMergeQueueFields(queueSource, status) + queue := resolveTaskPRMergeQueueState(queueSource, status) + tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEstimatedTimeToMergeSeconds = queue.state, queue.position, queue.estimate + tp.MergeQueueEntryID, tp.MergeQueueEntryHeadSHA = queue.entryID, queue.entryHeadSHA + tp.MergeQueueLastRemovalID, tp.MergeQueueLastRemovedAt = queue.lastRemovalID, queue.lastRemovedAt + tp.MergeQueueLastRemovalReason, tp.MergeQueueLastRemovalBeforeSHA = queue.lastRemovalReason, queue.lastRemovalBeforeSHA + if status != nil && status.PR != nil && status.PR.HeadSHA != "" { + tp.HeadSHA = status.PR.HeadSHA + } else if tp.HeadSHA == "" && outgoing.ID != "" { + tp.HeadSHA = outgoing.HeadSHA + } if tp.RepositoryID != "" { if _, err := tx.ExecContext(ctx, @@ -2109,18 +2187,7 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR, status *PRStatus) return nil, err } } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, workspace_id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, - state, review_state, checks_state, mergeable_state, merge_queue_state, merge_queue_position, merge_queue_estimated_time_to_merge_seconds, review_count, pending_review_count, required_reviews, comment_count, - unresolved_review_threads, checks_total, checks_passing, additions, deletions, - created_at, merged_at, closed_at, last_synced_at, detached_at, updated_at, - is_draft, changed_files, merged_by_login, closed_by_login, auto_merge_observed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.WorkspaceID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, - tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEstimatedTimeToMergeSeconds, tp.ReviewCount, tp.PendingReviewCount, tp.RequiredReviews, tp.CommentCount, - tp.UnresolvedReviewThreads, tp.ChecksTotal, tp.ChecksPassing, tp.Additions, tp.Deletions, - tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.DetachedAt, tp.UpdatedAt, - tp.IsDraft, tp.ChangedFiles, tp.MergedByLogin, tp.ClosedByLogin, tp.AutoMergeObservedAt); err != nil { + if err := insertTaskPR(ctx, tx, tp); err != nil { return nil, err } if err := tx.Commit(); err != nil { @@ -2159,7 +2226,9 @@ func replaceTaskPROutgoingRow(ctx context.Context, tx *sqlx.Tx, tp *TaskPR) (*Ta // UpdateTaskPR updates a task-PR association. This is the sync writer's // exclusive write path. -// UpdateTaskPR writes auto_merge_observed_at through a SQL-level +// UpdateTaskPR writes queue-removal evidence in a separate guarded statement +// so a delayed observation cannot replace a newer event already committed by +// another sync. It writes auto_merge_observed_at through a SQL-level // COALESCE(auto_merge_observed_at, ?) rather than a direct SET. The Go-side // latch check in resolveTaskPROutcomeFields (tp.AutoMergeObservedAt == nil) // reads a snapshot that can be stale by the time this statement executes — @@ -2170,9 +2239,14 @@ func replaceTaskPROutgoingRow(ctx context.Context, tx *sqlx.Tx, tp *TaskPR) (*Ta // is silently discarded instead of overwriting it (AC-16/AC-17). func (s *Store) UpdateTaskPR(ctx context.Context, tp *TaskPR) error { tp.UpdatedAt = time.Now().UTC() - _, err := s.db.ExecContext(ctx, ` + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, ` UPDATE github_task_prs SET state = ?, review_state = ?, checks_state = ?, mergeable_state = ?, - merge_queue_state = ?, merge_queue_position = ?, merge_queue_estimated_time_to_merge_seconds = ?, + head_sha = ?, merge_queue_state = ?, merge_queue_position = ?, merge_queue_entry_id = ?, merge_queue_entry_head_sha = ?, merge_queue_estimated_time_to_merge_seconds = ?, review_count = ?, pending_review_count = ?, required_reviews = ?, comment_count = ?, unresolved_review_threads = ?, checks_total = ?, checks_passing = ?, additions = ?, deletions = ?, pr_title = ?, base_branch = ?, @@ -2180,14 +2254,33 @@ func (s *Store) UpdateTaskPR(ctx context.Context, tp *TaskPR) error { is_draft = ?, changed_files = ?, merged_by_login = ?, closed_by_login = ?, auto_merge_observed_at = COALESCE(auto_merge_observed_at, ?) WHERE id = ?`, - tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEstimatedTimeToMergeSeconds, + tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.HeadSHA, tp.MergeQueueState, tp.MergeQueuePosition, tp.MergeQueueEntryID, tp.MergeQueueEntryHeadSHA, tp.MergeQueueEstimatedTimeToMergeSeconds, tp.ReviewCount, tp.PendingReviewCount, tp.RequiredReviews, tp.CommentCount, tp.UnresolvedReviewThreads, tp.ChecksTotal, tp.ChecksPassing, tp.Additions, tp.Deletions, tp.PRTitle, tp.BaseBranch, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt, tp.IsDraft, tp.ChangedFiles, tp.MergedByLogin, tp.ClosedByLogin, - tp.AutoMergeObservedAt, tp.ID) - return err + tp.AutoMergeObservedAt, tp.ID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE github_task_prs SET + merge_queue_last_removal_id = ?, merge_queue_last_removed_at = ?, + merge_queue_last_removal_reason = ?, merge_queue_last_removal_before_sha = ? + WHERE id = ? + AND ? <> '' + AND merge_queue_last_removal_id <> ? + AND ( + merge_queue_last_removal_id = '' + OR merge_queue_last_removed_at IS NULL + OR julianday(?) > julianday(merge_queue_last_removed_at) + )`, + tp.MergeQueueLastRemovalID, tp.MergeQueueLastRemovedAt, tp.MergeQueueLastRemovalReason, + tp.MergeQueueLastRemovalBeforeSHA, tp.ID, tp.MergeQueueLastRemovalID, + tp.MergeQueueLastRemovalID, tp.MergeQueueLastRemovedAt); err != nil { + return err + } + return tx.Commit() } // --- Task CI automation operations --- @@ -2715,18 +2808,26 @@ func (s *Store) RecordTaskCIFixAttempt(ctx context.Context, attempt TaskCIFixAtt INSERT INTO github_task_ci_pr_state ( task_id, repository_id, pr_number, last_fix_signature, last_fix_checkpoint_json, last_fix_enqueued_at, last_fix_session_id, auto_fix_round_count, auto_fix_exhausted_at, + last_queue_fix_event_id, last_queue_removal_cause, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?) ON CONFLICT(task_id, repository_id, pr_number) DO UPDATE SET last_fix_signature = excluded.last_fix_signature, last_fix_checkpoint_json = excluded.last_fix_checkpoint_json, last_fix_enqueued_at = excluded.last_fix_enqueued_at, last_fix_session_id = excluded.last_fix_session_id, auto_fix_round_count = github_task_ci_pr_state.auto_fix_round_count + excluded.auto_fix_round_count, + last_queue_fix_event_id = CASE + WHEN excluded.last_queue_fix_event_id <> '' THEN excluded.last_queue_fix_event_id + ELSE github_task_ci_pr_state.last_queue_fix_event_id END, + last_queue_removal_cause = CASE + WHEN excluded.last_queue_removal_cause <> '' THEN excluded.last_queue_removal_cause + ELSE github_task_ci_pr_state.last_queue_removal_cause END, last_error = NULL, updated_at = excluded.updated_at`, attempt.TaskID, attempt.RepositoryID, attempt.PRNumber, attempt.Signature, - attempt.CheckpointJSON, when, nullableString(attempt.SessionID), roundCount, now, now) + attempt.CheckpointJSON, when, nullableString(attempt.SessionID), roundCount, + attempt.QueueRemovalEventID, attempt.QueueRemovalCause, now, now) return err }) } @@ -2759,14 +2860,67 @@ func (s *Store) RecordTaskCIMergeAttempt(ctx context.Context, attempt TaskCIMerg return s.mutateTaskCIPRState(ctx, attempt.TaskID, func(ctx context.Context, tx *sqlx.Tx, now time.Time) error { _, err := tx.ExecContext(ctx, ` INSERT INTO github_task_ci_pr_state ( - task_id, repository_id, pr_number, last_merge_signature, last_merge_attempt_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) + task_id, repository_id, pr_number, last_merge_signature, last_merge_attempt_at, + last_queue_attempt_head_sha, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, repository_id, pr_number) DO UPDATE SET last_merge_signature = excluded.last_merge_signature, last_merge_attempt_at = excluded.last_merge_attempt_at, + last_queue_attempt_head_sha = CASE + WHEN excluded.last_queue_attempt_head_sha <> '' THEN excluded.last_queue_attempt_head_sha + ELSE github_task_ci_pr_state.last_queue_attempt_head_sha END, last_error = NULL, updated_at = excluded.updated_at`, - attempt.TaskID, attempt.RepositoryID, attempt.PRNumber, attempt.Signature, when, now, now) + attempt.TaskID, attempt.RepositoryID, attempt.PRNumber, attempt.Signature, when, + attempt.AttemptedHeadSHA, now, now) + return err + }) +} + +// RecordTaskCIMergeQueueObservation persists an active queue attempt or a +// conservative current-head baseline when a removal is observed first. The +// baseline is written only when no queue attempt has been recorded yet, so a +// later poll cannot move the guard to a newer head and accidentally requeue a +// removal that belongs to an older attempt. +func (s *Store) RecordTaskCIMergeQueueObservation(ctx context.Context, observation TaskCIMergeQueueObservation) error { + return s.mutateTaskCIPRState(ctx, observation.TaskID, func(ctx context.Context, tx *sqlx.Tx, now time.Time) error { + baselineHead := "" + if observation.ActiveQueueHeadSHA == "" { + baselineHead = observation.RemovalObservedHeadSHA + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO github_task_ci_pr_state ( + task_id, repository_id, pr_number, last_merge_signature, + last_queue_attempt_head_sha, last_queue_removal_cause, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id, repository_id, pr_number) DO UPDATE SET + last_merge_signature = CASE + WHEN excluded.last_merge_signature <> '' THEN excluded.last_merge_signature + ELSE github_task_ci_pr_state.last_merge_signature END, + last_queue_attempt_head_sha = CASE + WHEN excluded.last_queue_attempt_head_sha <> '' + THEN excluded.last_queue_attempt_head_sha + ELSE github_task_ci_pr_state.last_queue_attempt_head_sha END, + last_queue_removal_cause = CASE + WHEN excluded.last_queue_removal_cause <> '' + THEN excluded.last_queue_removal_cause + ELSE github_task_ci_pr_state.last_queue_removal_cause END, + updated_at = excluded.updated_at`, + observation.TaskID, observation.RepositoryID, observation.PRNumber, + observation.MergeSignature, + observation.ActiveQueueHeadSHA, observation.RemovalCause, now, now) + if err != nil { + return err + } + if baselineHead == "" { + return nil + } + _, err = tx.ExecContext(ctx, ` + UPDATE github_task_ci_pr_state + SET last_queue_attempt_head_sha = ?, updated_at = ? + WHERE task_id = ? AND repository_id = ? AND pr_number = ? + AND last_queue_attempt_head_sha = ''`, + baselineHead, now, observation.TaskID, observation.RepositoryID, observation.PRNumber) return err }) } diff --git a/apps/backend/internal/github/store_ci_automation_test.go b/apps/backend/internal/github/store_ci_automation_test.go index 2243aa88f1..0abec587d9 100644 --- a/apps/backend/internal/github/store_ci_automation_test.go +++ b/apps/backend/internal/github/store_ci_automation_test.go @@ -466,6 +466,51 @@ func TestStoreTaskCIPRState_RecordAttemptsAndError(t *testing.T) { } } +func TestStoreTaskCIMergeQueueRecoveryState(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.RecordTaskCIMergeQueueObservation(ctx, TaskCIMergeQueueObservation{ + TaskID: "task-1", RepositoryID: "repo-1", PRNumber: 42, + ActiveQueueHeadSHA: "head-a", MergeSignature: "merge-a", + }); err != nil { + t.Fatalf("record active queue observation: %v", err) + } + observed, err := store.GetTaskCIPRState(ctx, "task-1", "repo-1", 42) + if err != nil { + t.Fatalf("get observed queue state: %v", err) + } + if observed == nil || observed.LastMergeAttemptAt != nil { + t.Fatalf("passive queue observation claimed a merge attempt: %+v", observed) + } + if err := store.RecordTaskCIFixAttempt(ctx, TaskCIFixAttempt{ + TaskID: "task-1", RepositoryID: "repo-1", PRNumber: 42, + QueueRemovalEventID: "removal-a", QueueRemovalCause: "checks_failed", + IncrementRound: true, + }); err != nil { + t.Fatalf("record queue recovery fix: %v", err) + } + if err := store.RecordTaskCIMergeAttempt(ctx, TaskCIMergeAttempt{ + TaskID: "task-1", RepositoryID: "repo-1", PRNumber: 42, + Signature: "merge-b", AttemptedHeadSHA: "head-b", + }); err != nil { + t.Fatalf("record queue merge attempt: %v", err) + } + + state, err := store.GetTaskCIPRState(ctx, "task-1", "repo-1", 42) + if err != nil { + t.Fatalf("get queue automation state: %v", err) + } + if state == nil { + t.Fatal("expected queue automation state") + } + if state.LastQueueAttemptHeadSHA != "head-b" || state.LastMergeSignature != "merge-b" { + t.Fatalf("queue attempt state = %+v, want head-b and merge-b", state) + } + if state.LastQueueFixEventID != "removal-a" || state.LastQueueRemovalCause != "checks_failed" || state.AutoFixRoundCount != 1 { + t.Fatalf("queue repair state = %+v, want removal checkpoint and one round", state) + } +} + func TestStoreTaskCIPRState_MarkExhaustedAndResetOnReenable(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/apps/backend/internal/github/store_merge_queue_recovery_test.go b/apps/backend/internal/github/store_merge_queue_recovery_test.go new file mode 100644 index 0000000000..b5aeb20eb4 --- /dev/null +++ b/apps/backend/internal/github/store_merge_queue_recovery_test.go @@ -0,0 +1,171 @@ +package github + +import ( + "context" + "strings" + "testing" + "time" +) + +var taskPRMergeQueueRecoveryColumnNames = []string{ + "head_sha", + "merge_queue_entry_id", + "merge_queue_entry_head_sha", + "merge_queue_last_removal_id", + "merge_queue_last_removed_at", + "merge_queue_last_removal_reason", + "merge_queue_last_removal_before_sha", +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3 +func TestTaskPRMergeQueueRecoverySchemaIsReplayable(t *testing.T) { + store := newTestStore(t) + columns, err := store.tableColumns("github_task_prs") + if err != nil { + t.Fatalf("read github_task_prs columns: %v", err) + } + for _, name := range taskPRMergeQueueRecoveryColumnNames { + if _, ok := columns[name]; !ok { + t.Errorf("github_task_prs is missing %q", name) + } + } + if err := store.initSchema(false); err != nil { + t.Fatalf("replay github schema: %v", err) + } +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.1 +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3 +func TestTaskPRMergeQueueRecoveryRoundTripsThroughWritePaths(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + removedAt := time.Date(2026, 8, 24, 18, 0, 0, 0, time.UTC) + seed := &TaskPR{ + TaskID: "task-queue-recovery", WorkspaceID: "workspace-queue-recovery", + Owner: "owner", Repo: "repo", PRNumber: 42, PRURL: "https://example.test/42", + PRTitle: "queued", HeadBranch: "feature", BaseBranch: "main", State: "open", + HeadSHA: "head-a", MergeQueueState: "queued", MergeQueueEntryID: "entry-a", + MergeQueueEntryHeadSHA: "head-a", MergeQueueLastRemovalID: "removal-a", + MergeQueueLastRemovedAt: &removedAt, MergeQueueLastRemovalReason: "CI checks failed", + MergeQueueLastRemovalBeforeSHA: "before-a", CreatedAt: removedAt, + } + if err := store.CreateTaskPR(ctx, seed); err != nil { + t.Fatalf("CreateTaskPR: %v", err) + } + assertTaskPRMergeQueueRecoveryFields(t, "CreateTaskPR", mustGetTaskPR(t, store, ctx, seed.ID), seed) + + seed.HeadSHA = "head-b" + seed.MergeQueueState = "" + seed.MergeQueueEntryID = "" + seed.MergeQueueEntryHeadSHA = "" + if err := store.UpdateTaskPR(ctx, seed); err != nil { + t.Fatalf("UpdateTaskPR: %v", err) + } + assertTaskPRMergeQueueRecoveryFields(t, "UpdateTaskPR", mustGetTaskPR(t, store, ctx, seed.ID), seed) + + replaced := &TaskPR{ + TaskID: seed.TaskID, WorkspaceID: seed.WorkspaceID, Owner: seed.Owner, Repo: seed.Repo, + PRNumber: seed.PRNumber, PRURL: seed.PRURL, PRTitle: "replaced", HeadBranch: seed.HeadBranch, + BaseBranch: seed.BaseBranch, State: "open", HeadSHA: "head-c", + CreatedAt: removedAt, + } + newRemovedAt := removedAt.Add(time.Minute) + if _, err := store.ReplaceTaskPR(ctx, replaced, &PRStatus{ + PR: &PR{Number: seed.PRNumber, State: "open", RepoOwner: seed.Owner, RepoName: seed.Repo, HeadSHA: "head-c"}, + MergeQueueLastRemovalID: "removal-b", MergeQueueLastRemovedAt: &newRemovedAt, + MergeQueueLastRemovalReason: "merge conflict", MergeQueueLastRemovalBeforeSHA: "before-b", + mergeQueueRecoveryPopulated: true, + }); err != nil { + t.Fatalf("ReplaceTaskPR: %v", err) + } + wantReplacement := *replaced + wantReplacement.MergeQueueLastRemovalID = "removal-b" + wantReplacement.MergeQueueLastRemovedAt = &newRemovedAt + wantReplacement.MergeQueueLastRemovalReason = "merge conflict" + wantReplacement.MergeQueueLastRemovalBeforeSHA = "before-b" + assertTaskPRMergeQueueRecoveryFields(t, "ReplaceTaskPR", mustGetTaskPR(t, store, ctx, replaced.ID), &wantReplacement) + + if _, changed, err := store.DetachTaskPR(ctx, replaced.ID); err != nil || !changed { + t.Fatalf("DetachTaskPR: changed=%v err=%v", changed, err) + } + restored, err := store.RestoreTaskPR(ctx, replaced.TaskID, replaced.RepositoryID, &PRStatus{ + PR: &PR{Number: replaced.PRNumber, RepoOwner: replaced.Owner, RepoName: replaced.Repo, HTMLURL: replaced.PRURL, Title: replaced.PRTitle, State: "open"}, + }) + if err != nil { + t.Fatalf("RestoreTaskPR: %v", err) + } + assertTaskPRMergeQueueRecoveryFields(t, "RestoreTaskPR", restored, &wantReplacement) +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3 +func TestTaskPRColumnListsIncludeMergeQueueRecoveryColumns(t *testing.T) { + for _, name := range taskPRMergeQueueRecoveryColumnNames { + if !strings.Contains(taskPRColumns, name) { + t.Errorf("taskPRColumns missing %q", name) + } + if !strings.Contains(taskPRColumnsQualified, "gtp."+name) { + t.Errorf("taskPRColumnsQualified missing %q", name) + } + } +} + +func TestTaskPRInsertArityMatchesColumnList(t *testing.T) { + if got, want := taskPRColumnCount(), len(taskPRValues(&TaskPR{})); got != want { + t.Fatalf("TaskPR insert arity = %d columns and %d values, want equal", got, want) + } +} + +// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.4 +func TestUpdateTaskPRPreservesNewerQueueRemovalObservation(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + base := &TaskPR{ + TaskID: "task-queue-recovery-order", Owner: "owner", Repo: "repo", PRNumber: 42, + PRURL: "https://example.test/42", State: "open", HeadSHA: "head-a", CreatedAt: nowUTC(), + } + if err := store.CreateTaskPR(ctx, base); err != nil { + t.Fatalf("seed TaskPR: %v", err) + } + + newerAt := time.Date(2026, 8, 24, 18, 0, 0, 0, time.UTC) + olderAt := time.Date(2026, 8, 24, 19, 0, 0, 0, time.FixedZone("WEST", 2*60*60)) + older := *base + older.MergeQueueLastRemovalID = "removal-old" + older.MergeQueueLastRemovedAt = &olderAt + older.MergeQueueLastRemovalReason = "old reason" + older.MergeQueueLastRemovalBeforeSHA = "before-old" + newer := older + newer.MergeQueueLastRemovalID = "removal-new" + newer.MergeQueueLastRemovedAt = &newerAt + newer.MergeQueueLastRemovalReason = "new reason" + newer.MergeQueueLastRemovalBeforeSHA = "before-new" + + if err := store.UpdateTaskPR(ctx, &newer); err != nil { + t.Fatalf("write newer removal observation: %v", err) + } + if err := store.UpdateTaskPR(ctx, &older); err != nil { + t.Fatalf("write delayed older removal observation: %v", err) + } + + got := mustGetTaskPR(t, store, ctx, base.ID) + if got.MergeQueueLastRemovalID != "removal-new" || got.MergeQueueLastRemovalReason != "new reason" || + got.MergeQueueLastRemovalBeforeSHA != "before-new" || !timeEqual(got.MergeQueueLastRemovedAt, &newerAt) { + t.Fatalf("delayed observation regressed removal evidence: %#v", got) + } +} + +func assertTaskPRMergeQueueRecoveryFields(t *testing.T, name string, got, want *TaskPR) { + t.Helper() + if got == nil { + t.Fatalf("%s: got nil TaskPR", name) + } + if got.HeadSHA != want.HeadSHA || got.MergeQueueEntryID != want.MergeQueueEntryID || got.MergeQueueEntryHeadSHA != want.MergeQueueEntryHeadSHA { + t.Errorf("%s: active snapshot = %#v, want head=%q entry=%q entry_head=%q", name, got, want.HeadSHA, want.MergeQueueEntryID, want.MergeQueueEntryHeadSHA) + } + if got.MergeQueueLastRemovalID != want.MergeQueueLastRemovalID || got.MergeQueueLastRemovalReason != want.MergeQueueLastRemovalReason || got.MergeQueueLastRemovalBeforeSHA != want.MergeQueueLastRemovalBeforeSHA { + t.Errorf("%s: removal snapshot = %#v, want id=%q reason=%q before=%q", name, got, want.MergeQueueLastRemovalID, want.MergeQueueLastRemovalReason, want.MergeQueueLastRemovalBeforeSHA) + } + if !timeEqual(got.MergeQueueLastRemovedAt, want.MergeQueueLastRemovedAt) { + t.Errorf("%s: removal time = %v, want %v", name, got.MergeQueueLastRemovedAt, want.MergeQueueLastRemovedAt) + } +} diff --git a/apps/backend/internal/github/store_merge_queue_status_test.go b/apps/backend/internal/github/store_merge_queue_status_test.go index 8a74a45908..091fa2100b 100644 --- a/apps/backend/internal/github/store_merge_queue_status_test.go +++ b/apps/backend/internal/github/store_merge_queue_status_test.go @@ -86,7 +86,7 @@ func TestTaskPRColumnListsIncludeMergeQueueColumns(t *testing.T) { } } -func TestRestoreTaskPRPreservesMergeQueueFields(t *testing.T) { +func TestRestoreTaskPRClearsQueueFieldsFromQueueUnawareRead(t *testing.T) { store := newTestStore(t) ctx := context.Background() position := 3 @@ -111,7 +111,7 @@ func TestRestoreTaskPRPreservesMergeQueueFields(t *testing.T) { if err != nil { t.Fatalf("RestoreTaskPR: %v", err) } - assertTaskPRMergeQueueFields(t, "RestoreTaskPR", restored, seed.MergeQueueState, &position, &estimate) + assertTaskPRMergeQueueFields(t, "RestoreTaskPR", restored, "", nil, nil) } func mustGetTaskPR(t *testing.T, store *Store, ctx context.Context, id string) *TaskPR { diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index e6ef29b1fb..7eec5aaff3 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -61,6 +61,7 @@ type GitHubService interface { RecordTaskCIFixAttempt(ctx context.Context, attempt github.TaskCIFixAttempt) error RefreshTaskCIFixCheckpoint(ctx context.Context, taskID, repositoryID string, prNumber int, signature, checkpointJSON string) error RecordTaskCIMergeAttempt(ctx context.Context, attempt github.TaskCIMergeAttempt) error + RecordTaskCIMergeQueueObservation(ctx context.Context, observation github.TaskCIMergeQueueObservation) error RecordTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error MarkTaskCIAutoFixExhausted(ctx context.Context, taskID, repositoryID string, prNumber int, message string) error ClearTaskCIError(ctx context.Context, taskID, repositoryID string, prNumber int) error 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 a7a719594b..d75d534ec6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go @@ -38,8 +38,9 @@ const ( var ciAutomationSnapshotFieldReplacer = strings.NewReplacer("\r", " ", "\n", " ", "<", "", ">", "") type ciAutomationCheckpoint struct { - FailedChecks []ciAutomationCheckSnapshot `json:"failed_checks"` - Comments []ciAutomationCommentSnapshot `json:"comments"` + FailedChecks []ciAutomationCheckSnapshot `json:"failed_checks"` + Comments []ciAutomationCommentSnapshot `json:"comments"` + QueueRemovals []ciAutomationQueueRemovalSnapshotData `json:"queue_removals,omitempty"` } type ciAutomationCheckSnapshot struct { @@ -56,6 +57,25 @@ type ciAutomationCommentSnapshot struct { Line int `json:"line,omitempty"` } +const ( + ciAutomationQueueRemovalCauseChecksFailed = "checks_failed" + ciAutomationQueueRemovalCauseChecksTimedOut = "checks_timed_out" + ciAutomationQueueRemovalCauseConflict = "conflict" + ciAutomationQueueRemovalCauseManual = "manual" + ciAutomationQueueRemovalCauseBranchProtection = "branch_protection" + ciAutomationQueueRemovalCauseUnknown = "unknown" +) + +type ciAutomationQueueRemovalSnapshotData struct { + EventID string `json:"event_id"` + Cause string `json:"cause"` + Reason string `json:"reason,omitempty"` + RemovedAt time.Time `json:"removed_at,omitempty"` + BeforeCommit string `json:"before_commit,omitempty"` + CheckCommit string `json:"check_commit,omitempty"` + Conflict bool `json:"conflict,omitempty"` +} + func (s *Service) handleTaskPRCIAutomation(ctx context.Context, pr *github.TaskPR) error { return s.handleTaskPRCIAutomationWithRefresh(ctx, pr, true) } @@ -92,6 +112,9 @@ func (s *Service) handleTaskPRCIAutomationWithRefresh(ctx context.Context, pr *g pr = refreshed freshlySynced = synced } + if options.AutoFixEnabled || options.AutoMergeEnabled { + s.recordTaskPRMergeQueueObservation(ctx, pr) + } autoFixBlockedMerge := false autoFixError := "" if options.AutoFixEnabled && ciAutomationCanAutoFixFromFeedback(pr) { @@ -279,8 +302,13 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, previous := decodeCIAutomationCheckpoint(state) delta := ciAutomationBuildDelta(feedback, previous) checkpoint := ciAutomationCurrentCheckpoint(feedback) + queueRemoval, queueRecovery := ciAutomationNewQueueRemoval(pr, state) + if queueRecovery { + delta.QueueRemovals = append(delta.QueueRemovals, *queueRemoval) + checkpoint.QueueRemovals = append(checkpoint.QueueRemovals, *queueRemoval) + } checkpointJSON, signature := encodeCIAutomationCheckpoint(checkpoint) - if len(delta.FailedChecks) == 0 && len(delta.Comments) == 0 { + if ciAutomationCheckpointEmpty(delta) { return s.handleTaskPRCIAutoFixEmptyDelta(ctx, pr, state, previous, signature, checkpointJSON), "" } if state != nil && state.LastFixSignature == signature { @@ -304,15 +332,22 @@ func (s *Service) handleTaskPRCIAutoFix(ctx context.Context, pr *github.TaskPR, if err != nil { return true, err.Error() } + queueRemovalEventID, queueRemovalCause := "", "" + if queueRecovery { + queueRemovalEventID = queueRemoval.EventID + queueRemovalCause = queueRemoval.Cause + } if err := s.githubService.RecordTaskCIFixAttempt(context.WithoutCancel(ctx), github.TaskCIFixAttempt{ - TaskID: pr.TaskID, - RepositoryID: pr.RepositoryID, - PRNumber: pr.PRNumber, - Signature: signature, - CheckpointJSON: checkpointJSON, - SessionID: session.ID, - EnqueuedAt: time.Now().UTC(), - IncrementRound: result.consumesRound(), + TaskID: pr.TaskID, + RepositoryID: pr.RepositoryID, + PRNumber: pr.PRNumber, + Signature: signature, + CheckpointJSON: checkpointJSON, + SessionID: session.ID, + EnqueuedAt: time.Now().UTC(), + IncrementRound: queueRecovery || result.consumesRound(), + QueueRemovalEventID: queueRemovalEventID, + QueueRemovalCause: queueRemovalCause, }); err != nil { s.logger.Debug("record CI auto-fix attempt failed", zap.String("task_id", pr.TaskID), zap.Error(err)) } else { @@ -356,19 +391,28 @@ func (s *Service) handleTaskPRCIAutoFixEmptyDelta(ctx context.Context, pr *githu } func (s *Service) handleTaskPRCIAutoMerge(ctx context.Context, pr *github.TaskPR) string { + if ciAutomationHasActiveMergeQueueEntry(pr) { + return "" + } signature := ciAutomationMergeSignature(pr) state, err := s.githubService.GetTaskCIPRState(ctx, pr.TaskID, pr.RepositoryID, pr.PRNumber) if err != nil { s.logger.Debug("load CI automation merge state failed; attempting merge without dedupe", zap.String("task_id", pr.TaskID), zap.Error(err)) - } else if state != nil && state.LastMergeSignature == signature { - return "" + } else if state != nil { + if state.LastMergeSignature == signature { + return "" + } + if pr.HeadSHA != "" && state.LastQueueAttemptHeadSHA == pr.HeadSHA { + return "" + } } attempt := github.TaskCIMergeAttempt{ - TaskID: pr.TaskID, - RepositoryID: pr.RepositoryID, - PRNumber: pr.PRNumber, - Signature: signature, - AttemptedAt: time.Now().UTC(), + TaskID: pr.TaskID, + RepositoryID: pr.RepositoryID, + PRNumber: pr.PRNumber, + Signature: signature, + AttemptedAt: time.Now().UTC(), + AttemptedHeadSHA: pr.HeadSHA, } if err := s.githubService.MergePRForAutomation( ctx, pr.WorkspaceID, pr.Owner, pr.Repo, pr.PRNumber, "", @@ -380,6 +424,34 @@ func (s *Service) handleTaskPRCIAutoMerge(ctx context.Context, pr *github.TaskPR return "" } +func (s *Service) recordTaskPRMergeQueueObservation(ctx context.Context, pr *github.TaskPR) { + if pr == nil || (pr.MergeQueueLastRemovalID == "" && !ciAutomationHasActiveMergeQueueEntry(pr)) { + return + } + removal, _ := ciAutomationQueueRemovalSnapshot(pr) + observation := github.TaskCIMergeQueueObservation{ + TaskID: pr.TaskID, + RepositoryID: pr.RepositoryID, + PRNumber: pr.PRNumber, + RemovalEventID: pr.MergeQueueLastRemovalID, + RemovalObservedHeadSHA: pr.HeadSHA, + } + if ciAutomationHasActiveMergeQueueEntry(pr) { + observation.ActiveQueueHeadSHA = pr.MergeQueueEntryHeadSHA + if observation.ActiveQueueHeadSHA == "" { + observation.ActiveQueueHeadSHA = pr.HeadSHA + } + observation.MergeSignature = ciAutomationMergeSignature(pr) + } + if removal != nil { + observation.RemovalCause = removal.Cause + } + if err := s.githubService.RecordTaskCIMergeQueueObservation(context.WithoutCancel(ctx), observation); err != nil { + s.logger.Debug("record merge queue observation failed", + zap.String("task_id", pr.TaskID), zap.Int("pr_number", pr.PRNumber), zap.Error(err)) + } +} + // dispatchCIAutomationPromptForPR adapts the provider-agnostic // dispatchCIAutomationPrompt (ci_automation_dispatch.go, C5) to GitHub PR // vocabulary: the prompt gets the "@ci-auto-fix" mention prefix, the @@ -632,6 +704,115 @@ func ciAutomationCurrentCheckpoint(feedback *github.PRFeedback) ciAutomationChec return ciAutomationBuildDelta(feedback, ciAutomationCheckpoint{}) } +func ciAutomationCheckpointEmpty(checkpoint ciAutomationCheckpoint) bool { + return len(checkpoint.FailedChecks) == 0 && len(checkpoint.Comments) == 0 && len(checkpoint.QueueRemovals) == 0 +} + +func ciAutomationNewQueueRemoval(pr *github.TaskPR, state *github.TaskCIPRAutomationState) (*ciAutomationQueueRemovalSnapshotData, bool) { + snapshot, actionable := ciAutomationQueueRemovalSnapshot(pr) + if !actionable || snapshot == nil || !ciAutomationQueueRemovalBelongsToCurrentHead(pr, state) { + return nil, false + } + if state != nil && state.LastQueueFixEventID == snapshot.EventID { + return nil, false + } + return snapshot, true +} + +func ciAutomationQueueRemovalBelongsToCurrentHead(pr *github.TaskPR, state *github.TaskCIPRAutomationState) bool { + if pr == nil || strings.TrimSpace(pr.HeadSHA) == "" || state == nil { + return false + } + attemptHead := strings.TrimSpace(state.LastQueueAttemptHeadSHA) + // A removal-only observation may establish a current-head baseline, but it + // does not prove that Kandev ever queued this head. Require the merge + // signature written by an actual merge attempt or active-entry adoption so + // an old removal cannot spend a repair round after automation is enabled. + return attemptHead != "" && attemptHead == strings.TrimSpace(pr.HeadSHA) && strings.TrimSpace(state.LastMergeSignature) != "" +} + +func ciAutomationHasActiveMergeQueueEntry(pr *github.TaskPR) bool { + if pr == nil || (pr.State != "" && !strings.EqualFold(strings.TrimSpace(pr.State), "open")) { + return false + } + return strings.TrimSpace(pr.MergeQueueState) != "" +} + +func ciAutomationQueueRemovalSnapshot(pr *github.TaskPR) (*ciAutomationQueueRemovalSnapshotData, bool) { + if pr == nil || strings.TrimSpace(pr.MergeQueueLastRemovalID) == "" { + return nil, false + } + cause := ciAutomationQueueRemovalCause(pr.MergeQueueLastRemovalReason, pr) + return &ciAutomationQueueRemovalSnapshotData{ + EventID: pr.MergeQueueLastRemovalID, + Cause: cause, + Reason: ciAutomationSanitizeSnapshotField(pr.MergeQueueLastRemovalReason), + RemovedAt: valueOrZeroTime(pr.MergeQueueLastRemovedAt), + BeforeCommit: ciAutomationSanitizeSnapshotField(pr.MergeQueueLastRemovalBeforeSHA), + Conflict: ciAutomationQueueRemovalConflictEvidence(pr), + }, ciAutomationQueueRemovalCauseActionable(cause) +} + +func valueOrZeroTime(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return value.UTC() +} + +func ciAutomationQueueRemovalCause(reason string, pr *github.TaskPR) string { + normalized := normalizeCIAutomationQueueRemovalReason(reason) + switch normalized { + case "checks_failed", "check_failed", "checks_failure", "ci_checks_failed", "ci_check_failed", "checks_failed_on_merge_group", "checks_failed_on_merge_queue": + return ciAutomationQueueRemovalCauseChecksFailed + case "checks_timed_out", "check_timed_out", "checks_timeout", "timeout", "timed_out", "checks_timed_out_on_merge_group": + return ciAutomationQueueRemovalCauseChecksTimedOut + case "merge_conflict", "merge_conflicts", "conflict", "unmergeable": + return ciAutomationQueueRemovalCauseConflict + case "manual", "removed_manually", "user_removed": + return ciAutomationQueueRemovalCauseManual + case "branch_protection", "branch_protection_failed", "required_branch_protection": + return ciAutomationQueueRemovalCauseBranchProtection + } + if ciAutomationQueueRemovalConflictEvidence(pr) { + return ciAutomationQueueRemovalCauseConflict + } + return ciAutomationQueueRemovalCauseUnknown +} + +func normalizeCIAutomationQueueRemovalReason(reason string) string { + var b strings.Builder + lastUnderscore := false + for _, r := range strings.ToLower(strings.TrimSpace(reason)) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + b.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(b.String(), "_") +} + +func ciAutomationQueueRemovalConflictEvidence(pr *github.TaskPR) bool { + if pr == nil { + return false + } + mergeable := strings.ToLower(strings.TrimSpace(pr.MergeableState)) + queueState := strings.ToLower(strings.TrimSpace(pr.MergeQueueState)) + return mergeable == "dirty" || strings.Contains(mergeable, "conflict") || + queueState == "unmergeable" || strings.Contains(queueState, "unmergeable") +} + +func ciAutomationQueueRemovalCauseActionable(cause string) bool { + return cause == ciAutomationQueueRemovalCauseChecksFailed || + cause == ciAutomationQueueRemovalCauseChecksTimedOut || + cause == ciAutomationQueueRemovalCauseConflict +} + func ciAutomationRenderPrompt(base string, pr *github.TaskPR, delta ciAutomationCheckpoint) string { if base = strings.TrimSpace(base); base != "" { return ciAutomationRenderPromptTemplate(base, ciAutomationRenderSnapshot(pr, delta)) @@ -680,6 +861,31 @@ func ciAutomationRenderSnapshot(pr *github.TaskPR, delta ciAutomationCheckpoint) b.WriteString(fmt.Sprintf("\n- %s:%d %s", ciAutomationSanitizeSnapshotField(comment.Path), comment.Line, ciAutomationSanitizeSnapshotField(strings.TrimSpace(comment.Body)))) } } + if len(delta.QueueRemovals) > 0 { + b.WriteString("\n\nMerge queue removal recovery:") + for _, removal := range delta.QueueRemovals { + b.WriteString("\n- event ") + b.WriteString(ciAutomationSanitizeSnapshotField(removal.EventID)) + b.WriteString(": cause ") + b.WriteString(ciAutomationSanitizeSnapshotField(removal.Cause)) + if removal.Reason != "" { + b.WriteString("; reason ") + b.WriteString(ciAutomationSanitizeSnapshotField(removal.Reason)) + } + if removal.Conflict { + b.WriteString("; conflict state observed") + } + if !removal.RemovedAt.IsZero() { + b.WriteString("; removed at ") + b.WriteString(removal.RemovedAt.UTC().Format(time.RFC3339)) + } + if removal.BeforeCommit != "" { + b.WriteString("; beforeCommit ") + b.WriteString(ciAutomationSanitizeSnapshotField(removal.BeforeCommit)) + b.WriteString(" (not used as check identity)") + } + } + } return b.String() } @@ -703,7 +909,7 @@ func encodeCIAutomationCheckpoint(checkpoint ciAutomationCheckpoint) (string, st } func ciAutomationMergeSignature(pr *github.TaskPR) string { - payload := fmt.Sprintf("%s|%s|%d|%s|%d|%d|%s|%s|%s|%d|%d", pr.TaskID, pr.RepositoryID, pr.PRNumber, pr.HeadBranch, pr.Additions, pr.Deletions, pr.ChecksState, pr.ReviewState, pr.MergeableState, pr.ReviewCount, pr.UnresolvedReviewThreads) + payload := fmt.Sprintf("%s|%s|%d|%s|%s|%d|%d|%s|%s|%s|%d|%d", pr.TaskID, pr.RepositoryID, pr.PRNumber, pr.HeadSHA, pr.HeadBranch, pr.Additions, pr.Deletions, pr.ChecksState, pr.ReviewState, pr.MergeableState, pr.ReviewCount, pr.UnresolvedReviewThreads) sum := sha256.Sum256([]byte(payload)) return hex.EncodeToString(sum[:]) } diff --git a/apps/backend/internal/orchestrator/event_handlers_github_ci_merge_queue_recovery_test.go b/apps/backend/internal/orchestrator/event_handlers_github_ci_merge_queue_recovery_test.go new file mode 100644 index 0000000000..7785ef278a --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_github_ci_merge_queue_recovery_test.go @@ -0,0 +1,281 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/task/models" +) + +type mergeQueueRecoveryGitHubService struct { + *mockGitHubService +} + +func (s *mergeQueueRecoveryGitHubService) RecordTaskCIMergeQueueObservation( + _ context.Context, + observation github.TaskCIMergeQueueObservation, +) error { + if s.ciPRState == nil { + s.ciPRState = &github.TaskCIPRAutomationState{ + TaskID: observation.TaskID, RepositoryID: observation.RepositoryID, PRNumber: observation.PRNumber, + } + } + if observation.ActiveQueueHeadSHA != "" { + s.ciPRState.LastQueueAttemptHeadSHA = observation.ActiveQueueHeadSHA + if observation.MergeSignature != "" { + s.ciPRState.LastMergeSignature = observation.MergeSignature + } + } else if s.ciPRState.LastQueueAttemptHeadSHA == "" { + s.ciPRState.LastQueueAttemptHeadSHA = observation.RemovalObservedHeadSHA + } + if observation.RemovalCause != "" { + s.ciPRState.LastQueueRemovalCause = observation.RemovalCause + } + return nil +} + +func (s *mergeQueueRecoveryGitHubService) RecordTaskCIFixAttempt( + ctx context.Context, + attempt github.TaskCIFixAttempt, +) error { + if err := s.mockGitHubService.RecordTaskCIFixAttempt(ctx, attempt); err != nil { + return err + } + if attempt.QueueRemovalEventID != "" { + if s.ciPRState == nil { + s.ciPRState = &github.TaskCIPRAutomationState{ + TaskID: attempt.TaskID, RepositoryID: attempt.RepositoryID, PRNumber: attempt.PRNumber, + } + } + s.ciPRState.LastQueueFixEventID = attempt.QueueRemovalEventID + s.ciPRState.LastQueueRemovalCause = attempt.QueueRemovalCause + } + return nil +} + +func (s *mergeQueueRecoveryGitHubService) RecordTaskCIMergeAttempt( + ctx context.Context, + attempt github.TaskCIMergeAttempt, +) error { + if err := s.mockGitHubService.RecordTaskCIMergeAttempt(ctx, attempt); err != nil { + return err + } + if s.ciPRState == nil { + s.ciPRState = &github.TaskCIPRAutomationState{ + TaskID: attempt.TaskID, RepositoryID: attempt.RepositoryID, PRNumber: attempt.PRNumber, + } + } + s.ciPRState.LastQueueAttemptHeadSHA = attempt.AttemptedHeadSHA + s.ciPRState.LastMergeSignature = attempt.Signature + return nil +} + +func TestCIAutomationMergeQueueRecoveryClassifiesReviewedReasons(t *testing.T) { + tests := []struct { + name string + reason string + want string + }{ + {name: "checks failed enum", reason: "CHECKS_FAILED", want: ciAutomationQueueRemovalCauseChecksFailed}, + {name: "CI checks failed text", reason: "CI checks failed", want: ciAutomationQueueRemovalCauseChecksFailed}, + {name: "checks timed out text", reason: "Checks timed out", want: ciAutomationQueueRemovalCauseChecksTimedOut}, + {name: "merge conflict text", reason: "merge conflict", want: ciAutomationQueueRemovalCauseConflict}, + {name: "manual", reason: "MANUAL", want: ciAutomationQueueRemovalCauseManual}, + {name: "branch protection", reason: "BRANCH_PROTECTION", want: ciAutomationQueueRemovalCauseBranchProtection}, + {name: "unknown", reason: "provider changed this", want: ciAutomationQueueRemovalCauseUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ciAutomationQueueRemovalCause(tt.reason, &github.TaskPR{}); got != tt.want { + t.Fatalf("ciAutomationQueueRemovalCause(%q) = %q, want %q", tt.reason, got, tt.want) + } + }) + } +} + +func TestCIAutomationMergeQueueRecoveryRequiresDurableAttemptEvidence(t *testing.T) { + pr := &github.TaskPR{HeadSHA: "head-a"} + tests := []struct { + name string + state *github.TaskCIPRAutomationState + want bool + }{ + {name: "no state", state: nil, want: false}, + { + name: "passive baseline has no attempt signature", + state: &github.TaskCIPRAutomationState{LastQueueAttemptHeadSHA: "head-a"}, + want: false, + }, + { + name: "empty attempt head fails closed", + state: &github.TaskCIPRAutomationState{LastMergeSignature: "merge-a"}, + want: false, + }, + { + name: "matching attempted head and signature", + state: &github.TaskCIPRAutomationState{ + LastQueueAttemptHeadSHA: "head-a", + LastMergeSignature: "merge-a", + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ciAutomationQueueRemovalBelongsToCurrentHead(pr, tt.state); got != tt.want { + t.Fatalf("queue removal evidence = %v, want %v for state %+v", got, tt.want, tt.state) + } + }) + } +} + +func TestCIAutomationMergeQueueRecoveryUsesConflictEvidenceForUnknownReason(t *testing.T) { + pr := &github.TaskPR{MergeableState: "dirty"} + if got := ciAutomationQueueRemovalCause("provider changed this", pr); got != ciAutomationQueueRemovalCauseConflict { + t.Fatalf("conflict evidence cause = %q, want %q", got, ciAutomationQueueRemovalCauseConflict) + } + if got := ciAutomationQueueRemovalCause("provider changed this", &github.TaskPR{MergeQueueState: "UNMERGEABLE"}); got != ciAutomationQueueRemovalCauseConflict { + t.Fatalf("unmergeable queue cause = %q, want %q", got, ciAutomationQueueRemovalCauseConflict) + } +} + +func TestCIAutomationActiveQueueCheckIgnoresStaleIdentifiers(t *testing.T) { + if ciAutomationHasActiveMergeQueueEntry(&github.TaskPR{ + State: "open", MergeQueueEntryID: "stale-entry", MergeQueueEntryHeadSHA: "stale-head", + }) { + t.Fatal("stale queue identifiers were treated as an active queue entry") + } + if !ciAutomationHasActiveMergeQueueEntry(&github.TaskPR{State: "open", MergeQueueState: "queued"}) { + t.Fatal("queued merge queue state was not treated as active") + } + if ciAutomationHasActiveMergeQueueEntry(&github.TaskPR{State: "closed", MergeQueueState: "queued"}) { + t.Fatal("closed PR was treated as an active queue entry") + } +} + +func TestCIAutomationMergeQueueRecoveryDoesNotTreatBeforeCommitAsCheckIdentity(t *testing.T) { + pr := &github.TaskPR{ + HeadSHA: "head-a", MergeQueueLastRemovalID: "removal-a", MergeQueueLastRemovalBeforeSHA: "merge-group-a", + MergeQueueLastRemovalReason: "CHECKS_FAILED", State: "open", + } + snapshot, actionable := ciAutomationQueueRemovalSnapshot(pr) + if !actionable || snapshot == nil { + t.Fatalf("expected actionable queue removal, snapshot=%+v actionable=%v", snapshot, actionable) + } + if snapshot.BeforeCommit != "merge-group-a" || snapshot.CheckCommit != "" { + t.Fatalf("beforeCommit was treated as a check identity: %+v", snapshot) + } +} + +func TestCIAutomationMergeQueueRecoveryQueuesOneRepairPerRemoval(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mergeQueueRecoveryGitHubService{mockGitHubService: &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{ + TaskID: "task-1", AutoFixEnabled: true, EffectiveAutoFixPrompt: "Repair the PR\n\n{{pr.feedback}}", + }, + prFeedback: &github.PRFeedback{}, + }} + ghSvc.ciPRState = &github.TaskCIPRAutomationState{ + TaskID: "task-1", RepositoryID: "repo-1", PRNumber: 42, + LastQueueAttemptHeadSHA: "head-a", LastMergeSignature: "merge-a", + } + svc.SetGitHubService(ghSvc) + pr := &github.TaskPR{ + TaskID: "task-1", RepositoryID: "repo-1", Owner: "acme", Repo: "widget", PRNumber: 42, + State: "open", ChecksState: "success", HeadSHA: "head-a", + MergeQueueLastRemovalID: "removal-a", MergeQueueLastRemovalReason: "CHECKS_FAILED", + MergeQueueLastRemovedAt: func() *time.Time { value := time.Now().UTC(); return &value }(), + } + + if err := svc.handleTaskPRCIAutomationWithRefresh(ctx, pr, false); err != nil { + t.Fatalf("first queue recovery evaluation: %v", err) + } + if got := svc.messageQueue.GetStatus(ctx, "session-1"); got.Count != 1 || !strings.Contains(strings.ToLower(got.Entries[0].Content), "merge queue") { + t.Fatalf("queue recovery prompt = %+v, want one merge-queue prompt", got) + } + if len(ghSvc.fixAttempts) != 1 || ghSvc.fixAttempts[0].QueueRemovalEventID != "removal-a" || !ghSvc.fixAttempts[0].IncrementRound { + t.Fatalf("queue recovery attempts = %+v, want one round-consuming removal attempt", ghSvc.fixAttempts) + } + + if err := svc.handleTaskPRCIAutomationWithRefresh(ctx, pr, false); err != nil { + t.Fatalf("duplicate queue recovery evaluation: %v", err) + } + if got := svc.messageQueue.GetStatus(ctx, "session-1"); got.Count != 1 || len(ghSvc.fixAttempts) != 1 { + t.Fatalf("duplicate queue recovery was dispatched: status=%+v attempts=%+v", got, ghSvc.fixAttempts) + } +} + +func TestCIAutomationMergeQueueRecoveryBlocksSameHeadAndRequeuesNewHead(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mergeQueueRecoveryGitHubService{mockGitHubService: &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{TaskID: "task-1", AutoMergeEnabled: true}, + }} + svc.SetGitHubService(ghSvc) + now := time.Now().UTC() + pr := &github.TaskPR{ + TaskID: "task-1", RepositoryID: "repo-1", Owner: "acme", Repo: "widget", PRNumber: 42, + State: "open", ChecksState: "success", ReviewState: "approved", MergeableState: "clean", + HeadSHA: "head-a", LastSyncedAt: &now, MergeQueueLastRemovalID: "removal-a", + MergeQueueLastRemovalReason: "CHECKS_FAILED", + } + + if err := svc.handleTaskPRCIAutomationWithRefresh(ctx, pr, false); err != nil { + t.Fatalf("same-head evaluation: %v", err) + } + if ghSvc.mergeCalls != 0 { + t.Fatalf("same-head removal was requeued: merge calls=%d", ghSvc.mergeCalls) + } + + pr.HeadSHA = "head-b" + if err := svc.handleTaskPRCIAutomationWithRefresh(ctx, pr, false); err != nil { + t.Fatalf("new-head evaluation: %v", err) + } + if ghSvc.mergeCalls != 1 || len(ghSvc.mergeAttempts) != 1 || ghSvc.mergeAttempts[0].AttemptedHeadSHA != "head-b" { + t.Fatalf("new-head queue attempt = calls %d attempts %+v, want one head-b attempt", ghSvc.mergeCalls, ghSvc.mergeAttempts) + } +} + +func TestCIAutomationMergeQueueRecoveryAdoptsActiveQueueEntry(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-1", "session-1", models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mergeQueueRecoveryGitHubService{mockGitHubService: &mockGitHubService{ + ciOptionsResp: &github.TaskCIOptionsResponse{TaskID: "task-1", AutoMergeEnabled: true}, + }} + svc.SetGitHubService(ghSvc) + now := time.Now().UTC() + pr := &github.TaskPR{ + TaskID: "task-1", RepositoryID: "repo-1", Owner: "acme", Repo: "widget", PRNumber: 42, + State: "open", ChecksState: "success", MergeableState: "clean", HeadSHA: "head-a", LastSyncedAt: &now, + MergeQueueState: "QUEUED", MergeQueueEntryID: "entry-a", MergeQueueEntryHeadSHA: "head-a", + } + + if err := svc.handleTaskPRCIAutomationWithRefresh(ctx, pr, false); err != nil { + t.Fatalf("active queue adoption: %v", err) + } + if ghSvc.mergeCalls != 0 { + t.Fatalf("active queue entry was duplicated: merge calls=%d", ghSvc.mergeCalls) + } + if ghSvc.ciPRState == nil || ghSvc.ciPRState.LastQueueAttemptHeadSHA != "head-a" { + t.Fatalf("active queue adoption state = %+v, want head-a", ghSvc.ciPRState) + } +} + +func TestCIAutomationMergeQueueRecoveryMergeSignatureIncludesHeadSHA(t *testing.T) { + left := &github.TaskPR{TaskID: "task-1", RepositoryID: "repo-1", PRNumber: 42, HeadSHA: "head-a"} + right := *left + right.HeadSHA = "head-b" + if ciAutomationMergeSignature(left) == ciAutomationMergeSignature(&right) { + t.Fatal("merge signature did not change when PR head SHA changed") + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index cc3a2127cf..da445618eb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -220,6 +220,20 @@ func (m *mockGitHubService) RecordTaskCIMergeAttempt(_ context.Context, attempt m.mergeAttempts = append(m.mergeAttempts, attempt) return nil } +func (m *mockGitHubService) RecordTaskCIMergeQueueObservation(_ context.Context, observation github.TaskCIMergeQueueObservation) error { + if m.ciPRState == nil { + m.ciPRState = &github.TaskCIPRAutomationState{ + TaskID: observation.TaskID, RepositoryID: observation.RepositoryID, PRNumber: observation.PRNumber, + } + } + if observation.ActiveQueueHeadSHA != "" { + m.ciPRState.LastQueueAttemptHeadSHA = observation.ActiveQueueHeadSHA + } + if observation.RemovalCause != "" { + m.ciPRState.LastQueueRemovalCause = observation.RemovalCause + } + return nil +} func (m *mockGitHubService) RecordTaskCIError(_ context.Context, taskID, repositoryID string, prNumber int, message string) error { m.ciErrors = append(m.ciErrors, github.TaskCIPRAutomationState{ TaskID: taskID, diff --git a/apps/web/components/github/pr-ci-automation-controls.tsx b/apps/web/components/github/pr-ci-automation-controls.tsx index 260818545c..e30842148c 100644 --- a/apps/web/components/github/pr-ci-automation-controls.tsx +++ b/apps/web/components/github/pr-ci-automation-controls.tsx @@ -6,6 +6,7 @@ import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-o import { findCIAutomationStateForPR, findPRAutomationOptionsForPR, + deriveCIAutomationQueueState, } from "@/lib/github/ci-automation"; import type { TaskCIAutomationPatch, TaskPR } from "@/lib/types/github"; import { CIAutomationPromptDialog } from "@/components/github/pr-ci-automation-prompt-dialog"; @@ -23,6 +24,7 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) { const [promptDraft, setPromptDraft] = useState(""); const automationState = findCIAutomationStateForPR(options?.pr_states, pr); const prOptions = findPRAutomationOptionsForPR(options?.pr_options, pr); + const queueState = deriveCIAutomationQueueState(pr, prOptions, automationState); const openPromptEditor = useCallback(() => { setPromptDraft(options?.auto_fix_prompt_override ?? options?.effective_auto_fix_prompt ?? ""); @@ -77,7 +79,12 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) { data-testid="pr-ci-automation-controls" className="flex flex-col gap-1 border-t border-border/50 pt-2" > - + cleanup()); + +describe("CI automation queue recovery status", () => { + it("renders active queue status as non-interactive status text", () => { + const { getByRole } = render( + , + ); + + expect(getByRole("status").textContent).toContain("Active merge queue attempt"); + }); + + it("renders the same-head guard status after removal", () => { + const { getByRole } = render( + , + ); + + expect(getByRole("status").textContent).toContain("Waiting for a new commit"); + }); +}); diff --git a/apps/web/components/github/pr-ci-automation-rows.tsx b/apps/web/components/github/pr-ci-automation-rows.tsx index d56fefa7ac..61581a79fd 100644 --- a/apps/web/components/github/pr-ci-automation-rows.tsx +++ b/apps/web/components/github/pr-ci-automation-rows.tsx @@ -9,7 +9,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Switch } from "@kandev/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; -import { autoFixRoundForState } from "@/lib/github/ci-automation"; +import { + autoFixRoundForState, + type CIAutomationQueueRecoveryState, + deriveCIAutomationQueueState, +} from "@/lib/github/ci-automation"; import type { TaskCIAutomationPatch, TaskCIPRAutomationState, @@ -42,7 +46,7 @@ export function CIAutomationInfoButton() { - {t("github:watchesThisTaskSLinkedPull")} + {t("github:ciAutomationQueueRecoveryHelp")} ); @@ -55,6 +59,7 @@ export function CIAutomationRow({ disabled, onCheckedChange, help, + supportingText, describedBy, }: { id: string; @@ -63,6 +68,7 @@ export function CIAutomationRow({ disabled: boolean; onCheckedChange: (checked: boolean) => void; help?: ReactNode; + supportingText?: ReactNode; describedBy?: string; }) { const { isFinePointer, isMobile } = useResponsiveBreakpoint(); @@ -70,11 +76,18 @@ export function CIAutomationRow({ return (
-
- - {help} +
+
+ + {help} +
+ {supportingText ? ( + + {supportingText} + + ) : null}
) => string; + +function queueContextTitle( + t: CIAutomationTranslate, + context: CIAutomationQueueRecoveryState["context"], +): string { + switch (context) { + case "queued": + return t("github:mergeQueueAutomation"); + case "recovery": + return t("github:mergeQueueRecovery"); + default: + return t("github:automation"); + } +} + +function queueRemovalCauseLabel( + t: CIAutomationTranslate, + cause: CIAutomationQueueRecoveryState["removalCause"], +): string { + switch (cause) { + case "checks_failed": + return t("github:mergeQueueRemovalCauseChecksFailed"); + case "checks_timed_out": + return t("github:mergeQueueRemovalCauseChecksTimedOut"); + case "conflict": + return t("github:mergeQueueRemovalCauseConflict"); + default: + return ""; + } +} + +function autoFixQueueSupport( + t: CIAutomationTranslate, + queueState: CIAutomationQueueRecoveryState, + enabled: boolean, +): string | undefined { + switch (queueState.status) { + case "queued": + return t("github:autoFixQueueSupportQueued"); + case "removed_actionable": + return enabled ? undefined : t("github:autoFixQueueSupportEnable"); + case "repair_requested": + return t("github:autoFixQueueSupportRequested"); + case "removed_not_actionable": + return t("github:autoFixQueueSupportNotActionable"); + case "waiting_for_checks": + return t("github:autoFixQueueSupportNewHead"); + default: + return undefined; + } +} + +function autoMergeQueueSupport( + t: CIAutomationTranslate, + queueState: CIAutomationQueueRecoveryState, + enabled: boolean, +): string | undefined { + switch (queueState.status) { + case "queued": + return t("github:autoMergeQueueSupportQueued"); + case "repair_requested": + case "waiting_for_commit": + return t("github:autoMergeQueueSupportWaitingForCommit"); + case "waiting_for_checks": + return t("github:autoMergeQueueSupportWaitingForChecks"); + default: + if (queueState.context !== "recovery") return undefined; + return enabled + ? t("github:autoMergeQueueSupportNewHead") + : t("github:autoMergeQueueSupportDisabled"); + } +} + export function CIAutomationHeader({ pr, + queueState, disabled, onEditPrompt, }: { pr: TaskPR; + queueState: CIAutomationQueueRecoveryState; disabled: boolean; onEditPrompt: () => void; }) { const { t } = useTranslation(); + const title = queueContextTitle(t, queueState.context); + let subtitle = t("github:automationForPr", { number: pr.pr_number }); + if (queueState.context === "queued") { + subtitle = t("github:automationForPrQueued", { number: pr.pr_number }); + } else if (queueState.context === "recovery") { + const cause = queueRemovalCauseLabel(t, queueState.removalCause); + if (cause) { + subtitle = t("github:automationForPrRemoved", { number: pr.pr_number, cause }); + } else { + subtitle = t("github:automationForPrRemovedGeneric", { number: pr.pr_number }); + } + } return (
-
{t("github:automation")}
-
- {t("github:automationForPr", { number: pr.pr_number })} -
+
{title}
+
{subtitle}
@@ -370,6 +469,49 @@ export function CIAutomationHeader({ ); } +export function CIAutomationQueueStatusRow({ + queueState, +}: { + queueState: CIAutomationQueueRecoveryState; +}) { + const { t } = useTranslation(); + if (queueState.status === "none") return null; + let message: string; + switch (queueState.status) { + case "queued": + message = t("github:mergeQueueRecoveryStatusQueued"); + break; + case "removed_actionable": + message = t("github:mergeQueueRecoveryStatusActionable"); + break; + case "removed_not_actionable": + message = t("github:mergeQueueRecoveryStatusNotActionable"); + break; + case "repair_requested": + if (queueState.waitingForCommit) { + message = t("github:mergeQueueRecoveryStatusRepairRequestedWaiting"); + } else { + message = t("github:mergeQueueRecoveryStatusRepairRequested"); + } + break; + case "waiting_for_commit": + message = t("github:mergeQueueRecoveryStatusWaitingForCommit"); + break; + case "waiting_for_checks": + message = t("github:mergeQueueRecoveryStatusWaitingForChecks"); + break; + } + return ( +
+ {message} +
+ ); +} + export function CIAutomationOptionRows({ pr, prOptions, @@ -387,6 +529,13 @@ export function CIAutomationOptionRows({ }) { const { t } = useTranslation(); const scopeKey = prAutomationScopeKey(pr); + const queueState = deriveCIAutomationQueueState(pr, prOptions, automationState); + const autoFixSupportingText = autoFixQueueSupport(t, queueState, prOptions.auto_fix_enabled); + const autoMergeSupportingText = autoMergeQueueSupport( + t, + queueState, + prOptions.auto_merge_enabled, + ); return ( <> patchOption({ auto_fix_enabled: checked })} + supportingText={autoFixSupportingText} help={ prOptions.auto_fix_enabled ? ( @@ -403,11 +553,13 @@ export function CIAutomationOptionRows({ /> patchOption({ auto_merge_enabled: checked })} + supportingText={autoMergeSupportingText} /> + = {}): TaskPR { }; } -function renderPopover() { +function renderPopover(pr: TaskPR = makePR()) { return render( - + , @@ -305,6 +305,73 @@ describe("PRCIPopover automation status", () => { expect(screen.queryByLabelText(/edit.*review/i)).toBeNull(); }); + it("shows active queue context without adding another switch", () => { + renderPopover( + makePR({ + merge_queue_state: "queued", + merge_queue_entry_id: "entry-a", + head_sha: "head-a", + }), + ); + + expect(screen.getByText("Merge queue automation")).not.toBeNull(); + expect(screen.getByText("PR #1 is in the merge queue")).not.toBeNull(); + expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain( + "Active merge queue attempt", + ); + expect(screen.getAllByRole("switch")).toHaveLength(2); + }); + + it("shows classified recovery state and generic copy for unknown causes", () => { + hookMocks.options = makeOptions({ + pr_states: [ + { + task_id: "task-1", + repository_id: "", + pr_number: 1, + last_fix_signature: "", + last_fix_checkpoint_json: "", + last_fix_enqueued_at: null, + last_fix_session_id: null, + auto_fix_round_count: 0, + auto_fix_exhausted_at: null, + last_merge_signature: "", + last_merge_attempt_at: null, + last_queue_attempt_head_sha: "head-a", + last_queue_fix_event_id: "", + last_queue_removal_cause: "provider_changed", + last_error: null, + created_at: "", + updated_at: "", + }, + ], + }); + renderPopover( + makePR({ + head_sha: "head-a", + merge_queue_last_removal_id: "removal-a", + merge_queue_last_removal_reason: "provider changed this", + }), + ); + + expect(screen.getByText("Merge queue recovery")).not.toBeNull(); + expect(screen.getByText("PR #1 was removed from the merge queue")).not.toBeNull(); + expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain( + "No automatic repair", + ); + expect(screen.queryByText("provider changed this")).toBeNull(); + }); +}); + +describe("PRCIPopover linked PR status", () => { + beforeEach(() => { + resetHookMocks(); + }); + + afterEach(() => { + cleanup(); + }); + it("uses the PR title in the header and omits the redundant detail link", () => { renderPopover(); diff --git a/apps/web/components/github/pr-status-chip.test.tsx b/apps/web/components/github/pr-status-chip.test.tsx index e616f85023..8ceb98094c 100644 --- a/apps/web/components/github/pr-status-chip.test.tsx +++ b/apps/web/components/github/pr-status-chip.test.tsx @@ -533,7 +533,7 @@ describe("PRStatusChip CI automation mobile parity", () => { const drawer = document.querySelector(DRAWER_SELECTOR); expect(drawer?.textContent).toContain("Auto-fix CI and address comments"); - expect(drawer?.textContent).toContain("Auto-merge when ready"); + expect(drawer?.textContent).toContain("Auto-merge or requeue when ready"); act(() => { fireEvent.click(screen.getByLabelText("Edit auto-fix prompt for this task")); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 8c26851b11..ec0ee8c11d 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1498,6 +1498,42 @@ export class ApiClient { }); } + async mockGitHubTransitionMergeQueue(data: { + task_id: string; + owner: string; + repo: string; + pr_number: number; + head_sha?: string; + merge_queue_state?: string; + merge_queue_position?: number | null; + merge_queue_entry_id?: string; + merge_queue_entry_head_sha?: string; + merge_queue_estimated_time_to_merge_seconds?: number | null; + merge_queue_last_removal_id?: string; + merge_queue_last_removed_at?: string; + merge_queue_last_removal_reason?: string; + merge_queue_last_removal_before_sha?: string; + checks?: Array<{ + name: string; + source?: string; + status?: string; + conclusion?: string; + html_url?: string; + output?: string; + }>; + }): Promise { + await this.request("PUT", "/api/v1/github/mock/merge-queue", data); + } + + async mockGitHubGetMergeAttempts(): Promise< + Array<{ owner: string; repo: string; number: number; merge_method: string }> + > { + const response = await this.request<{ + attempts?: Array<{ owner: string; repo: string; number: number; merge_method: string }>; + }>("GET", "/api/v1/github/mock/merge-attempts"); + return response.attempts ?? []; + } + async mockGitHubAddIssues(issues: MockIssue[]): Promise { await this.request("POST", "/api/v1/github/mock/issues", { issues }); await this.seedMockGitHubRepositoryAccess(issues); @@ -1679,12 +1715,19 @@ export class ApiClient { base_branch: string; author_login: string; state?: string; + head_sha?: string; review_state?: string; checks_state?: string; mergeable_state?: string; merge_queue_state?: string; merge_queue_position?: number | null; + merge_queue_entry_id?: string; + merge_queue_entry_head_sha?: string; merge_queue_estimated_time_to_merge_seconds?: number | null; + merge_queue_last_removal_id?: string; + merge_queue_last_removed_at?: string; + merge_queue_last_removal_reason?: string; + merge_queue_last_removal_before_sha?: string; additions?: number; deletions?: number; review_count?: number; diff --git a/apps/web/e2e/tests/pr/ci-automation-options.spec.ts b/apps/web/e2e/tests/pr/ci-automation-options.spec.ts index 449ea2f8ce..59672438cb 100644 --- a/apps/web/e2e/tests/pr/ci-automation-options.spec.ts +++ b/apps/web/e2e/tests/pr/ci-automation-options.spec.ts @@ -189,7 +189,9 @@ test.describe("PR CI automation options", () => { await expect( popover.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).toBeVisible(); - await expect(popover.getByRole("switch", { name: "Auto-merge when ready" })).toBeVisible(); + await expect( + popover.getByRole("switch", { name: "Auto-merge or requeue when ready" }), + ).toBeVisible(); const reviewFollowUp = popover.getByTestId("ci-review-follow-up-trigger"); await expect(reviewFollowUp).toHaveAttribute("aria-expanded", "false"); await reviewFollowUp.click(); @@ -211,7 +213,7 @@ test.describe("PR CI automation options", () => { ).toBeVisible(); await popover.getByRole("switch", { name: "Auto-fix CI and address comments" }).click(); - await popover.getByRole("switch", { name: "Auto-merge when ready" }).click(); + await popover.getByRole("switch", { name: "Auto-merge or requeue when ready" }).click(); await popover.getByRole("switch", { name: "Your review is requested" }).click(); await popover.getByRole("switch", { name: "PR merged" }).click(); @@ -225,11 +227,17 @@ test.describe("PR CI automation options", () => { }); await popover.getByLabel("Explain CI automation options").hover(); - await expect(testPage.getByText(/1 minute PR refresh loop/)).toBeVisible(); - await expect(testPage.getByText(/notification switches wake the task's agent/)).toBeVisible(); - await expect( - testPage.getByText(/workspace's connected GitHub account is requested for review/i), - ).toBeVisible(); + const queueRecoveryHelp = testPage.getByRole("tooltip"); + await expect(queueRecoveryHelp).toContainText("Auto-fix repairs actionable queue removals."); + await expect(queueRecoveryHelp).toContainText( + "Auto-merge submits an eligible head or requeues it after a new commit.", + ); + await expect(queueRecoveryHelp).toContainText( + "Both controls form the repair and requeue loop.", + ); + await expect(queueRecoveryHelp).toContainText( + "Kandev never requeues the same head after removal.", + ); await openPromptDialog(session); const promptDialog = testPage.getByRole("dialog", { name: "Auto-fix prompt" }); @@ -264,7 +272,7 @@ test.describe("PR CI automation options", () => { }), ).toBeChecked(); await expect( - reloaded.prTopbarPopover().getByRole("switch", { name: "Auto-merge when ready" }), + reloaded.prTopbarPopover().getByRole("switch", { name: "Auto-merge or requeue when ready" }), ).toBeChecked(); }); @@ -303,7 +311,7 @@ test.describe("PR CI automation options", () => { popover.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).toBeVisible(); await popover.getByRole("switch", { name: "Auto-fix CI and address comments" }).click(); - await popover.getByRole("switch", { name: "Auto-merge when ready" }).click(); + await popover.getByRole("switch", { name: "Auto-merge or requeue when ready" }).click(); await expect .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) @@ -327,7 +335,9 @@ test.describe("PR CI automation options", () => { await expect( popover.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).not.toBeChecked(); - await expect(popover.getByRole("switch", { name: "Auto-merge when ready" })).not.toBeChecked(); + await expect( + popover.getByRole("switch", { name: "Auto-merge or requeue when ready" }), + ).not.toBeChecked(); // Reload: independence must persist across a full page load. Select each // tab explicitly — the default tab tracks live "worst status" data (real @@ -340,14 +350,14 @@ test.describe("PR CI automation options", () => { reloadedPopover.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).toBeChecked(); await expect( - reloadedPopover.getByRole("switch", { name: "Auto-merge when ready" }), + reloadedPopover.getByRole("switch", { name: "Auto-merge or requeue when ready" }), ).toBeChecked(); await reloadedPopover.getByRole("tab", { name: `${REPO} #${secondPRNumber}` }).click(); await expect( reloadedPopover.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).not.toBeChecked(); await expect( - reloadedPopover.getByRole("switch", { name: "Auto-merge when ready" }), + reloadedPopover.getByRole("switch", { name: "Auto-merge or requeue when ready" }), ).not.toBeChecked(); }); @@ -367,4 +377,174 @@ test.describe("PR CI automation options", () => { "Lifecycle prompt could not be delivered to a task session.", ); }); + + test("desktop merge queue recovery proves repair and new-head requeue", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const queuedHead = "head-queued-desktop"; + const replacementHead = "head-replacement-desktop"; + const taskId = await seedTaskWithPR(apiClient, seedData, "CI merge queue recovery", { + head_sha: queuedHead, + checks_state: "success", + checks_total: 1, + checks_passing: 1, + unresolved_review_threads: 0, + mergeable_state: "clean", + merge_queue_state: "queued", + merge_queue_position: 1, + merge_queue_entry_id: "entry-desktop-a", + merge_queue_entry_head_sha: queuedHead, + }); + await apiClient.mockGitHubSetMergeOutcome(OWNER, REPO, PR_NUMBER, "queued"); + + const session = await openTask(testPage, taskId); + const popover = session.prTopbarPopover(); + await expect(popover.getByText("Merge queue automation")).toBeVisible(); + await expect(popover.getByText("PR #144 is in the merge queue")).toBeVisible(); + await expect(popover.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Active merge queue attempt", + ); + await expect(popover.getByRole("switch")).toHaveCount(2); + + await popover.getByRole("switch", { name: "Auto-fix CI and address comments" }).click(); + await popover.getByRole("switch", { name: "Auto-merge or requeue when ready" }).click(); + await expect + .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .toMatchObject({ + pr_options: expect.arrayContaining([ + expect.objectContaining({ + pr_number: PR_NUMBER, + auto_fix_enabled: true, + auto_merge_enabled: true, + }), + ]), + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(0); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: queuedHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-desktop-a", + merge_queue_last_removed_at: new Date().toISOString(), + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-desktop-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "failure", + html_url: "https://example.test/checks/merge-group-desktop", + }, + ], + }); + + await expect + .poll(async () => { + const options = await apiClient.getTaskCIAutomationOptions(taskId); + const state = options.pr_states?.find((item) => item.pr_number === PR_NUMBER); + return { + round: state?.auto_fix_round_count, + event: state?.last_queue_fix_event_id, + cause: state?.last_queue_removal_cause, + }; + }) + .toEqual({ round: 1, event: "removal-desktop-a", cause: "checks_failed" }); + await expect(popover.getByText("Merge queue recovery")).toBeVisible(); + await expect(popover.getByText("PR #144 was removed: checks failed")).toBeVisible(); + await expect(popover.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Repair requested. Waiting for a new commit before requeue", + ); + + // A clean replacement of the same head is still blocked by the durable + // queue-attempt head guard, so it cannot create a merge request. + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: queuedHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-desktop-a", + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-desktop-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-desktop", + }, + ], + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(0); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: replacementHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-desktop-a", + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-desktop-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-desktop", + }, + ], + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(1); + await expect + .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .toMatchObject({ + pr_states: expect.arrayContaining([ + expect.objectContaining({ last_queue_attempt_head_sha: replacementHead }), + ]), + }); + + // Reflect GitHub accepting the queued merge request. The old removal + // evidence remains durable, but the active entry takes presentation + // precedence and a second merge request is still prohibited. + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: replacementHead, + merge_queue_state: "queued", + merge_queue_position: 1, + merge_queue_entry_id: "entry-desktop-b", + merge_queue_entry_head_sha: replacementHead, + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-desktop", + }, + ], + }); + await expect(popover.getByText("Merge queue automation")).toBeVisible(); + await expect(popover.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Active merge queue attempt", + ); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(1); + }); }); diff --git a/apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts b/apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts index 4e5eda032c..9eb8294260 100644 --- a/apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts +++ b/apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts @@ -16,7 +16,12 @@ function manyRunningChecks(count: number) { })); } -async function seedTaskWithPR(apiClient: ApiClient, seedData: SeedData, title: string) { +async function seedTaskWithPR( + apiClient: ApiClient, + seedData: SeedData, + title: string, + prOverrides: Partial[0]> = {}, +) { await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("test-user"); const task = await apiClient.createTaskWithAgent( @@ -47,6 +52,7 @@ async function seedTaskWithPR(apiClient: ApiClient, seedData: SeedData, title: s checks_state: "failure", checks_total: 2, checks_passing: 1, + ...prOverrides, }); return task.id; } @@ -120,7 +126,9 @@ test.describe("mobile PR CI automation options", () => { await expect( drawer.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).toBeVisible(); - await expect(drawer.getByRole("switch", { name: "Auto-merge when ready" })).toBeVisible(); + await expect( + drawer.getByRole("switch", { name: "Auto-merge or requeue when ready" }), + ).toBeVisible(); const reviewFollowUp = drawer.getByTestId("ci-review-follow-up-trigger"); await expect(reviewFollowUp).toHaveAttribute("aria-expanded", "false"); await expect(reviewFollowUp).toHaveCSS("min-height", "44px"); @@ -274,4 +282,196 @@ test.describe("mobile PR CI automation options", () => { expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); expect(documentMetrics.scrollWidth).toBeLessThanOrEqual(documentMetrics.clientWidth); }); + + test("mobile merge queue recovery proves repair and new-head requeue", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const queuedHead = "head-queued-mobile"; + const replacementHead = "head-replacement-mobile"; + const taskId = await seedTaskWithPR(apiClient, seedData, "CI mobile merge queue recovery", { + head_sha: queuedHead, + checks_state: "success", + checks_total: 1, + checks_passing: 1, + unresolved_review_threads: 0, + mergeable_state: "clean", + merge_queue_state: "queued", + merge_queue_position: 1, + merge_queue_entry_id: "entry-mobile-a", + merge_queue_entry_head_sha: queuedHead, + }); + await apiClient.mockGitHubSetMergeOutcome(OWNER, REPO, PR_NUMBER, "queued"); + await interceptTallPRFeedback(testPage); + + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.tapPRStatusChip(); + const drawer = session.prStatusChipDrawer(); + await expect(drawer.getByText("Merge queue automation")).toBeVisible(); + await expect(drawer.getByText("PR #144 is in the merge queue")).toBeVisible(); + await expect(drawer.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Active merge queue attempt", + ); + await expect(drawer.getByRole("switch")).toHaveCount(2); + await expect( + drawer.getByRole("switch", { name: "Auto-fix CI and address comments" }).locator(".."), + ).toHaveCSS("min-height", "44px"); + await expect( + drawer.getByRole("switch", { name: "Auto-merge or requeue when ready" }).locator(".."), + ).toHaveCSS("min-height", "44px"); + + await drawer.getByRole("switch", { name: "Auto-fix CI and address comments" }).tap(); + await drawer.getByRole("switch", { name: "Auto-merge or requeue when ready" }).tap(); + await expect + .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .toMatchObject({ + pr_options: expect.arrayContaining([ + expect.objectContaining({ + pr_number: PR_NUMBER, + auto_fix_enabled: true, + auto_merge_enabled: true, + }), + ]), + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(0); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: queuedHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-mobile-a", + merge_queue_last_removed_at: new Date().toISOString(), + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-mobile-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "failure", + html_url: "https://example.test/checks/merge-group-mobile", + }, + ], + }); + await expect + .poll(async () => { + const options = await apiClient.getTaskCIAutomationOptions(taskId); + const state = options.pr_states?.find((item) => item.pr_number === PR_NUMBER); + return { + round: state?.auto_fix_round_count, + event: state?.last_queue_fix_event_id, + cause: state?.last_queue_removal_cause, + }; + }) + .toEqual({ round: 1, event: "removal-mobile-a", cause: "checks_failed" }); + await expect(drawer.getByText("Merge queue recovery")).toBeVisible(); + await expect(drawer.getByText("PR #144 was removed: checks failed")).toBeVisible(); + await expect(drawer.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Repair requested. Waiting for a new commit before requeue", + ); + + const scrollBody = drawer.locator("[data-vaul-no-drag]"); + const [drawerBox, scrollMetrics, documentMetrics] = await Promise.all([ + drawer.boundingBox(), + scrollBody.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })), + testPage.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })), + ]); + expect(drawerBox).not.toBeNull(); + expect(drawerBox!.x).toBeGreaterThanOrEqual(0); + expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(documentMetrics.clientWidth); + expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); + expect(documentMetrics.scrollWidth).toBeLessThanOrEqual(documentMetrics.clientWidth); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: queuedHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-mobile-a", + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-mobile-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-mobile", + }, + ], + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(0); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: replacementHead, + merge_queue_state: "", + merge_queue_entry_id: "", + merge_queue_entry_head_sha: "", + merge_queue_last_removal_id: "removal-mobile-a", + merge_queue_last_removal_reason: "checks failed on merge group", + merge_queue_last_removal_before_sha: "merge-group-mobile-a", + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-mobile", + }, + ], + }); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(1); + await expect + .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .toMatchObject({ + pr_states: expect.arrayContaining([ + expect.objectContaining({ last_queue_attempt_head_sha: replacementHead }), + ]), + }); + + await apiClient.mockGitHubTransitionMergeQueue({ + task_id: taskId, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + head_sha: replacementHead, + merge_queue_state: "queued", + merge_queue_position: 1, + merge_queue_entry_id: "entry-mobile-b", + merge_queue_entry_head_sha: replacementHead, + checks: [ + { + name: "merge group checks", + status: "completed", + conclusion: "success", + html_url: "https://example.test/checks/merge-group-mobile", + }, + ], + }); + await expect(drawer.getByText("Merge queue automation")).toBeVisible(); + await expect(drawer.getByTestId("ci-merge-queue-recovery-status")).toContainText( + "Active merge queue attempt", + ); + await expect.poll(() => apiClient.mockGitHubGetMergeAttempts()).toHaveLength(1); + }); }); diff --git a/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts b/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts index 889fbef782..98506558cc 100644 --- a/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts +++ b/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts @@ -172,7 +172,9 @@ test.describe("mobile PR CI chip drawer", () => { await expect( drawer.getByRole("switch", { name: "Auto-fix CI and address comments" }), ).toBeVisible(); - await expect(drawer.getByRole("switch", { name: "Auto-merge when ready" })).toBeVisible(); + await expect( + drawer.getByRole("switch", { name: "Auto-merge or requeue when ready" }), + ).toBeVisible(); await expect(drawer.getByRole("switch", { name: "Your review is requested" })).toBeVisible(); await expect(drawer.getByRole("switch", { name: "PR merged" })).toBeVisible(); await expect(drawer.getByRole("switch", { name: "PR closed without merging" })).toBeVisible(); diff --git a/apps/web/lib/github/ci-automation.test.ts b/apps/web/lib/github/ci-automation.test.ts index 4485064ce9..8b842be599 100644 --- a/apps/web/lib/github/ci-automation.test.ts +++ b/apps/web/lib/github/ci-automation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { autoFixRoundForState, clampAutoFixRound, + deriveCIAutomationQueueState, findCIAutomationStateForPR, findPRAutomationOptionsForPR, normalizeAutoFixMaxRounds, @@ -132,3 +133,95 @@ describe("CI automation helpers", () => { expect(clampAutoFixRound(14, 10)).toBe(10); }); }); + +describe("CI merge queue recovery helpers", () => { + function makeQueuePR(overrides: Partial = {}): TaskPR { + return { ...makePR("", 42), ...overrides }; + } + + it("prioritizes an active queue entry", () => { + const result = deriveCIAutomationQueueState( + makeQueuePR({ + state: "open", + merge_queue_state: "queued", + merge_queue_entry_id: "entry-a", + head_sha: "head-a", + }), + makePRAutomationOptions("repo-1", 42), + undefined, + ); + + expect(result.status).toBe("queued"); + expect(result.context).toBe("queued"); + }); + + it("shows an actionable removal until one repair round is accepted", () => { + const pr = makeQueuePR({ + pr_number: 42, + head_sha: "head-a", + merge_queue_last_removal_id: "removal-a", + }); + const options = makePRAutomationOptions("", 42); + const result = deriveCIAutomationQueueState(pr, options, { + last_queue_removal_cause: "checks_failed", + } as TaskCIPRAutomationState); + + expect(result.status).toBe("removed_actionable"); + expect(result.removalCause).toBe("checks_failed"); + expect(result.repairAccepted).toBe(false); + }); + + it("uses generic copy state for unknown removals", () => { + const result = deriveCIAutomationQueueState( + makeQueuePR({ pr_number: 42, head_sha: "head-a", merge_queue_last_removal_id: "removal-a" }), + makePRAutomationOptions("", 42), + { last_queue_removal_cause: "provider_changed" } as TaskCIPRAutomationState, + ); + + expect(result.status).toBe("removed_not_actionable"); + expect(result.removalCause).toBe("unknown"); + }); + + it("classifies the persisted removal reason when CI state is absent", () => { + const result = deriveCIAutomationQueueState( + makeQueuePR({ + pr_number: 42, + head_sha: "head-a", + merge_queue_last_removal_id: "removal-a", + merge_queue_last_removal_reason: "CI checks failed on merge group", + }), + makePRAutomationOptions("", 42), + undefined, + ); + + expect(result.status).toBe("removed_actionable"); + expect(result.removalCause).toBe("checks_failed"); + }); + + it("distinguishes accepted repair, same-head wait, and pending new-head checks", () => { + const pr = makeQueuePR({ + pr_number: 42, + head_sha: "head-a", + merge_queue_last_removal_id: "removal-a", + }); + const options = makePRAutomationOptions("", 42, { auto_merge_enabled: true }); + const accepted = deriveCIAutomationQueueState(pr, options, { + last_queue_attempt_head_sha: "head-a", + last_queue_fix_event_id: "removal-a", + last_queue_removal_cause: "checks_failed", + } as TaskCIPRAutomationState); + + expect(accepted.status).toBe("repair_requested"); + expect(accepted.waitingForCommit).toBe(true); + + const pending = deriveCIAutomationQueueState( + { ...pr, head_sha: "head-b", checks_state: "pending" }, + options, + { + last_queue_attempt_head_sha: "head-a", + last_queue_removal_cause: "checks_failed", + } as TaskCIPRAutomationState, + ); + expect(pending.status).toBe("waiting_for_checks"); + }); +}); diff --git a/apps/web/lib/github/ci-automation.ts b/apps/web/lib/github/ci-automation.ts index ff0676168d..5e1c4be2c6 100644 --- a/apps/web/lib/github/ci-automation.ts +++ b/apps/web/lib/github/ci-automation.ts @@ -1,4 +1,9 @@ -import type { TaskCIPRAutomationState, TaskPR, TaskPRAutomationOptions } from "@/lib/types/github"; +import type { + CIAutomationQueueRemovalCause, + TaskCIPRAutomationState, + TaskPR, + TaskPRAutomationOptions, +} from "@/lib/types/github"; const DEFAULT_AUTO_FIX_MAX_ROUNDS = 10; @@ -13,6 +18,220 @@ export const DISABLED_PR_AUTOMATION_OPTIONS: Omit< prompt_on_closed: false, }; +export type CIAutomationQueueStatus = + | "none" + | "queued" + | "removed_actionable" + | "removed_not_actionable" + | "repair_requested" + | "waiting_for_commit" + | "waiting_for_checks"; + +export type CIAutomationQueueRecoveryState = { + context: "normal" | "queued" | "recovery"; + status: CIAutomationQueueStatus; + removalCause: CIAutomationQueueRemovalCause; + repairAccepted: boolean; + waitingForCommit: boolean; +}; + +const ACTIONABLE_QUEUE_REMOVAL_CAUSES: CIAutomationQueueRemovalCause[] = [ + "checks_failed", + "checks_timed_out", + "conflict", +]; + +function normalizedQueueRemovalReason(value: string | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +const QUEUE_REMOVAL_CAUSE_ALIASES: Record = { + checks_failed: "checks_failed", + checks_timed_out: "checks_timed_out", + conflict: "conflict", + manual: "manual", + branch_protection: "branch_protection", + check_failed: "checks_failed", + checks_failure: "checks_failed", + ci_checks_failed: "checks_failed", + ci_check_failed: "checks_failed", + checks_failed_on_merge_group: "checks_failed", + checks_failed_on_merge_queue: "checks_failed", + ci_checks_failed_on_merge_group: "checks_failed", + ci_checks_failed_on_merge_queue: "checks_failed", + check_timed_out: "checks_timed_out", + checks_timeout: "checks_timed_out", + timeout: "checks_timed_out", + timed_out: "checks_timed_out", + checks_timed_out_on_merge_group: "checks_timed_out", + merge_conflict: "conflict", + merge_conflicts: "conflict", + unmergeable: "conflict", + removed_manually: "manual", + user_removed: "manual", + branch_protection_failed: "branch_protection", + required_branch_protection: "branch_protection", +}; + +function normalizeQueueRemovalCause( + value: string | undefined, + pr?: TaskPR, +): CIAutomationQueueRemovalCause { + const normalized = normalizedQueueRemovalReason(value); + const knownCause = QUEUE_REMOVAL_CAUSE_ALIASES[normalized]; + if (knownCause) { + return knownCause; + } + if (pr) { + const mergeableState = pr.mergeable_state?.trim().toLowerCase() ?? ""; + const queueState = pr.merge_queue_state?.trim().toLowerCase() ?? ""; + if ( + mergeableState === "dirty" || + mergeableState.includes("conflict") || + queueState === "unmergeable" || + queueState.includes("unmergeable") + ) { + return "conflict"; + } + } + return "unknown"; +} + +function hasActiveQueueEntry(pr: TaskPR): boolean { + return pr.state === "open" && Boolean(pr.merge_queue_state?.trim()); +} + +function isReadyForMerge(pr: TaskPR): boolean { + if (pr.state !== "open" || pr.checks_state !== "success" || pr.mergeable_state !== "clean") { + return false; + } + if ( + pr.review_state === "changes_requested" || + pr.pending_review_count > 0 || + pr.unresolved_review_threads > 0 + ) { + return false; + } + return pr.required_reviews == null || pr.review_count >= pr.required_reviews; +} + +function queueStateForRemoval( + pr: TaskPR, + options: Pick, + state: TaskCIPRAutomationState | undefined, + removalID: string, +): CIAutomationQueueRecoveryState { + const stateCause = normalizeQueueRemovalCause(state?.last_queue_removal_cause); + const removalCause = + stateCause !== "unknown" + ? stateCause + : normalizeQueueRemovalCause(pr.merge_queue_last_removal_reason, pr); + const actionable = ACTIONABLE_QUEUE_REMOVAL_CAUSES.includes(removalCause); + const headContext = queueHeadContext(pr, state, removalID); + + if (headContext.repairAccepted) { + return { + context: "recovery", + status: "repair_requested", + removalCause, + repairAccepted: true, + waitingForCommit: options.auto_merge_enabled, + }; + } + if (options.auto_merge_enabled && headContext.sameHead) { + return { + context: "recovery", + status: "waiting_for_commit", + removalCause, + repairAccepted: false, + waitingForCommit: true, + }; + } + if (shouldWaitForNewHead(pr, options, headContext)) { + return { + context: "recovery", + status: "waiting_for_checks", + removalCause, + repairAccepted: false, + waitingForCommit: false, + }; + } + return { + context: "recovery", + status: actionable ? "removed_actionable" : "removed_not_actionable", + removalCause, + repairAccepted: false, + waitingForCommit: false, + }; +} + +function queueHeadContext( + pr: TaskPR, + state: TaskCIPRAutomationState | undefined, + removalID: string, +): { + currentHead: string; + lastAttemptHead: string; + sameHead: boolean; + repairAccepted: boolean; +} { + const currentHead = pr.head_sha?.trim() ?? ""; + const lastAttemptHead = state?.last_queue_attempt_head_sha?.trim() ?? ""; + const sameHead = Boolean(currentHead && lastAttemptHead && currentHead === lastAttemptHead); + return { + currentHead, + lastAttemptHead, + sameHead, + repairAccepted: state?.last_queue_fix_event_id === removalID && sameHead, + }; +} + +function shouldWaitForNewHead( + pr: TaskPR, + options: Pick, + headContext: ReturnType, +): boolean { + return Boolean( + options.auto_merge_enabled && + headContext.lastAttemptHead && + headContext.currentHead && + !headContext.sameHead && + !isReadyForMerge(pr), + ); +} + +export function deriveCIAutomationQueueState( + pr: TaskPR, + options: Pick, + state: TaskCIPRAutomationState | undefined, +): CIAutomationQueueRecoveryState { + if (hasActiveQueueEntry(pr)) { + return { + context: "queued", + status: "queued", + removalCause: "unknown", + repairAccepted: false, + waitingForCommit: false, + }; + } + + const removalID = pr.merge_queue_last_removal_id?.trim() ?? ""; + if (!removalID) { + return { + context: "normal", + status: "none", + removalCause: "unknown", + repairAccepted: false, + waitingForCommit: false, + }; + } + return queueStateForRemoval(pr, options, state, removalID); +} + /** * Selects the given PR's own automation switches out of the task-scoped * pr_options array. Falls back to all-off defaults when the PR has no diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index 0728474769..b7598e2718 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -264,12 +264,20 @@ export type TaskPR = { closed_at: string | null; last_synced_at: string | null; updated_at: string; + /** Current pull-request head used to explain safe queue recovery. */ + head_sha?: string; /** Empty when GitHub did not return an active merge-queue entry. */ merge_queue_state?: MergeQueueState; + merge_queue_entry_id?: string; + merge_queue_entry_head_sha?: string; /** GitHub's one-based queue position, when available. */ merge_queue_position?: number | null; /** GitHub's estimated time to merge in seconds, when available. */ merge_queue_estimated_time_to_merge_seconds?: number | null; + merge_queue_last_removal_id?: string; + merge_queue_last_removed_at?: string | null; + merge_queue_last_removal_reason?: string; + merge_queue_last_removal_before_sha?: string; // The five PR-outcome-attribution fields below are always present on a // real API/WS payload (the backend sends every key, never omits one) — the // `?:` here follows this file's existing convention for nullable fields @@ -301,6 +309,14 @@ export type TaskPRDeletedEvent = { association_id: string; }; +export type CIAutomationQueueRemovalCause = + | "checks_failed" + | "checks_timed_out" + | "conflict" + | "manual" + | "branch_protection" + | "unknown"; + export type TaskCIPRAutomationState = { task_id: string; repository_id: string; @@ -313,6 +329,9 @@ export type TaskCIPRAutomationState = { auto_fix_exhausted_at: string | null; last_merge_signature: string; last_merge_attempt_at: string | null; + last_queue_attempt_head_sha?: string; + last_queue_fix_event_id?: string; + last_queue_removal_cause?: CIAutomationQueueRemovalCause | string; review_request_initialized?: boolean; last_review_requested?: boolean; last_observed_pr_state?: string; diff --git a/apps/web/src/locales/en/github.json b/apps/web/src/locales/en/github.json index 738df85c5a..f5256f6f6f 100644 --- a/apps/web/src/locales/en/github.json +++ b/apps/web/src/locales/en/github.json @@ -44,7 +44,7 @@ "autoFixExhaustedAria": "auto-fix paused after reaching {{current}} of {{max}} rounds", "autoFixCiAndAddressComments": "Auto-fix CI and address comments", "autoFixPrompt": "Auto-fix prompt", - "autoFixPromptDescription": "This prompt is used only for this task. Leave it blank to use the default prompt. Add <0>{{placeholder}} when you want Kandev to include its PR feedback snapshot.", + "autoFixPromptDescription": "This prompt is used only for this task. Leave it blank to use the default prompt. Add <0>{{placeholder}} when you want Kandev to include PR feedback, queue-removal context, and available failed merge-group checks.", "autoFixRoundExplanation": "Auto-fix has used {{current}} of {{max}} rounds for this PR. A round is counted when 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 {{max}}/{{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.", "autoMerge": "Auto-merge", "autoMergeEnabledAria": "auto-merge enabled", @@ -503,7 +503,7 @@ "theAppIsReadyToSelect": "The App is ready to select and install for this workspace.", "theExistingWorkspaceConnectionWasNot": "The existing workspace connection was not changed.", "theGithubResponseCouldNotBe": "The GitHub response could not be verified. Review the connection and try again.", - "thePlaceholderInsertsTheCurrentPr": "The placeholder inserts the current PR identifier, new or changed failing checks with GitHub job links, and new or changed review comments with file, line, and body text.", + "thePlaceholderInsertsTheCurrentPr": "The placeholder inserts the current PR identifier, queue-removal context, new or changed failing checks only when an exact merge-group commit is available, and new or changed review comments.", "thePullRequestIsStillLinked": "The pull request is still linked.", "theRepositoryWillBeAutomaticallyCloned": "The repository will be automatically cloned to {{path}} if it is not already present in the workspace.", "theSettingsCouldNotBeSaved": "The settings could not be saved", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "Queued", "mergeQueueStateUnmergeable": "Unmergeable", "mergeQueueStatus": "Merge queue: {{state}}", - "mergeQueueSubMinuteEstimate": "Estimated merge time: less than a minute." + "mergeQueueSubMinuteEstimate": "Estimated merge time: less than a minute.", + "autoMergeOrRequeueWhenReady": "Auto-merge or requeue when ready", + "autoFixQueueSupportQueued": "Will repair failed queue checks or conflicts.", + "autoFixQueueSupportEnable": "Enable to send this actionable queue failure to the agent.", + "autoFixQueueSupportRequested": "Repair requested for this removal.", + "autoFixQueueSupportNotActionable": "No automatic repair for this removal.", + "autoFixQueueSupportNewHead": "The fix created a new commit.", + "autoMergeQueueSupportQueued": "Current queue attempt is already active.", + "autoMergeQueueSupportDisabled": "Automatic requeue is off.", + "autoMergeQueueSupportWaitingForCommit": "Waiting for a new commit before requeue.", + "autoMergeQueueSupportWaitingForChecks": "Waiting for required checks.", + "autoMergeQueueSupportNewHead": "Will requeue after a new commit passes checks.", + "automationForPrQueued": "PR #{{number}} is in the merge queue", + "automationForPrRemoved": "PR #{{number}} was removed: {{cause}}", + "automationForPrRemovedGeneric": "PR #{{number}} was removed from the merge queue", + "ciAutomationQueueRecoveryHelp": "Auto-fix repairs actionable queue removals. Auto-merge submits an eligible head or requeues it after a new commit. Both controls form the repair and requeue loop. Kandev never requeues the same head after removal.", + "mergeQueueAutomation": "Merge queue automation", + "mergeQueueRecovery": "Merge queue recovery", + "mergeQueueRemovalCauseChecksFailed": "checks failed", + "mergeQueueRemovalCauseChecksTimedOut": "checks timed out", + "mergeQueueRemovalCauseConflict": "merge conflict", + "mergeQueueRecoveryStatusQueued": "Active merge queue attempt. Auto-merge will not submit a duplicate.", + "mergeQueueRecoveryStatusActionable": "Queue removal needs attention. Enable auto-fix to send it to the agent.", + "mergeQueueRecoveryStatusNotActionable": "Queue removal recorded. No automatic repair for this removal.", + "mergeQueueRecoveryStatusRepairRequested": "Repair requested for this removal.", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "Repair requested. Waiting for a new commit before requeue.", + "mergeQueueRecoveryStatusWaitingForCommit": "Waiting for a new commit before requeue.", + "mergeQueueRecoveryStatusWaitingForChecks": "New head detected. Waiting for required checks." } diff --git a/apps/web/src/locales/pseudo/github.json b/apps/web/src/locales/pseudo/github.json index ab08015bc7..37727ebba8 100644 --- a/apps/web/src/locales/pseudo/github.json +++ b/apps/web/src/locales/pseudo/github.json @@ -44,7 +44,7 @@ "autoFixExhaustedAria": "àũţō-ƒĩx ƥàũśēď àƒţēŕ ŕēàćĥĩńĝ {{current}} ōƒ {{max}} ŕōũńďś", "autoFixCiAndAddressComments": "Àũţō-ƒĩx ĆĨ àńď àďďŕēśś ćōḿḿēńţś", "autoFixPrompt": "Àũţō-ƒĩx ƥŕōḿƥţ", - "autoFixPromptDescription": "Ţĥĩś ƥŕōḿƥţ ĩś ũśēď ōńĺŷ ƒōŕ ţĥĩś ţàśķ. Ĺēàvē ĩţ ƀĺàńķ ţō ũśē ţĥē ďēƒàũĺţ ƥŕōḿƥţ. Àďď <0>{{placeholder}} ŵĥēń ŷōũ ŵàńţ Ķàńďēv ţō ĩńćĺũďē ĩţś ƤŔ ƒēēďƀàćķ śńàƥśĥōţ.", + "autoFixPromptDescription": "Ţĥĩś ƥŕōḿƥţ ĩś ũśēď ōńĺŷ ƒōŕ ţĥĩś ţàśķ. Ĺēàvē ĩţ ƀĺàńķ ţō ũśē ţĥē ďēƒàũĺţ ƥŕōḿƥţ. Àďď <0>{{placeholder}} ŵĥēń ŷōũ ŵàńţ Ķàńďēv ţō ĩńćĺũďē ƤŔ ƒēēďƀàćķ, qũēũē-ŕēḿōvàĺ ćōńţēxţ, àńď àvàĩĺàƀĺē ƒàĩĺēď ḿēŕĝē-ĝŕōũƥ ćĥēćķś.", "autoFixRoundExplanation": "Àũţō-ƒĩx ĥàś ũśēď {{current}} ōƒ {{max}} ŕōũńďś ƒōŕ ţĥĩś ƤŔ. À ŕōũńď ĩś ćōũńţēď ŵĥēń Ķàńďēv śēńďś ōŕ qũēũēś à ĆĨ àũţō-ƒĩx ḿēśśàĝē. Ķàńďēv ŵàĩţś ƒōŕ àĺĺ ƤŔ ćĥēćķś ţō ƒĩńĩśĥ ƀēƒōŕē śţàŕţĩńĝ à ńēŵ ĆĨ àũţō-ƒĩx ţũŕń, śō ţĥē àĝēńţ ĝēţś ţĥē ƒĩńàĺ ƒàĩĺēď ćĥēćķś àńď ćũŕŕēńţ ćōḿḿēńţś ţōĝēţĥēŕ. Ũƥďàţĩńĝ àń àĺŕēàďŷ qũēũēď àũţō-ƒĩx ḿēśśàĝē ďōēś ńōţ ũśē àńōţĥēŕ ŕōũńď. Ŵĥēń ţĥĩś ĩś àţ {{max}}/{{max}} àńď ţĥēŕē ĩś ńō ƥēńďĩńĝ àũţō-ƒĩx ḿēśśàĝē ĺēƒţ ţō ũƥďàţē, Ķàńďēv ƥàũśēś àũţō-ƒĩx ƒōŕ ţĥĩś ƤŔ śō ĩţ ćàńńōţ ĺōōƥ ƒōŕēvēŕ. Ďĩśàƀĺē àńď ŕē-ēńàƀĺē àũţō-ƒĩx àƒţēŕ ḿàńũàĺ ŕēvĩēŵ ţō śţàŕţ ōvēŕ.", "autoMerge": "Àũţō-ḿēŕĝē", "autoMergeEnabledAria": "àũţō-ḿēŕĝē ēńàƀĺēď", @@ -503,7 +503,7 @@ "theAppIsReadyToSelect": "Ţĥē Àƥƥ ĩś ŕēàďŷ ţō śēĺēćţ àńď ĩńśţàĺĺ ƒōŕ ţĥĩś ŵōŕķśƥàćē.", "theExistingWorkspaceConnectionWasNot": "Ţĥē ēxĩśţĩńĝ ŵōŕķśƥàćē ćōńńēćţĩōń ŵàś ńōţ ćĥàńĝēď.", "theGithubResponseCouldNotBe": "Ţĥē ĜĩţĤũƀ ŕēśƥōńśē ćōũĺď ńōţ ƀē vēŕĩƒĩēď. Ŕēvĩēŵ ţĥē ćōńńēćţĩōń àńď ţŕŷ àĝàĩń.", - "thePlaceholderInsertsTheCurrentPr": "Ţĥē ƥĺàćēĥōĺďēŕ ĩńśēŕţś ţĥē ćũŕŕēńţ ƤŔ ĩďēńţĩƒĩēŕ, ńēŵ ōŕ ćĥàńĝēď ƒàĩĺĩńĝ ćĥēćķś ŵĩţĥ ĜĩţĤũƀ ĵōƀ ĺĩńķś, àńď ńēŵ ōŕ ćĥàńĝēď ŕēvĩēŵ ćōḿḿēńţś ŵĩţĥ ƒĩĺē, ĺĩńē, àńď ƀōďŷ ţēxţ.", + "thePlaceholderInsertsTheCurrentPr": "Ţĥē ƥĺàćēĥōĺďēŕ ĩńśēŕţś ţĥē ćũŕŕēńţ ƤŔ ĩďēńţĩƒĩēŕ, qũēũē-ŕēḿōvàĺ ćōńţēxţ, ńēŵ ōŕ ćĥàńĝēď ƒàĩĺĩńĝ ćĥēćķś ōńĺŷ ŵĥēń àń ēxàćţ ḿēŕĝē-ĝŕōũƥ ćōḿḿĩţ ĩś àvàĩĺàƀĺē, àńď ńēŵ ōŕ ćĥàńĝēď ŕēvĩēŵ ćōḿḿēńţś.", "thePullRequestIsStillLinked": "Ţĥē ƥũĺĺ ŕēqũēśţ ĩś śţĩĺĺ ĺĩńķēď.", "theRepositoryWillBeAutomaticallyCloned": "Ţĥē ŕēƥōśĩţōŕŷ ŵĩĺĺ ƀē àũţōḿàţĩćàĺĺŷ ćĺōńēď ţō {{path}} ĩƒ ĩţ ĩś ńōţ àĺŕēàďŷ ƥŕēśēńţ ĩń ţĥē ŵōŕķśƥàćē.", "theSettingsCouldNotBeSaved": "Ţĥē śēţţĩńĝś ćōũĺď ńōţ ƀē śàvēď", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "Qũēũēď", "mergeQueueStateUnmergeable": "Ũńḿēŕĝēàƀĺē", "mergeQueueStatus": "Ḿēŕĝē qũēũē: {{state}}", - "mergeQueueSubMinuteEstimate": "Ēśţĩḿàţēď ḿēŕĝē ţĩḿē: ĺēśś ţĥàń à ḿĩńũţē." + "mergeQueueSubMinuteEstimate": "Ēśţĩḿàţēď ḿēŕĝē ţĩḿē: ĺēśś ţĥàń à ḿĩńũţē.", + "autoMergeOrRequeueWhenReady": "Àũţō-ḿēŕĝē ōŕ ŕēqũēũē ŵĥēń ŕēàďŷ", + "autoFixQueueSupportQueued": "Ŵĩĺĺ ŕēƥàĩŕ ƒàĩĺēď qũēũē ćĥēćķś ōŕ ćōńƒĺĩćţś.", + "autoFixQueueSupportEnable": "Ēńàƀĺē ţō śēńď ţĥĩś àćţĩōńàƀĺē qũēũē ƒàĩĺũŕē ţō ţĥē àĝēńţ.", + "autoFixQueueSupportRequested": "Ŕēƥàĩŕ ŕēqũēśţēď ƒōŕ ţĥĩś ŕēḿōvàĺ.", + "autoFixQueueSupportNotActionable": "Ńō àũţōḿàţĩć ŕēƥàĩŕ ƒōŕ ţĥĩś ŕēḿōvàĺ.", + "autoFixQueueSupportNewHead": "Ţĥē ƒĩx ćŕēàţēď à ńēŵ ćōḿḿĩţ.", + "autoMergeQueueSupportQueued": "Ćũŕŕēńţ qũēũē àţţēḿƥţ ĩś àĺŕēàďŷ àćţĩvē.", + "autoMergeQueueSupportDisabled": "Àũţōḿàţĩć ŕēqũēũē ĩś ōƒƒ.", + "autoMergeQueueSupportWaitingForCommit": "Ŵàĩţĩńĝ ƒōŕ à ńēŵ ćōḿḿĩţ ƀēƒōŕē ŕēqũēũē.", + "autoMergeQueueSupportWaitingForChecks": "Ŵàĩţĩńĝ ƒōŕ ŕēqũĩŕēď ćĥēćķś.", + "autoMergeQueueSupportNewHead": "Ŵĩĺĺ ŕēqũēũē àƒţēŕ à ńēŵ ćōḿḿĩţ ƥàśśēś ćĥēćķś.", + "automationForPrQueued": "ƤŔ #{{number}} ĩś ĩń ţĥē ḿēŕĝē qũēũē", + "automationForPrRemoved": "ƤŔ #{{number}} ŵàś ŕēḿōvēď: {{cause}}", + "automationForPrRemovedGeneric": "ƤŔ #{{number}} ŵàś ŕēḿōvēď ƒŕōḿ ţĥē ḿēŕĝē qũēũē", + "ciAutomationQueueRecoveryHelp": "Àũţō-ƒĩx ŕēƥàĩŕś àćţĩōńàƀĺē qũēũē ŕēḿōvàĺś. Àũţō-ḿēŕĝē śũƀḿĩţś àń ēĺĩĝĩƀĺē ĥēàď ōŕ ŕēqũēũēś ĩţ àƒţēŕ à ńēŵ ćōḿḿĩţ. Ɓōţĥ ćōńţŕōĺś ƒōŕḿ ţĥē ŕēƥàĩŕ àńď ŕēqũēũē ĺōōƥ. Ķàńďēv ńēvēŕ ŕēqũēũēś ţĥē śàḿē ĥēàď àƒţēŕ ŕēḿōvàĺ.", + "mergeQueueAutomation": "Ḿēŕĝē qũēũē àũţōḿàţĩōń", + "mergeQueueRecovery": "Ḿēŕĝē qũēũē ŕēćōvēŕŷ", + "mergeQueueRemovalCauseChecksFailed": "ćĥēćķś ƒàĩĺēď", + "mergeQueueRemovalCauseChecksTimedOut": "ćĥēćķś ţĩḿēď ōũţ", + "mergeQueueRemovalCauseConflict": "ḿēŕĝē ćōńƒĺĩćţ", + "mergeQueueRecoveryStatusQueued": "Àćţĩvē ḿēŕĝē qũēũē àţţēḿƥţ. Àũţō-ḿēŕĝē ŵĩĺĺ ńōţ śũƀḿĩţ à ďũƥĺĩćàţē.", + "mergeQueueRecoveryStatusActionable": "Qũēũē ŕēḿōvàĺ ńēēďś àţţēńţĩōń. Ēńàƀĺē àũţō-ƒĩx ţō śēńď ĩţ ţō ţĥē àĝēńţ.", + "mergeQueueRecoveryStatusNotActionable": "Qũēũē ŕēḿōvàĺ ŕēćōŕďēď. Ńō àũţōḿàţĩć ŕēƥàĩŕ ƒōŕ ţĥĩś ŕēḿōvàĺ.", + "mergeQueueRecoveryStatusRepairRequested": "Ŕēƥàĩŕ ŕēqũēśţēď ƒōŕ ţĥĩś ŕēḿōvàĺ.", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "Ŕēƥàĩŕ ŕēqũēśţēď. Ŵàĩţĩńĝ ƒōŕ à ńēŵ ćōḿḿĩţ ƀēƒōŕē ŕēqũēũē.", + "mergeQueueRecoveryStatusWaitingForCommit": "Ŵàĩţĩńĝ ƒōŕ à ńēŵ ćōḿḿĩţ ƀēƒōŕē ŕēqũēũē.", + "mergeQueueRecoveryStatusWaitingForChecks": "Ńēŵ ĥēàď ďēţēćţēď. Ŵàĩţĩńĝ ƒōŕ ŕēqũĩŕēď ćĥēćķś." } diff --git a/apps/web/src/locales/pt-pt/github.json b/apps/web/src/locales/pt-pt/github.json index 12268994c4..bea3541222 100644 --- a/apps/web/src/locales/pt-pt/github.json +++ b/apps/web/src/locales/pt-pt/github.json @@ -42,7 +42,7 @@ "autoFixExhaustedAria": "correção automática pausada após atingir {{current}} de {{max}} rondas", "autoFixCiAndAddressComments": "Corrigir automaticamente o CI e responder aos comentários", "autoFixPrompt": "Prompt da correção automática", - "autoFixPromptDescription": "Este prompt é usado apenas nesta tarefa. Deixe-o em branco para usar o prompt pré-definido. Adicione <0>{{placeholder}} quando quiser que o Kandev inclua o seu instantâneo do feedback do PR.", + "autoFixPromptDescription": "Este prompt é usado apenas nesta tarefa. Deixe-o em branco para usar o prompt pré-definido. Adicione <0>{{placeholder}} quando quiser que o Kandev inclua feedback do PR, contexto de remoção da fila e verificações falhadas de merge-group disponíveis.", "autoFixRoundExplanation": "A correção automática já usou {{current}} de {{max}} rondas neste PR. Uma ronda é contada quando o Kandev envia ou coloca em fila uma mensagem de correção automática do CI. O Kandev espera que todas as verificações do PR terminem antes de iniciar um novo turno de correção automática do CI, para que o agente receba em conjunto as verificações falhadas finais e os comentários atuais. Atualizar uma mensagem de correção automática já em fila não consome outra ronda. Quando isto estiver em {{max}}/{{max}} e não restar nenhuma mensagem de correção automática pendente para atualizar, o Kandev suspende a correção automática neste PR para que não entre num ciclo infinito. Desative e reative a correção automática após revisão manual para recomeçar.", "autoMerge": "Merge automático", "autoMergeWhenReady": "Fazer merge automático quando estiver pronto", @@ -484,7 +484,7 @@ "theAppIsReadyToSelect": "A App está pronta a ser selecionada e instalada nesta área de trabalho.", "theExistingWorkspaceConnectionWasNot": "A ligação existente da área de trabalho não foi alterada.", "theGithubResponseCouldNotBe": "Não foi possível verificar a resposta do GitHub. Reveja a ligação e tente novamente.", - "thePlaceholderInsertsTheCurrentPr": "O marcador insere o identificador atual do PR, as verificações falhadas novas ou alteradas com ligações para os jobs do GitHub, e os comentários de revisão novos ou alterados com ficheiro, linha e texto.", + "thePlaceholderInsertsTheCurrentPr": "O marcador insere o identificador atual do PR, o contexto de remoção da fila, novos comentários de revisão e verificações falhadas do merge-group apenas quando existe um commit exato.", "thePullRequestIsStillLinked": "O pull request continua associado.", "theRepositoryWillBeAutomaticallyCloned": "O repositório será clonado automaticamente para {{path}} se ainda não estiver presente na área de trabalho.", "theSettingsCouldNotBeSaved": "Não foi possível guardar as definições", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "Em fila", "mergeQueueStateUnmergeable": "Não integrável", "mergeQueueStatus": "Fila de merge: {{state}}", - "mergeQueueSubMinuteEstimate": "Tempo estimado até ao merge: menos de um minuto." + "mergeQueueSubMinuteEstimate": "Tempo estimado até ao merge: menos de um minuto.", + "autoMergeOrRequeueWhenReady": "Fazer merge ou voltar a colocar em fila quando estiver pronto", + "autoFixQueueSupportQueued": "Corrige verificações falhadas ou conflitos da fila.", + "autoFixQueueSupportEnable": "Ative para enviar esta falha acionável da fila ao agente.", + "autoFixQueueSupportRequested": "Correção pedida para esta remoção.", + "autoFixQueueSupportNotActionable": "Não há correção automática para esta remoção.", + "autoFixQueueSupportNewHead": "A correção criou um novo commit.", + "autoMergeQueueSupportQueued": "A tentativa atual na fila já está ativa.", + "autoMergeQueueSupportDisabled": "A recolocação automática em fila está desligada.", + "autoMergeQueueSupportWaitingForCommit": "A aguardar um novo commit antes de voltar à fila.", + "autoMergeQueueSupportWaitingForChecks": "A aguardar verificações obrigatórias.", + "autoMergeQueueSupportNewHead": "Volta à fila depois de um novo commit passar as verificações.", + "automationForPrQueued": "O PR #{{number}} está na fila de merge", + "automationForPrRemoved": "O PR #{{number}} foi removido: {{cause}}", + "automationForPrRemovedGeneric": "O PR #{{number}} foi removido da fila de merge", + "ciAutomationQueueRecoveryHelp": "A correção automática repara remoções acionáveis da fila. O merge automático envia um head elegível ou volta a colocá-lo na fila depois de um novo commit. As duas opções formam o ciclo de reparação e fila. O Kandev nunca volta a colocar o mesmo head na fila depois de uma remoção.", + "mergeQueueAutomation": "Automatização da fila de merge", + "mergeQueueRecovery": "Recuperação da fila de merge", + "mergeQueueRemovalCauseChecksFailed": "as verificações falharam", + "mergeQueueRemovalCauseChecksTimedOut": "as verificações excederam o tempo", + "mergeQueueRemovalCauseConflict": "conflito de merge", + "mergeQueueRecoveryStatusQueued": "Tentativa ativa na fila de merge. O merge automático não envia uma duplicada.", + "mergeQueueRecoveryStatusActionable": "A remoção da fila precisa de atenção. Ative a correção automática para a enviar ao agente.", + "mergeQueueRecoveryStatusNotActionable": "Remoção da fila registada. Não há correção automática para esta remoção.", + "mergeQueueRecoveryStatusRepairRequested": "Correção pedida para esta remoção.", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "Correção pedida. A aguardar um novo commit antes de voltar à fila.", + "mergeQueueRecoveryStatusWaitingForCommit": "A aguardar um novo commit antes de voltar à fila.", + "mergeQueueRecoveryStatusWaitingForChecks": "Novo head detetado. A aguardar verificações obrigatórias." } diff --git a/apps/web/src/locales/zh-cn/github.json b/apps/web/src/locales/zh-cn/github.json index a0d7912c87..ff1f1ccafa 100644 --- a/apps/web/src/locales/zh-cn/github.json +++ b/apps/web/src/locales/zh-cn/github.json @@ -44,7 +44,7 @@ "autoFixExhaustedAria": "达到 {{current}} / {{max}} 轮后,自动修复已暂停", "autoFixCiAndAddressComments": "自动修复 CI 并处理评论", "autoFixPrompt": "自动修复提示词", - "autoFixPromptDescription": "此提示词仅用于本任务。留空则使用默认提示词。若希望 Kandev 附上 PR 反馈快照,请添加 <0>{{placeholder}}。", + "autoFixPromptDescription": "此提示词仅用于本任务。留空则使用默认提示词。若希望 Kandev 附上 PR 反馈、队列移除上下文和可用的合并组失败检查,请添加 <0>{{placeholder}}。", "autoFixRoundExplanation": "自动修复已为该 PR 使用 {{current}} / {{max}} 轮。Kandev 每次发送或排队一条 CI 自动修复消息即计为一轮。Kandev 会等待全部 PR 检查完成后再开始新一轮自动修复,以便智能体同时获得最终的失败检查与当前评论。更新已排队的自动修复消息不会额外消耗轮次。当已达 {{max}}/{{max}} 且没有待更新的自动修复消息时,Kandev 会暂停该 PR 的自动修复,避免无限循环。人工审阅后,请关闭并重新启用自动修复以重新开始。", "autoMerge": "自动合并", "autoMergeEnabledAria": "自动合并已启用", @@ -503,7 +503,7 @@ "theAppIsReadyToSelect": "该应用已可供此工作区选择和安装。", "theExistingWorkspaceConnectionWasNot": "现有工作区连接未更改。", "theGithubResponseCouldNotBe": "无法验证 GitHub 响应。请检查连接后重试。", - "thePlaceholderInsertsTheCurrentPr": "该占位符会插入当前 PR 标识、新增或变更的失败检查(含 GitHub 作业链接),以及新增或变更的审阅评论(含文件、行号和正文)。", + "thePlaceholderInsertsTheCurrentPr": "该占位符会插入当前 PR 标识、队列移除上下文、新增或变更的审阅评论,以及仅在存在准确合并组提交时提供的失败合并组检查。", "thePullRequestIsStillLinked": "拉取请求仍保持关联。", "theRepositoryWillBeAutomaticallyCloned": "如果工作区中尚无此仓库,将自动克隆到 {{path}}。", "theSettingsCouldNotBeSaved": "无法保存设置", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "排队中", "mergeQueueStateUnmergeable": "不可合并", "mergeQueueStatus": "合并队列:{{state}}", - "mergeQueueSubMinuteEstimate": "预计合并时间:少于一分钟" + "mergeQueueSubMinuteEstimate": "预计合并时间:少于一分钟", + "autoMergeOrRequeueWhenReady": "就绪时自动合并或重新排队", + "autoFixQueueSupportQueued": "将修复队列检查失败或冲突。", + "autoFixQueueSupportEnable": "启用后将此可处理的队列失败发送给智能体。", + "autoFixQueueSupportRequested": "已请求修复此移除事件。", + "autoFixQueueSupportNotActionable": "不会自动修复此移除事件。", + "autoFixQueueSupportNewHead": "修复创建了新的提交。", + "autoMergeQueueSupportQueued": "当前队列尝试已经处于活动状态。", + "autoMergeQueueSupportDisabled": "自动重新排队已关闭。", + "autoMergeQueueSupportWaitingForCommit": "等待新提交后再重新排队。", + "autoMergeQueueSupportWaitingForChecks": "等待必需的检查。", + "autoMergeQueueSupportNewHead": "新提交通过检查后将重新排队。", + "automationForPrQueued": "PR #{{number}} 已在合并队列中", + "automationForPrRemoved": "PR #{{number}} 已被移除:{{cause}}", + "automationForPrRemovedGeneric": "PR #{{number}} 已从合并队列中移除", + "ciAutomationQueueRecoveryHelp": "自动修复会处理可操作的队列移除。自动合并会提交符合条件的 head,或在新提交后重新排队。两项设置共同完成修复和重新排队流程。移除后 Kandev 不会让同一个 head 再次排队。", + "mergeQueueAutomation": "合并队列自动化", + "mergeQueueRecovery": "合并队列恢复", + "mergeQueueRemovalCauseChecksFailed": "检查失败", + "mergeQueueRemovalCauseChecksTimedOut": "检查超时", + "mergeQueueRemovalCauseConflict": "合并冲突", + "mergeQueueRecoveryStatusQueued": "合并队列尝试已活动。自动合并不会提交重复请求。", + "mergeQueueRecoveryStatusActionable": "队列移除需要处理。启用自动修复即可将其发送给智能体。", + "mergeQueueRecoveryStatusNotActionable": "已记录队列移除。不会自动修复此移除事件。", + "mergeQueueRecoveryStatusRepairRequested": "已请求修复此移除事件。", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "已请求修复。等待新提交后再重新排队。", + "mergeQueueRecoveryStatusWaitingForCommit": "等待新提交后再重新排队。", + "mergeQueueRecoveryStatusWaitingForChecks": "检测到新 head。等待必需的检查。" } diff --git a/apps/web/src/locales/zh-hk/github.json b/apps/web/src/locales/zh-hk/github.json index 8abd17264b..964979ee7e 100644 --- a/apps/web/src/locales/zh-hk/github.json +++ b/apps/web/src/locales/zh-hk/github.json @@ -44,7 +44,7 @@ "autoFixExhaustedAria": "達到 {{current}} / {{max}} 輪後,自動修復已暫停", "autoFixCiAndAddressComments": "自動修復 CI 並處理評論", "autoFixPrompt": "自動修復提示詞", - "autoFixPromptDescription": "此提示詞僅用於本任務。留空則使用預設提示詞。若希望 Kandev 附上 PR 反饋快照,請新增 <0>{{placeholder}}。", + "autoFixPromptDescription": "此提示詞僅用於本任務。留空則使用預設提示詞。若希望 Kandev 附上 PR 反饋、佇列移除上下文和可用的合併組失敗檢查,請新增 <0>{{placeholder}}。", "autoFixRoundExplanation": "自動修復已為該 PR 使用 {{current}} / {{max}} 輪。Kandev 每次傳送或排隊一條 CI 自動修復訊息即計為一輪。Kandev 會等待全部 PR 檢查完成後再開始新一輪自動修復,以便代理程式同時獲得最終的失敗檢查與目前評論。更新已排隊的自動修復訊息不會額外消耗輪次。當已達 {{max}}/{{max}} 且沒有待更新的自動修復訊息時,Kandev 會暫停該 PR 的自動修復,避免無限循環。人工審閱後,請關閉並重新啓用自動修復以重新開始。", "autoMerge": "自動合併", "autoMergeEnabledAria": "自動合併已啓用", @@ -503,7 +503,7 @@ "theAppIsReadyToSelect": "該應用已可供此工作區選擇和安裝。", "theExistingWorkspaceConnectionWasNot": "現有工作區連接未更改。", "theGithubResponseCouldNotBe": "無法驗證 GitHub 回應。請檢查連接後重試。", - "thePlaceholderInsertsTheCurrentPr": "該佔位符會插入目前 PR 標識、新增或變更的失敗檢查(含 GitHub 作業連結),以及新增或變更的審閱評論(含檔案、行號和正文)。", + "thePlaceholderInsertsTheCurrentPr": "該佔位符會插入目前 PR 標識、佇列移除上下文、新增或變更的審閱評論,以及僅在存在準確合併組提交時提供的失敗合併組檢查。", "thePullRequestIsStillLinked": "拉取要求仍保持關聯。", "theRepositoryWillBeAutomaticallyCloned": "如果工作區中尚無此儲存庫,將自動複製到 {{path}}。", "theSettingsCouldNotBeSaved": "無法儲存設定", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "排隊中", "mergeQueueStateUnmergeable": "不可合併", "mergeQueueStatus": "合併佇列:{{state}}", - "mergeQueueSubMinuteEstimate": "預計合併時間:少於一分鐘" + "mergeQueueSubMinuteEstimate": "預計合併時間:少於一分鐘", + "autoMergeOrRequeueWhenReady": "就緒時自動合併或重新排隊", + "autoFixQueueSupportQueued": "將修復佇列檢查失敗或衝突。", + "autoFixQueueSupportEnable": "啓用後將此可處理的佇列失敗傳送給代理程式。", + "autoFixQueueSupportRequested": "已請求修復此移除事件。", + "autoFixQueueSupportNotActionable": "不會自動修復此移除事件。", + "autoFixQueueSupportNewHead": "修復建立了新的提交。", + "autoMergeQueueSupportQueued": "目前佇列嘗試已經處於活動狀態。", + "autoMergeQueueSupportDisabled": "自動重新排隊已關閉。", + "autoMergeQueueSupportWaitingForCommit": "等待新提交後再重新排隊。", + "autoMergeQueueSupportWaitingForChecks": "等待必需的檢查。", + "autoMergeQueueSupportNewHead": "新提交通過檢查後將重新排隊。", + "automationForPrQueued": "PR #{{number}} 已在合併佇列中", + "automationForPrRemoved": "PR #{{number}} 已被移除:{{cause}}", + "automationForPrRemovedGeneric": "PR #{{number}} 已從合併佇列中移除", + "ciAutomationQueueRecoveryHelp": "自動修復會處理可操作的佇列移除。自動合併會提交符合條件的 head,或在新提交後重新排隊。兩項設定共同完成修復和重新排隊流程。移除後 Kandev 不會讓同一個 head 再次排隊。", + "mergeQueueAutomation": "合併佇列自動化", + "mergeQueueRecovery": "合併佇列恢復", + "mergeQueueRemovalCauseChecksFailed": "檢查失敗", + "mergeQueueRemovalCauseChecksTimedOut": "檢查超時", + "mergeQueueRemovalCauseConflict": "合併衝突", + "mergeQueueRecoveryStatusQueued": "合併佇列嘗試已活動。自動合併不會提交重複請求。", + "mergeQueueRecoveryStatusActionable": "佇列移除需要處理。啓用自動修復即可將其傳送給代理程式。", + "mergeQueueRecoveryStatusNotActionable": "已記錄佇列移除。不會自動修復此移除事件。", + "mergeQueueRecoveryStatusRepairRequested": "已請求修復此移除事件。", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "已請求修復。等待新提交後再重新排隊。", + "mergeQueueRecoveryStatusWaitingForCommit": "等待新提交後再重新排隊。", + "mergeQueueRecoveryStatusWaitingForChecks": "檢測到新 head。等待必需的檢查。" } diff --git a/apps/web/src/locales/zh-tw/github.json b/apps/web/src/locales/zh-tw/github.json index a5cd3d8165..a5de1b1ac3 100644 --- a/apps/web/src/locales/zh-tw/github.json +++ b/apps/web/src/locales/zh-tw/github.json @@ -44,7 +44,7 @@ "autoFixExhaustedAria": "達到 {{current}} / {{max}} 輪後,自動修復已暫停", "autoFixCiAndAddressComments": "自動修復 CI 並處理評論", "autoFixPrompt": "自動修復提示詞", - "autoFixPromptDescription": "此提示詞僅用於本任務。留空則使用預設提示詞。若希望 Kandev 附上 PR 意見回饋快照,請新增 <0>{{placeholder}}。", + "autoFixPromptDescription": "此提示詞僅用於本任務。留空則使用預設提示詞。若希望 Kandev 附上 PR 意見回饋、佇列移除上下文和可用的合併組失敗檢查,請新增 <0>{{placeholder}}。", "autoFixRoundExplanation": "自動修復已為該 PR 使用 {{current}} / {{max}} 輪。Kandev 每次傳送或排隊一條 CI 自動修復訊息即計為一輪。Kandev 會等待全部 PR 檢查完成後再開始新一輪自動修復,以便代理程式同時獲得最終的失敗檢查與目前評論。更新已排隊的自動修復訊息不會額外消耗輪次。當已達 {{max}}/{{max}} 且沒有待更新的自動修復訊息時,Kandev 會暫停該 PR 的自動修復,避免無限迴圈。人工審閱後,請關閉並重新啟用自動修復以重新開始。", "autoMerge": "自動合併", "autoMergeEnabledAria": "自動合併已啟用", @@ -503,7 +503,7 @@ "theAppIsReadyToSelect": "該應用已可供此工作區選擇和安裝。", "theExistingWorkspaceConnectionWasNot": "現有工作區連線未更改。", "theGithubResponseCouldNotBe": "無法驗證 GitHub 回應。請檢查連線後重試。", - "thePlaceholderInsertsTheCurrentPr": "該佔位符會插入目前 PR 標識、新增或變更的失敗檢查(含 GitHub 作業連結),以及新增或變更的審閱評論(含檔案、行號和正文)。", + "thePlaceholderInsertsTheCurrentPr": "該佔位符會插入目前 PR 標識、佇列移除上下文、新增或變更的審閱評論,以及僅在存在準確合併組提交時提供的失敗合併組檢查。", "thePullRequestIsStillLinked": "提取要求仍保持關聯。", "theRepositoryWillBeAutomaticallyCloned": "如果工作區中尚無此儲存庫,將自動複製到 {{path}}。", "theSettingsCouldNotBeSaved": "無法儲存設定", @@ -588,5 +588,32 @@ "mergeQueueStateQueued": "排隊中", "mergeQueueStateUnmergeable": "不可合併", "mergeQueueStatus": "合併佇列:{{state}}", - "mergeQueueSubMinuteEstimate": "預計合併時間:少於一分鐘" + "mergeQueueSubMinuteEstimate": "預計合併時間:少於一分鐘", + "autoMergeOrRequeueWhenReady": "就緒時自動合併或重新排隊", + "autoFixQueueSupportQueued": "將修復佇列檢查失敗或衝突。", + "autoFixQueueSupportEnable": "啟用後將此可處理的佇列失敗傳送給代理程式。", + "autoFixQueueSupportRequested": "已請求修復此移除事件。", + "autoFixQueueSupportNotActionable": "不會自動修復此移除事件。", + "autoFixQueueSupportNewHead": "修復建立了新的提交。", + "autoMergeQueueSupportQueued": "目前佇列嘗試已經處於活動狀態。", + "autoMergeQueueSupportDisabled": "自動重新排隊已關閉。", + "autoMergeQueueSupportWaitingForCommit": "等待新提交後再重新排隊。", + "autoMergeQueueSupportWaitingForChecks": "等待必需的檢查。", + "autoMergeQueueSupportNewHead": "新提交通過檢查後將重新排隊。", + "automationForPrQueued": "PR #{{number}} 已在合併佇列中", + "automationForPrRemoved": "PR #{{number}} 已被移除:{{cause}}", + "automationForPrRemovedGeneric": "PR #{{number}} 已從合併佇列中移除", + "ciAutomationQueueRecoveryHelp": "自動修復會處理可操作的佇列移除。自動合併會提交符合條件的 head,或在新提交後重新排隊。兩項設定共同完成修復和重新排隊流程。移除後 Kandev 不會讓同一個 head 再次排隊。", + "mergeQueueAutomation": "合併佇列自動化", + "mergeQueueRecovery": "合併佇列恢復", + "mergeQueueRemovalCauseChecksFailed": "檢查失敗", + "mergeQueueRemovalCauseChecksTimedOut": "檢查超時", + "mergeQueueRemovalCauseConflict": "合併衝突", + "mergeQueueRecoveryStatusQueued": "合併佇列嘗試已活動。自動合併不會提交重複請求。", + "mergeQueueRecoveryStatusActionable": "佇列移除需要處理。啟用自動修復即可將其傳送給代理程式。", + "mergeQueueRecoveryStatusNotActionable": "已記錄佇列移除。不會自動修復此移除事件。", + "mergeQueueRecoveryStatusRepairRequested": "已請求修復此移除事件。", + "mergeQueueRecoveryStatusRepairRequestedWaiting": "已請求修復。等待新提交後再重新排隊。", + "mergeQueueRecoveryStatusWaitingForCommit": "等待新提交後再重新排隊。", + "mergeQueueRecoveryStatusWaitingForChecks": "檢測到新 head。等待必需的檢查。" } diff --git a/docs/plans/github-pr-merge-queue-recovery/plan.md b/docs/plans/github-pr-merge-queue-recovery/plan.md new file mode 100644 index 0000000000..cfecdd38d3 --- /dev/null +++ b/docs/plans/github-pr-merge-queue-recovery/plan.md @@ -0,0 +1,165 @@ +--- +created: 2026-08-24 +status: done +requirements: + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003 + - REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001 +system_design: + - ../../specs/integrations/system-design/github-pr-merge-queue-recovery.md + - ../../specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md +legacy_specs: [] +--- +# Implementation Plan: GitHub PR Merge Queue Recovery + +## Overview + +Extend the existing GitHub poller with durable queue-removal evidence. Then use +that evidence in the current auto-fix and auto-merge evaluator. + +Provider state lands first because retry safety depends on a stable event ID +and head SHA. Runtime automation follows that contract. The shared desktop and +mobile controls then expose the result. E2E work completes the package. + +## Scope + +### In scope + +- Poll GitHub for the latest merge-queue removal event. +- Persist the pull-request head, active queue-attempt identity, and removal + evidence. +- Send one auto-fix round for each actionable removal. +- Prevent same-head requeue loops. +- Requeue a new eligible head through the existing queue-aware merge action. +- Explain recovery in the current PR hover popover and phone drawer. +- Update the public GitHub integration guide. + +### Out of scope + +- Merge-group webhooks. +- Automatic flaky-check retries without a new pull-request head. +- A third queue-recovery switch. +- Manual queue removal or priority controls. +- Coordinated requeue ordering for a GitHub pull-request stack. +- GitLab merge requests. + +## Technical approach + +### Provider snapshot and persistence + +Extend `prFieldsBlock` and `batchedPRResult` in +`apps/backend/internal/github/graphql.go`. Read the active queue entry, current +head SHA, and latest `RemovedFromMergeQueueEvent`. + +Extend `PRStatus` and `TaskPR` in `apps/backend/internal/github/models.go`. +Add the new SQLite columns and thread them through every TaskPR write path in +`apps/backend/internal/github/store.go`. + +Extend `prepareTaskPRSyncState` in +`apps/backend/internal/github/service_pr_watch.go`. Active queue fields clear +on an authoritative null. The last removal remains until a newer event arrives. + +### Auto-fix and auto-merge + +Extend the CI checkpoint in +`apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go`. +Queue-removal evidence becomes actionable only with failed-check or conflict +evidence. + +Map reviewed GitHub removal-reason forms to a normalized cause. Do not treat +the removal event's `beforeCommit` as a merge-group commit. Reuse the current +prompt queue, coalescing key, session binding, and 10-round limit. + +Add the head SHA to `ciAutomationMergeSignature`. Store the last queue-attempt +head for Kandev-created and externally observed attempts. A queue removal +blocks the same head. A new eligible head permits one new queue-aware merge +request. + +### Responsive controls and copy + +Keep two switches in `pr-ci-automation-rows.tsx`. Change the auto-merge label +to `Auto-merge or requeue when ready`. + +Adapt the section title, subtitle, and supporting text for normal, queued, and +removed contexts. Keep both switch labels stable. Add one compact, +non-interactive queue status below the switches. Derive all presentation state +in a pure helper from `TaskPR`, per-PR options, and +`TaskCIPRAutomationState`. + +Use the normalized removal cause for localized copy. Use generic removal copy +for manual, branch-protection, and unknown causes. Do not use the raw provider +reason as a label. + +Update the information control and prompt dialog copy. Add every new key to all +five required locale catalogs. Update the GitHub section in +`docs/public/integrations.md`. + +### Integrated test support + +Extend the mock GitHub controller and E2E API client with queue-removal and head +transition inputs. Seed an actionable removal, observe one prompt, publish a +new head, and observe one queued merge attempt. + +## Tests + +- `AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.*`: GraphQL conversion, + SQLite round-trip, sync transitions, and restart-safe reads in + `apps/backend/internal/github`. +- `AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.*`: evaluator tests for + reviewed reason forms, conflicts, deduplication, unknown removal, durable + dispatch, commit-identity safety, and the round limit. +- `AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.*`: evaluator tests for + same-head blocking, new-head requeue, enable-while-queued adoption, and + GitHub rejection. +- `AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.*`: pure state tests and focused + component tests for labels, help, status, and errors. + +## E2E tests + +- Desktop: add a merge-queue recovery scenario to + `apps/web/e2e/tests/pr/ci-automation-options.spec.ts`. Open the hover popover, + start with an already queued PR, enable both switches, remove the PR, and + observe repair, wait, and requeue states. +- Mobile: add the same user outcome to + `apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts`. Use touch input + and the `mobile-chrome` project. +- Both scenarios assert no duplicate request when the options are enabled on + the active queue entry. They then assert one repair prompt and one queue + request per eligible transition. The mobile scenario also asserts 44-pixel + rows, internal drawer scrolling, and no document-level horizontal overflow. + +## Work orders + +- [x] [Task 01: Persist queue recovery evidence](task-01-persist-queue-recovery-evidence.md) +- [x] [Task 02: Evaluate repair and requeue](task-02-evaluate-repair-and-requeue.md) +- [x] [Task 03: Expose responsive recovery controls](task-03-expose-responsive-recovery-controls.md) +- [x] [Task 04: Prove desktop and mobile recovery](task-04-prove-desktop-and-mobile-recovery.md) + +## Verification results + +- `go test -tags fts5 ./internal/github ./internal/orchestrator`: 3,770 tests passed. +- `go test -race -tags fts5 ./internal/github`: 1,661 tests passed. +- `make -C apps/backend lint`: passed with zero issues. +- `python3 scripts/lint-spec-files.py --all`: passed. +- Web focused recovery and regression tests: 4 files, 69 tests passed. +- `pnpm run typecheck`: passed. +- Full web lint with `--max-warnings 0`: passed. +- `pnpm run i18n:check`: passed for all five catalogs; existing orphan warnings only. +- Public documentation tests: 61 passed; 41 pages validated. +- Fresh-build desktop recovery E2E: 1 test passed. +- Mobile recovery E2E: 1 test passed. +- `git diff --check`: passed. + +## Risks + +- GitHub exposes the removal reason as free-form text. Automatic action cannot + use an unrecognized form. Unknown forms remain visible but do not start a + repair. +- GitHub does not document the removal event's `beforeCommit` as the temporary + merge-group commit. The first version does not attach merge-group check logs + unless an exact commit identity is available. +- A one-minute poll can first observe removal after a later push. The safe + fallback can delay one automatic requeue instead of risking a retry loop. +- GitHub can remove several stacked pull requests after one lower failure. The + first version evaluates each linked pull request independently. diff --git a/docs/plans/github-pr-merge-queue-recovery/task-01-persist-queue-recovery-evidence.md b/docs/plans/github-pr-merge-queue-recovery/task-01-persist-queue-recovery-evidence.md new file mode 100644 index 0000000000..bd85ed44cd --- /dev/null +++ b/docs/plans/github-pr-merge-queue-recovery/task-01-persist-queue-recovery-evidence.md @@ -0,0 +1,91 @@ +--- +id: "01-persist-queue-recovery-evidence" +title: "Persist queue recovery evidence" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.1 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.2 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.4 +system_design: + - ../../specs/integrations/system-design/github-pr-merge-queue-recovery.md +--- +# Task 01: Persist Queue Recovery Evidence + +## Summary + +Extend the GitHub PR snapshot with a stable head SHA, active queue identity, +and the latest removal event. Persist complete observations across every +TaskPR write path. + +## In scope + +- Extend the batched GraphQL query and conversion. +- Add populated guards for queue entry and removal history. +- Add the TaskPR and SQLite fields from the system design. +- Preserve fields across create, replace, restore, and sync paths. +- Add focused GraphQL, service, and real-store tests. + +## Out of scope + +- Auto-fix classification or dispatch. +- Auto-merge and requeue decisions. +- Frontend rendering. + +## Acceptance + +- A poll can detect a removal after the active entry disappears. +- Queue-unaware reads preserve the complete recovery snapshot. +- The snapshot survives every TaskPR write path and a backend restart. + +## Verification + +```bash +go test -tags fts5 ./internal/github -run 'Test(PRFieldsBlockRequestsMergeQueueRecovery|ConvertBatchedPRResultPreservesMergeQueueRecovery|SyncTaskPR.*MergeQueueRecovery|TaskPRMergeQueueRecoverySchema)' +``` + +Run the command from `apps/backend`. + +## Files likely touched + +- `apps/backend/internal/github/graphql.go` +- `apps/backend/internal/github/models.go` +- `apps/backend/internal/github/store.go` +- `apps/backend/internal/github/service_pr_watch.go` +- `apps/backend/internal/github/graphql_merge_queue_recovery_test.go` +- `apps/backend/internal/github/service_pr_merge_queue_recovery_test.go` +- `apps/backend/internal/github/store_merge_queue_recovery_test.go` + +## Dependencies + +None. + +## Risks + +- A missing populated guard can erase a valid timeline observation during a + REST feedback refresh. +- Schema column lists have several create, replace, and restore paths. + +## Parallelism + +`sequential` + +## Inputs + +- Integration requirements 001 and system-design provider sections. +- Existing merge-queue status persistence and outcome-attribution patterns. + +## Results + +- Added GraphQL head, active queue-entry, and removal-event observations with + populated guards. +- Persisted and restored the recovery snapshot through TaskPR create, replace, + update, restore, and sync paths, including stale-event protection. +- Added GraphQL, service, and SQLite regression coverage. +- Verification: `go test -tags fts5 ./internal/github` passed (1,660 tests). +- Verification: `go test -race -tags fts5 ./internal/github` passed (1,660 tests). diff --git a/docs/plans/github-pr-merge-queue-recovery/task-02-evaluate-repair-and-requeue.md b/docs/plans/github-pr-merge-queue-recovery/task-02-evaluate-repair-and-requeue.md new file mode 100644 index 0000000000..952c4e7311 --- /dev/null +++ b/docs/plans/github-pr-merge-queue-recovery/task-02-evaluate-repair-and-requeue.md @@ -0,0 +1,118 @@ +--- +id: "02-evaluate-repair-and-requeue" +title: "Evaluate repair and requeue" +status: done +wave: 2 +depends_on: + - "01-persist-queue-recovery-evidence" +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.1 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.2 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.3 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.4 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.5 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.6 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.7 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.8 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.1 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.2 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.3 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.4 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.5 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.6 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.7 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.8 +system_design: + - ../../specs/integrations/system-design/github-pr-merge-queue-recovery.md +--- +# Task 02: Evaluate Repair and Requeue + +## Summary + +Extend CI automation with queue-removal checkpoints and failed-check evidence. +Use the PR head SHA to permit one safe queue attempt after a repair. + +## In scope + +- Map reviewed removal-reason forms to normalized causes. +- Keep `beforeCommit` separate from an exact merge-group commit identity. +- Add queue-removal evidence to the auto-fix checkpoint and prompt snapshot. +- Record one accepted auto-fix round per actionable removal event. +- Add the head SHA to the merge signature. +- Store the last accepted or observed queue-attempt head. +- Baseline Kandev-created and externally observed queue attempts. +- Adopt an active attempt when auto-merge is enabled while already queued. +- Evaluate one retained actionable removal when auto-fix becomes enabled. +- Prevent a same-head automatic requeue. +- Add focused service and orchestrator tests. + +## Out of scope + +- A new queue preference. +- A same-head flaky-check retry policy. +- UI and E2E changes. + +## Acceptance + +- One actionable removal produces at most one auto-fix round. +- A manual or unknown removal without evidence produces no repair prompt. +- The removal event commit does not produce merge-group check evidence. +- A new eligible head produces one queue-aware merge request. +- Enabling auto-merge on an active entry produces no duplicate request. + +## Verification + +```bash +go test -tags fts5 ./internal/github ./internal/orchestrator -run 'Test.*CIAutomation.*MergeQueueRecovery' +``` + +Run the command from `apps/backend`. + +## Files likely touched + +- `apps/backend/internal/github/service_ci_automation.go` +- `apps/backend/internal/github/store.go` +- `apps/backend/internal/orchestrator/event_handlers_github_ci_automation.go` +- `apps/backend/internal/orchestrator/event_handlers_github_ci_automation_test.go` +- `apps/backend/internal/github/service_ci_automation_test.go` + +## Dependencies + +- Task 01 supplies the durable provider snapshot. + +## Risks + +- GitHub can change its free-form removal reasons. Unknown forms must fail + closed and remain observable. +- Prompt text must preserve the existing untrusted-data boundary. + +## Parallelism + +`sequential` + +## Inputs + +- Integration requirements 002 and 003. +- Existing CI checkpoint, queue dispatch, round-limit, and merge-signature code. + +## Results + +- Added reviewed queue-removal cause classification with fail-closed handling + for manual, branch-protection, and unknown provider reasons. Merge conflict + and `UNMERGEABLE` evidence remain actionable without treating `beforeCommit` + as a merge-group check identity. +- Added queue-removal checkpoint and prompt evidence, durable event deduplication, + round-limit accounting, and current-head eligibility checks. +- Added durable active-queue adoption and attempted-head persistence. The merge + signature now includes `TaskPR.HeadSHA`; active entries are adopted, removed + heads are blocked, and a changed eligible head can be queued once. +- Added focused store, service wiring, and orchestrator coverage for the + recovery state machine. +- `go test -tags fts5 ./internal/github ./internal/orchestrator -run 'Test.*CIAutomation.*MergeQueueRecovery'` passed (13 tests). +- `go test -tags fts5 ./internal/github` passed (1,661 tests). +- `go test -tags fts5 ./internal/orchestrator` passed. +- `git diff --check` and targeted `gofmt -l` checks passed. diff --git a/docs/plans/github-pr-merge-queue-recovery/task-03-expose-responsive-recovery-controls.md b/docs/plans/github-pr-merge-queue-recovery/task-03-expose-responsive-recovery-controls.md new file mode 100644 index 0000000000..1aab956f2b --- /dev/null +++ b/docs/plans/github-pr-merge-queue-recovery/task-03-expose-responsive-recovery-controls.md @@ -0,0 +1,136 @@ +--- +id: "03-expose-responsive-recovery-controls" +title: "Expose responsive recovery controls" +status: done +wave: 3 +depends_on: + - "02-evaluate-repair-and-requeue" +plan: "plan.md" +requirements: + - REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001 +acceptance_criteria: + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.1 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.2 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.3 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.4 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.5 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.6 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.7 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.8 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.9 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.10 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.11 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.12 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.13 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.14 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.15 +system_design: + - ../../specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md + - ../../specs/integrations/system-design/github-pr-merge-queue-recovery.md +--- +# Task 03: Expose Responsive Recovery Controls + +## Summary + +Keep the two existing automation switches and add queue-aware copy and status. +Use the same state derivation in the desktop hover popover and mobile drawer. + +## In scope + +- Add web types for queue-recovery state. +- Update the auto-merge label. +- Adapt the title, subtitle, and supporting text to queue context. +- Use the normalized removal cause and generic unknown-cause fallback. +- Add the compact queue-recovery status line. +- Update the header information and prompt dialog copy. +- Add focused helper and component tests. +- Update all five locale catalogs. +- Update `docs/public/integrations.md`. + +## Out of scope + +- New navigation or overlay surfaces. +- A manual requeue button. +- GitLab copy changes. + +## Acceptance + +- The surface contains two switches and no queue-recovery switch. +- The queue status and same-head guard are clear on desktop and mobile. +- An already queued PR explains option adoption without implying a new enqueue. +- A removed PR explains the next repair or requeue step. +- Raw provider text does not become a localized label or accessible name. +- All copy is localized, accessible, and safe at narrow widths. + +## Verification + +```bash +# Run from apps/web. +pnpm test -- components/github/pr-ci-popover.automation.test.tsx components/github/pr-ci-automation-rows.test.tsx +pnpm run i18n:check + +# Run from the repository root. The test harness resolves public-docs paths +# relative to the current working directory. +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Install workspace dependencies from `apps` before the first pnpm command in a +fresh worktree. + +## Files likely touched + +- `apps/web/lib/types/github.ts` +- `apps/web/lib/github/ci-automation.ts` +- `apps/web/lib/github/ci-automation.test.ts` +- `apps/web/components/github/pr-ci-automation-controls.tsx` +- `apps/web/components/github/pr-ci-automation-rows.tsx` +- `apps/web/components/github/pr-ci-automation-rows.test.tsx` +- `apps/web/components/github/pr-ci-automation-prompt-dialog.tsx` +- `apps/web/components/github/pr-ci-popover.automation.test.tsx` +- `apps/web/src/locales/*/github.json` +- `docs/public/integrations.md` + +## Dependencies + +- Task 02 supplies the final API state and retry semantics. + +## Risks + +- The existing compact popover has limited vertical space. +- The label change also affects selectors and public documentation. + +## Parallelism + +`sequential` + +## Inputs + +- UI requirement and system design. +- Existing PR automation rows and `PRStatusChipDrawer` mobile composition. + +## Results + +Implemented the queue-aware desktop hover-popover and mobile drawer state +surface while retaining exactly two automation switches. The pure helper now +derives active-queue, actionable-removal, non-actionable-removal, +repair-requested, same-head wait, and new-head wait states from durable PR and +automation state. Titles, subtitles, supporting text, status, help, and prompt +copy use localized keys; unknown, manual, and branch-protection causes remain +generic and raw provider text is not exposed as a label or accessible name. + +Added focused helper and component coverage for queue context, recovery copy, +same-head waiting, accessibility, and the two-switch invariant. Updated all +five locale catalogs and `docs/public/integrations.md`. + +Verification: + +- `pnpm test -- components/github/pr-ci-popover.automation.test.tsx components/github/pr-ci-automation-rows.test.tsx components/github/pr-status-chip.test.tsx lib/github/ci-automation.test.ts`: 4 files, 69 tests passed. +- `pnpm run typecheck`: passed. +- `pnpm exec eslint` on the changed UI/helper files: passed with zero warnings. +- `pnpm run i18n:check`: passed; all five required catalogs complete, pseudo + locale synchronized, and no untranslated non-JSX copy or em dashes. +- `node --test scripts/validate-public-docs.test.mjs` from the repository root: + 61 tests passed. +- `node scripts/validate-public-docs.mjs` from the repository root: 41 pages + validated. diff --git a/docs/plans/github-pr-merge-queue-recovery/task-04-prove-desktop-and-mobile-recovery.md b/docs/plans/github-pr-merge-queue-recovery/task-04-prove-desktop-and-mobile-recovery.md new file mode 100644 index 0000000000..7d92e9b31d --- /dev/null +++ b/docs/plans/github-pr-merge-queue-recovery/task-04-prove-desktop-and-mobile-recovery.md @@ -0,0 +1,117 @@ +--- +id: "04-prove-desktop-and-mobile-recovery" +title: "Prove desktop and mobile recovery" +status: done +wave: 4 +depends_on: + - "03-expose-responsive-recovery-controls" +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003 + - REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.1 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.3 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.2 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.3 + - AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.7 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.8 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.9 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.10 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.11 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.12 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.13 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.14 + - AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.15 +system_design: + - ../../specs/integrations/system-design/github-pr-merge-queue-recovery.md + - ../../specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md +--- +# Task 04: Prove Desktop and Mobile Recovery + +## Summary + +Add deterministic mock GitHub transitions for queue removal and a new head. +Prove the same repair-and-requeue outcome through desktop hover and mobile tap +surfaces. + +## In scope + +- Extend mock GitHub seeding for queue entry, removal, head, and check evidence. +- Add API-client helpers for the E2E transitions. +- Add a desktop hover-popover recovery scenario. +- Add a mobile drawer recovery scenario. +- Start both scenarios with the pull request already in the queue. +- Assert contextual title, subtitle, supporting text, and status transitions. +- Assert the localized classified cause and generic unknown-cause fallback. +- Prove deduplication, touch geometry, scroll ownership, and overflow behavior. + +## Out of scope + +- Live writes to GitHub. +- Product-video or screenshot capture. +- Full merge-queue management. + +## Acceptance + +- One removal produces one visible repair round on desktop and mobile. +- Enabling both options on an active entry produces no second queue request. +- The same head does not requeue. A new eligible head queues once. +- The mobile drawer keeps 44-pixel rows and no document overflow. + +## Verification + +```bash +pnpm e2e:run tests/pr/ci-automation-options.spec.ts -- --grep "merge queue recovery" +pnpm e2e:run --no-build --project mobile-chrome tests/pr/mobile-ci-automation-options.spec.ts -- --grep "merge queue recovery" +``` + +Run the commands from `apps/web`. Run the first command with a fresh build. + +## Files likely touched + +- `apps/backend/internal/github/mock_client.go` +- `apps/backend/internal/github/mock_controller.go` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/tests/pr/ci-automation-options.spec.ts` +- `apps/web/e2e/tests/pr/mobile-ci-automation-options.spec.ts` + +## Dependencies + +- Task 03 completes the integrated backend and UI behavior. + +## Risks + +- The mock must reproduce the provider event order without fixed sleeps. +- The desktop test must reset pointer state before hover assertions. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs. +- Existing CI automation and merge-queue E2E fixtures. + +## Results + +Added deterministic mock GitHub merge-queue transitions and merge-attempt +inspection. The mock provider now carries queue membership, removal evidence, +head changes, check runs, and queued merge outcomes through the normal status +sync and event bus. + +Added desktop hover-popover and mobile drawer scenarios. Both start with an +active queue entry, enable both existing switches without a duplicate merge +request, observe one classified actionable repair round, block a same-head +requeue, queue exactly one new eligible head, and observe the resulting active +queue entry. The mobile scenario also uses touch input and verifies 44-pixel +switch rows, drawer-owned scrolling, and no document-level horizontal +overflow. GitHub selectors and component expectations were updated for the +new auto-merge/requeue label. + +Verification: + +- `pnpm e2e:run tests/pr/ci-automation-options.spec.ts -- --grep "merge queue recovery"`: fresh-build desktop run passed (1 test). +- `pnpm e2e:run --no-build --project mobile-chrome tests/pr/mobile-ci-automation-options.spec.ts -- --grep "merge queue recovery"`: mobile run passed (1 test). diff --git a/docs/public/integrations.md b/docs/public/integrations.md index 7d4d07e2be..cfaf4b6fb4 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -312,12 +312,14 @@ Repository scope, authentication, and watch filters are workspace-specific. Repo ### Automate a linked pull request -For a task with linked GitHub pull requests, open the PR status control above the task chat input. The automation controls, **Auto-fix CI & address comments**, **Auto-merge when ready**, **Your review is requested**, **PR merged**, and **PR closed without merging**, are scoped to whichever linked PR's tab is selected. Enabling a control for one linked PR does not enable it for the task's other linked PRs; Kandev tracks delivery and deduplication separately for each linked PR. The saved auto-fix prompt override applies to every linked PR. +For a task with linked GitHub pull requests, open the PR status control above the task chat input. The automation controls, **Auto-fix CI & address comments**, **Auto-merge or requeue when ready**, **Your review is requested**, **PR merged**, and **PR closed without merging**, are scoped to whichever linked PR's tab is selected. Enabling a control for one linked PR does not enable it for the task's other linked PRs; Kandev tracks delivery and deduplication separately for each linked PR. The saved auto-fix prompt override applies to every linked PR. This is a GitHub-only lifecycle feature. Kandev reuses the existing lightweight task PR poller, which checks watched linked PRs roughly once per minute; it does not add a separate scheduler. Saving enabled options also evaluates the task's current linked PRs without waiting for the next poll. When GitHub puts a linked pull request in a merge queue, the PR status control shows its queue state. It also shows the queue position and estimated merge time when GitHub provides them. The same status appears in the task summary, the mobile PR chip, and Review. +The existing two automation switches also control merge-queue recovery. Auto-fix sends one actionable queue removal to the linked task agent and counts it as one auto-fix round. Auto-merge submits an eligible pull request through GitHub's queue-aware merge action. If GitHub removes a queue attempt, Kandev records the reason and waits for a new pull-request head before it submits another attempt; it never retries the same head automatically. An active queue entry is adopted when auto-merge is enabled, so enabling the option does not submit a duplicate request. Unknown, manual, and branch-protection removals remain visible but do not start automatic repair. + **Your review is requested** matches the GitHub account connected to the task's workspace. The first observation is a quiet baseline. Any later transition to a request for that account wakes the agent, including the first new request after baselining and a re-review request after changes. Clearing a request rearms the next transition. If the workspace's connected GitHub account changes, Kandev quietly rebinds the task and re-establishes every linked PR's baseline; switching accounts does not itself create a prompt. **PR merged** and **PR closed without merging** are separate subscriptions to the same kind of follow-up: waking the agent when review work ends. Each notifies once when its linked PR enters the selected terminal state. Kandev delivers lifecycle notifications to the task's active promptable session, preferring the primary session. It does not interrupt a busy session: it queues a message for delivery when that session is available. If the task has no promptable session, Kandev records the per-PR delivery error, creates no new session, and retries once a session becomes promptable. diff --git a/docs/specs/integrations/README.md b/docs/specs/integrations/README.md index a91a66be80..d7f47e01b5 100644 --- a/docs/specs/integrations/README.md +++ b/docs/specs/integrations/README.md @@ -44,6 +44,7 @@ outcomes that expose those contracts. - [External Question Answering — authorized, discoverable, idempotent clarification resolution](requirements/external-question-answering.md) - [Workspace GitHub Authentication](requirements/github-authentication.md) - [GitHub PR Merge Queue](requirements/github-pr-merge-queue.md) +- [GitHub PR Merge Queue Recovery](requirements/github-pr-merge-queue-recovery.md) - [GitLab Integration](requirements/gitlab-integration.md) - [GitLab MR Status Chip](requirements/gitlab-mr-status-chip.md) - [GitLab MR Badge on the Sidebar and Tasks-List Rows](requirements/gitlab-mr-task-list-badges.md) @@ -70,6 +71,7 @@ outcomes that expose those contracts. - [Workspace GitHub Authentication System Design Part 2](system-design/github-authentication-02.md) - [Workspace GitHub Authentication System Design Part 3](system-design/github-authentication-03.md) - [GitHub PR Merge Queue](system-design/github-pr-merge-queue.md) +- [GitHub PR Merge Queue Recovery](system-design/github-pr-merge-queue-recovery.md) - [GitLab Integration System Design Part 1](system-design/gitlab-integration-01.md) - [GitLab Integration System Design Part 2](system-design/gitlab-integration-02.md) - [GitLab MR Status Chip System Design Part 1](system-design/gitlab-mr-status-chip-01.md) diff --git a/docs/specs/integrations/requirements/github-pr-merge-queue-recovery.md b/docs/specs/integrations/requirements/github-pr-merge-queue-recovery.md new file mode 100644 index 0000000000..ba76cd3bcf --- /dev/null +++ b/docs/specs/integrations/requirements/github-pr-merge-queue-recovery.md @@ -0,0 +1,124 @@ +--- +status: draft +system: integrations +created: 2026-08-24 +owners: + - Kandev +--- +# GitHub PR Merge Queue Recovery Requirements + +## Overview + +GitHub can remove a pull request from a merge queue after a merge-group check +fails or a conflict appears. Kandev shows active queue membership, but it does +not retain the removal event or repair the pull request. + +Queue-aware automation extends the existing per-PR auto-fix and auto-merge +controls. It does not add a separate queue-recovery preference. + +## Terminology + +- **Queue attempt:** One queue entry for one pull-request head commit. +- **Queue removal:** A GitHub `RemovedFromMergeQueueEvent` for an open pull + request. +- **Actionable removal:** A queue removal with a failed check, a timed-out + check, or a merge conflict. + +## Requirements + +### REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001: Queue removal observation + +**Intent:** Kandev must retain a queue removal after the active queue entry +disappears. This record lets automation respond after a short queue attempt. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.1:** When GitHub reports + a queue removal, Kandev shall store its provider event ID, time, reason, and + available commit identity. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.2:** When Kandev misses + the active queue entry, a later poll shall still detect the latest queue + removal from the pull-request timeline. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.3:** When Kandev + restarts, the last observed queue attempt and removal shall remain available + for deduplication and status display. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001.4:** When a queue-aware + read fails, Kandev shall preserve the last complete queue-recovery state. + +### REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002: Queue failure auto-fix + +**Intent:** Auto-fix must send actionable merge-queue failures to the linked +task agent without sending the same removal more than once. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.1:** When auto-fix is + enabled and a new actionable removal appears, Kandev shall send or queue one + auto-fix prompt for that removal. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.2:** The prompt shall + include the queue-removal reason and conflict state. It shall include failed + check names and links only when Kandev has an exact commit identity for those + checks. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.3:** The removal event ID + shall participate in the auto-fix checkpoint. Repeated polls shall not send + the same removal again. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.4:** A queue-recovery + prompt shall consume one of the existing 10 auto-fix rounds for that linked + pull request. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.5:** A manual or unknown + removal without failed-check or conflict evidence shall not start auto-fix. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.6:** When the task is + busy, the queue-recovery prompt shall use the existing durable auto-fix queue + and coalescing rules. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.7:** When a user enables + auto-fix after Kandev retained an unhandled actionable removal for the + current head, the next evaluation shall send or queue one repair prompt. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002.8:** Kandev shall not + treat the removal event's commit as a merge-group commit unless GitHub + identifies it as such. + +### REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003: Safe automatic requeue + +**Intent:** Auto-merge must requeue a repaired pull request without creating a +same-head retry loop or reversing an intentional queue removal. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.1:** When GitHub accepts + a queue attempt, Kandev shall record the attempted pull-request head commit. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.2:** When GitHub removes + that queue attempt, auto-merge shall not requeue the same head commit. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.3:** When the head commit + changes and the existing merge gates pass, auto-merge shall submit one new + queue-aware merge request. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.4:** A user or agent can + repair the pull request when auto-fix is disabled. A later eligible head + commit shall still re-arm auto-merge. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.5:** When both controls + are enabled, each actionable removal can start one repair round. A new + eligible head can then start one queue attempt. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.6:** Kandev shall use + GitHub's queue-aware default merge action. It shall not bypass repository + rules or choose direct merge instead of the required queue. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.7:** When a user enables + auto-merge while the pull request already has an active queue entry, Kandev + shall adopt that entry and head as the current attempt. It shall not submit a + duplicate merge request. +- **AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003.8:** When a user enables + auto-merge after a queue removal, Kandev shall keep the same-head guard. It + shall wait for a new eligible head instead of reversing the removal. + +## Related requirements + +- [GitHub PR Merge Queue](github-pr-merge-queue.md) +- [Task PR Automation Controls](../../ui/requirements/ci-pr-automation.md) +- [Merge Queue Recovery Controls](../../ui/requirements/ci-pr-merge-queue-recovery-controls.md) + +## Out of scope + +- Webhook ingestion for merge-group events. +- Automatic retry of a flaky queue failure without a new pull-request head. +- Editing branch protection, rulesets, or workflow files from the controls. +- Removing a pull request from the queue. +- Coordinated recovery and queue ordering for stacked pull requests. +- GitLab merge-request behavior. diff --git a/docs/specs/integrations/system-design/github-pr-merge-queue-recovery.md b/docs/specs/integrations/system-design/github-pr-merge-queue-recovery.md new file mode 100644 index 0000000000..763a79e9c3 --- /dev/null +++ b/docs/specs/integrations/system-design/github-pr-merge-queue-recovery.md @@ -0,0 +1,203 @@ +--- +status: draft +system: integrations +requirements: + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002 + - REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003 +created: 2026-08-24 +owners: + - Kandev +--- +# GitHub PR Merge Queue Recovery System Design + +## Purpose and boundaries + +This design extends the existing GitHub PR poller and CI automation evaluator. +It retains queue-removal evidence, sends repair prompts, and re-arms auto-merge +after a new pull-request head appears. + +GitHub remains the source of truth for queue policy and merge eligibility. +Kandev keeps the existing one-minute poll interval and queue-aware merge API. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-001` | [Provider observation](#provider-observation), [Persistence](#persistence) | +| `REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-002` | [Auto-fix flow](#auto-fix-flow) | +| `REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-RECOVERY-003` | [Requeue flow](#requeue-flow) | + +## Provider observation + +`apps/backend/internal/github/graphql.go` extends the batched pull-request +selection with these fields: + +```graphql +headRefOid +mergeQueueEntry { + id + state + position + estimatedTimeToMerge + headCommit { oid } +} +timelineItems(last: 1, itemTypes: REMOVED_FROM_MERGE_QUEUE_EVENT) { + nodes { + ... on RemovedFromMergeQueueEvent { + id + createdAt + reason + beforeCommit { oid } + } + } +} +``` + +The timeline event remains available after `mergeQueueEntry` becomes `null`. +This property closes the gap between queue attempts and the one-minute poll. + +The GraphQL result records separate populated flags for queue membership and +queue-removal history. A REST or `gh` read cannot clear either observation. + +## Persistence + +`github_task_prs` adds these snapshot fields: + +- `head_sha` stores the current pull-request head. +- `merge_queue_entry_id` stores the active queue entry ID. +- `merge_queue_entry_head_sha` stores the head for the active queue attempt. +- `merge_queue_last_removal_id` stores the latest removal event ID. +- `merge_queue_last_removed_at` stores the provider event time. +- `merge_queue_last_removal_reason` stores the provider reason. +- `merge_queue_last_removal_before_sha` stores the optional event commit. + +An authoritative `mergeQueueEntry: null` clears only the active entry fields. +The last removal fields remain until a newer removal replaces them. + +Every `TaskPR` write path must preserve these fields. The normal task-PR event +publishes them to current automation and UI consumers. + +`github_task_ci_pr_state` adds these fields: + +- `last_queue_attempt_head_sha` stores the head from the last accepted or + observed queue attempt. +- `last_queue_fix_event_id` stores the last removal that consumed an auto-fix + round. +- `last_queue_removal_cause` stores the normalized cause for the latest + removal. Values are `checks_failed`, `checks_timed_out`, `conflict`, + `manual`, `branch_protection`, and `unknown`. + +## Auto-fix flow + +`handleTaskPRCIAutomationWithRefresh` evaluates queue evidence before normal PR +feedback. A new removal is new when its event ID differs from +`last_queue_fix_event_id`. + +The evaluator classifies a removal as actionable when one of these facts +exists: + +- The provider reason matches a reviewed failure or timeout form. +- The pull request has a merge conflict. +- The active queue entry reached GitHub's `UNMERGEABLE` state. + +The reason mapper accepts only reviewed provider-generated forms. It maps +manual removal, branch-protection failure, and all unknown text to a +non-actionable cause. Structured logs record unknown forms for later review. + +GitHub documents `beforeCommit` as the commit before the removal event. It does +not document this field as the temporary merge-group commit. The evaluator +does not fetch check runs for this commit or use its checks as merge-group +evidence. + +When Kandev has an exact merge-group commit from a supported provider source, +the existing `ListCheckRuns` boundary can add failed check names and links. +This polling-only version does not infer that identity from the removal event. + +The auto-fix checkpoint adds a queue-removal item with its event ID, normalized +cause, raw reason, time, conflict state, and available check snapshots. +Existing sanitization and `sysprompt.Wrap` boundaries treat all provider text +as untrusted data. + +The existing auto-fix dispatch path owns session selection, durable queuing, +coalescing, and the 10-round limit. One accepted queue-recovery prompt records +`last_queue_fix_event_id` with the normal fix checkpoint. + +Enabling auto-fix runs the normal evaluator. If the latest retained removal is +actionable, belongs to the current head, and has not been checkpointed, the +evaluator accepts one repair round. Enabling the option while the pull request +is still queued only arms this behavior for a later removal. + +## Requeue flow + +The merge signature adds `TaskPR.HeadSHA`. This change makes a new commit a +clear re-arm signal, even when file counts and gate values stay unchanged. + +Kandev records the current merge signature and +`last_queue_attempt_head_sha` when either condition occurs: + +- Kandev receives a successful `queued` merge outcome. +- The poller observes an active queue entry that another client created. + +This observation also handles a user who enables auto-merge while the pull +request is already queued. The evaluator adopts the active entry and returns +without calling the merge API. + +When Kandev first observes a removal without an earlier active observation, it +records a conservative baseline for the current head. This fallback prevents +an immediate same-head requeue. + +The normal readiness gates remain unchanged. Auto-merge submits a new request +only when the head SHA changes and all gates pass. The existing +`MergePRForAutomation` call uses GitHub's `merge_action=default` behavior. +Enabling auto-merge after removal does not weaken this condition. + +This state machine has these transitions: + +```text +ready head A -> queued head A -> removed head A -> blocked head A +blocked head A -> auto-fix or manual repair -> ready head B -> queued head B +``` + +The event ID deduplicates repair work. The head SHA deduplicates queue work. + +The option combinations produce these outcomes: + +- Auto-fix only sends one actionable removal to the agent. It does not requeue. +- Auto-merge only adopts an active attempt or queues a later eligible new head. + It does not repair the failure or requeue the removed head. +- Both options run the full repair-and-requeue flow. + +## Failure and recovery + +- If the timeline query fails, Kandev preserves the prior removal snapshot. +- If the reason mapper does not recognize a value, Kandev records the value as + `unknown`. It does not start auto-fix from that value. +- If removal evidence is not actionable, Kandev records the status only. +- If the current head SHA is absent, Kandev does not requeue automatically. +- If GitHub rejects a requeue request, the existing error row shows the error. +- If a repair produces no new head, Kandev waits. A user can repair or requeue + the pull request on GitHub. + +## Permissions and security + +The timeline and check reads use the workspace automation identity. The merge +request uses the existing workspace-scoped automation write identity. + +Kandev does not trust the removal reason, check name, check output, or URL as +instructions. Prompt rendering keeps the existing untrusted-data boundary. + +## Observability + +Structured logs identify the task, repository, pull request, removal event, +head SHA, normalized cause, action classification, and decision. Logs do not +include check output. + +The existing per-PR `last_error` and automation-state event expose failed +reads, dispatches, and queue requests to the automation surface. + +## Related decisions and designs + +- [GitHub PR Merge Queue requirements](../requirements/github-pr-merge-queue.md) +- [Task PR Automation Controls design](../../ui/system-design/ci-pr-automation-01.md) +- [PR agent notification decision](../../../decisions/0051-pr-agent-notifications-extend-task-pr-automation.md) diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 6817a66a6f..c55308a372 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -50,6 +50,7 @@ contract that other capabilities can reuse. - [Backend-owned cancel-turn progress](requirements/cancel-turn-progress.md) - [Responsive Changes Walkthrough Action](requirements/changes-walkthrough-toolbar-width.md) - [Task PR Automation Controls](requirements/ci-pr-automation.md) +- [Merge Queue Recovery Controls](requirements/ci-pr-merge-queue-recovery-controls.md) - [Clarification Shared Context](requirements/clarification-context.md) - [Clarification submit feedback](requirements/clarification-submit-feedback.md) - [Command-panel Sidebar Task Reveal](requirements/command-panel-sidebar-task-reveal.md) @@ -149,6 +150,7 @@ contract that other capabilities can reuse. - [Task PR Automation Controls System Design Part 1](system-design/ci-pr-automation-01.md) - [Task PR Automation Controls System Design Part 2](system-design/ci-pr-automation-02.md) - [Task PR Automation Controls System Design Part 3](system-design/ci-pr-automation-03.md) +- [Merge Queue Recovery Controls](system-design/ci-pr-merge-queue-recovery-controls.md) - [Entity Reference Composer](system-design/entity-reference-composer.md) - [Persistent status motion](system-design/persistent-status-motion.md) - [Prompt History Panel](system-design/prompt-history-panel.md) diff --git a/docs/specs/ui/requirements/ci-pr-merge-queue-recovery-controls.md b/docs/specs/ui/requirements/ci-pr-merge-queue-recovery-controls.md new file mode 100644 index 0000000000..99f6d370e8 --- /dev/null +++ b/docs/specs/ui/requirements/ci-pr-merge-queue-recovery-controls.md @@ -0,0 +1,78 @@ +--- +status: draft +system: ui +created: 2026-08-24 +owners: + - Kandev +--- +# Merge Queue Recovery Controls Requirements + +## Overview + +The PR automation surface already separates repair from merge policy. Merge +queue recovery uses those controls because it has the same two decisions. + +The surface must explain queue recovery without adding a third switch. It must +also show the current recovery state on desktop and mobile. + +## Requirements + +### REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001: Queue-aware automation controls + +**Intent:** A user can understand and control queue recovery from the existing +per-PR automation surface. + +#### Acceptance criteria + +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.1:** The automation surface shall use + the existing auto-fix and auto-merge switches. It shall not add a separate + queue-recovery switch. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.2:** The auto-fix control shall keep + its current label. Its help shall state that actionable merge-queue failures + can start an auto-fix round. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.3:** The auto-merge control shall use + the label `Auto-merge or requeue when ready`. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.4:** The automation surface shall show + one compact merge-queue status line for the selected linked pull request. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.5:** The status line shall distinguish + an active queue entry, an actionable removal, an accepted repair request, a + same-head wait, and a new-head check wait. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.6:** The information control shall + explain the switch combinations and the same-head retry guard. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.7:** The task prompt editor shall + explain that `{{pr.feedback}}` can include queue-removal context and + available failed merge-group checks. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.8:** The desktop hover popover and the + mobile PR status drawer shall show the same controls, status, and errors. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.9:** On a phone, each switch row shall + keep a 44-pixel touch target. The status shall not cause horizontal document + overflow. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.10:** The section title and subtitle + shall distinguish normal automation, active merge-queue automation, and + recovery after removal. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.11:** The two switch labels shall keep + stable accessible names across queue states. Contextual supporting text may + explain what each saved option will do next. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.12:** When the selected pull request is + already queued, the surface shall explain that auto-fix watches for a future + queue failure and auto-merge will not create a second queue attempt. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.13:** After removal, the surface shall + explain whether Kandev will send the failure to the agent, wait for a new + commit, wait for checks, or stop because the needed option is disabled. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.14:** The removal subtitle shall use a + localized cause that Kandev classified. When no classified cause exists, it + shall use a localized generic removal message. +- **AC-UI-CI-PR-MERGE-QUEUE-RECOVERY-001.15:** The surface shall not show the + raw provider reason as a localized label or use it as a switch name. + +## Related requirements + +- [GitHub PR Merge Queue Recovery](../../integrations/requirements/github-pr-merge-queue-recovery.md) +- [Task PR Automation Controls](ci-pr-automation.md) + +## Out of scope + +- A new queue-management panel. +- A manual requeue action in the automation surface. +- A per-PR auto-fix prompt override. +- GitLab control labels or behavior. diff --git a/docs/specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md b/docs/specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md new file mode 100644 index 0000000000..a0b40e693f --- /dev/null +++ b/docs/specs/ui/system-design/ci-pr-merge-queue-recovery-controls.md @@ -0,0 +1,173 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001 +created: 2026-08-24 +owners: + - Kandev +--- +# Merge Queue Recovery Controls System Design + +## Purpose and boundaries + +This design extends the existing PR automation popover and phone drawer. The +two existing switches control repair and queue submission. + +The UI does not own provider classification or retry policy. It renders the +state from `TaskPR`, per-PR options, and per-PR automation state. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-UI-CI-PR-MERGE-QUEUE-RECOVERY-001` | [Control composition](#control-composition), [Responsive behavior](#responsive-behavior) | + +## Control composition + +`PRCIAutomationControls` keeps this order: + +1. A contextual section title and selected PR subtitle. +2. `Auto-fix CI and address comments`. +3. `Auto-merge or requeue when ready`. +4. One non-interactive merge-queue status line when queue context exists. +5. The existing `Review follow-up` disclosure. +6. The existing error row. + +No queue-recovery switch appears. Auto-fix owns agent repair work. Auto-merge +owns direct merge, initial enqueue, and safe requeue. + +The title and subtitle use these contexts: + +| Context | Title | Subtitle example | +| --- | --- | --- | +| No active queue or removal | `Automation` | `Applies to PR #3000 only` | +| Active queue entry | `Merge queue automation` | `PR #3000 is in the merge queue` | +| Removed with a classified cause | `Merge queue recovery` | `PR #3000 was removed: checks failed` | +| Removed with no classified cause | `Merge queue recovery` | `PR #3000 was removed from the merge queue` | + +The switch labels do not change with these contexts. They are the stable names +of saved per-PR preferences. Short supporting text changes to explain the next +effect: + +| Context | Auto-fix supporting text | Auto-merge supporting text | +| --- | --- | --- | +| Active queue entry | `Will repair failed queue checks or conflicts` | `Current queue attempt is already active` | +| Removed, actionable, auto-fix off | `Enable to send this queue failure to the agent` | `Will requeue after a new commit passes checks` | +| Removed, repair accepted | `Repair requested for this removal` | `Waiting for a new commit before requeue` | +| Removed, not actionable | `No automatic repair for this removal` | `Will requeue after a new commit passes checks` | +| New head, checks pending | `The fix created a new commit` | `Waiting for required checks` | + +When auto-merge is off, the last two auto-merge descriptions instead state +that automatic requeue is off. Supporting text is not part of the switch's +accessible name. + +`CIAutomationOptionRows` uses one pure presentation helper. The helper derives +these states: + +- `queued`: An active queue entry exists. +- `removed_actionable`: An actionable removal exists, but no repair was + accepted for it. +- `removed_not_actionable`: A manual, branch-protection, or unknown removal + exists. Kandev shows the event without offering automatic repair as its next + result. +- `repair_requested`: The latest removal has an accepted auto-fix round, and + the pull-request head is unchanged. The UI does not claim that the agent is + running because durable acceptance does not prove execution state. +- `waiting_for_commit`: The latest queue attempt left the queue, auto-merge is + enabled, and the pull-request head is unchanged. +- `waiting_for_checks`: The head changed after removal, auto-merge is enabled, + and one or more normal merge gates have not passed. +- `none`: No active or retained recovery state needs a line. + +The helper can combine `repair_requested` and `waiting_for_commit` into +`Repair requested. Waiting for a new commit.` A disabled option changes the +supporting text, not the provider state. + +The status line uses translated labels and a muted semantic style. A destructive +error continues to use `CIAutomationErrorRow`. + +The header information control explains these combinations: + +- Auto-fix only repairs an actionable queue removal. +- Auto-merge only submits an eligible head. +- Both controls form the repair-and-requeue loop. +- Kandev never requeues the same head after removal. + +When automation is enabled on an already queued pull request, the visible +active entry becomes the baseline. The UI updates the saved option immediately, +but it does not show a new queue submission. Auto-fix watches for a later +actionable removal. Auto-merge adopts the current attempt. + +The prompt dialog states that `{{pr.feedback}}` can include queue-removal +context and available failed merge-group checks. + +## Data contracts + +`apps/web/lib/types/github.ts` adds the queue-recovery snapshot fields to +`TaskPR`. It adds `last_queue_attempt_head_sha` and +`last_queue_fix_event_id` to `TaskCIPRAutomationState`. It also adds the +normalized `last_queue_removal_cause` enum. + +The subtitle and status use this normalized enum. They never insert the raw +provider reason into a translation key or accessible name. If the enum is +`unknown`, the UI uses the generic removal copy. + +The UI reads only existing boot, HTTP, and WebSocket data. It does not add a +fetch from the component. + +## Responsive behavior + +### Desktop outcome and mobile entry point + +A fine pointer opens the existing hover popover. A phone user taps the existing +PR status chip and uses the existing drawer. + +### Nearest mobile exemplar + +`PRStatusChipDrawer` is the nearest shipped surface. It supplies the inset +drawer, internal scroll region, and safe-area behavior. + +### Information hierarchy and primary action + +The two switches remain the primary controls. The status line explains their +current queue outcome. The PR status chip remains the mobile entry point. + +### Presentation rationale + +Queue recovery is short status content inside an existing temporary surface. +It does not need a new route, drawer, or nested disclosure. + +### Geometry and shared logic + +`CIAutomationRow` keeps its 44-pixel coarse-pointer height. The status line can +wrap inside the drawer, but it cannot create document-level horizontal scroll. +The existing drawer remains the only vertical scroll owner. + +Desktop and mobile share state derivation, copy, and action handlers. Existing +responsive wrappers continue to own presentation. + +## Accessibility + +The updated auto-merge label becomes the switch accessible name. The queue +status line uses status text, not color alone. + +The information control remains reachable by keyboard and touch. Its copy +names the same-head retry guard. + +## Localization + +All new labels and explanations use the `github` namespace. The change updates +English, Portuguese, Simplified Chinese, Hong Kong Chinese, and Taiwan Chinese +catalogs. The Traditional Chinese catalogs use `pnpm run i18n:zh-hant`. + +User-facing copy does not use a Unicode em dash. + +## Verification + +Focused component tests cover label changes, state derivation, and help copy. +Desktop Playwright coverage uses the current hover popover. Mobile coverage +uses a tap and the `mobile-chrome` project. + +The mobile test proves the 44-pixel switch rows, status visibility, internal +drawer scroll, and no document-level horizontal overflow.