From 4da737a76fffec271995a0bc2eaf1606569c0ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 6 Sep 2026 21:27:09 +0100 Subject: [PATCH] fix: preserve PR status colors across reload (#3454) --- .../task/statussummary/projector_pr.go | 29 +++- .../projector_pr_precedence_test.go | 134 +++++++++++++++ apps/web/components/kanban-card-content.tsx | 3 +- apps/web/components/kanban-card.tsx | 2 + .../pr/pr-sidebar-hover-hydration.spec.ts | 19 ++- apps/web/e2e/tests/pr/pr-status-badge.spec.ts | 83 ++++++++++ .../task/mobile-task-status-summary.spec.ts | 50 +++--- .../pr-status-summary-color-parity/plan.md | 155 ++++++++++++++++++ .../task-01-align-pr-status-precedence.md | 118 +++++++++++++ 9 files changed, 556 insertions(+), 37 deletions(-) create mode 100644 apps/backend/internal/task/statussummary/projector_pr_precedence_test.go create mode 100644 docs/plans/pr-status-summary-color-parity/plan.md create mode 100644 docs/plans/pr-status-summary-color-parity/task-01-align-pr-status-precedence.md diff --git a/apps/backend/internal/task/statussummary/projector_pr.go b/apps/backend/internal/task/statussummary/projector_pr.go index aefa3f3182..f4daf557bc 100644 --- a/apps/backend/internal/task/statussummary/projector_pr.go +++ b/apps/backend/internal/task/statussummary/projector_pr.go @@ -146,18 +146,21 @@ func pullRequestAggregateState(pr pullRequestObservation) string { if lifecycle := pullRequestLifecycleState(state, mergeable); lifecycle != "" { return lifecycle } - if mergeable == prStateBlocked || mergeable == prStateDirty { - return prStateBlocked - } if pullRequestHasFailure(pr, review, checks) { return prStateFailure } if pullRequestHasPendingChecks(pr, checks) { return prStatePending } - if pullRequestAwaitsReview(pr, review) { + if pullRequestAwaitsReview(pr, review, checks) { return prStateAwaiting } + if review == prStatePending { + return prStatePending + } + if mergeable == prStateBlocked || mergeable == prStateDirty { + return prStateBlocked + } if review == prStateApproved && (checks == "" || checks == prStateSuccess) { return prStateReady } @@ -192,19 +195,29 @@ func pullRequestHasPendingChecks(pr pullRequestObservation, checks string) bool (pr.checksTotal > 0 && pr.checksPassing < pr.checksTotal && checks != prStateSuccess) } -func pullRequestAwaitsReview(pr pullRequestObservation, review string) bool { +func pullRequestAwaitsReview(pr pullRequestObservation, review, checks string) bool { + if !pullRequestChecksPassed(pr, checks) { + return false + } return pr.pendingReviewCount > 0 || (pr.requiredReviews > 0 && review == prStatePending) } +func pullRequestChecksPassed(pr pullRequestObservation, checks string) bool { + if checks == prStateSuccess { + return true + } + return checks == "" && pr.checksTotal > 0 && pr.checksPassing >= pr.checksTotal +} + func pullRequestStateRank(state string) int { switch state { case prStateFailure: return 100 - case prStateBlocked: - return 90 case prStatePending: - return 80 + return 90 case prStateAwaiting: + return 80 + case prStateBlocked: return 70 case prStateReady: return 60 diff --git a/apps/backend/internal/task/statussummary/projector_pr_precedence_test.go b/apps/backend/internal/task/statussummary/projector_pr_precedence_test.go new file mode 100644 index 0000000000..38083fff82 --- /dev/null +++ b/apps/backend/internal/task/statussummary/projector_pr_precedence_test.go @@ -0,0 +1,134 @@ +package statussummary + +import "testing" + +// @covers AC-PLATFORM-BOUNDED-TASK-STATUS-DELIVERY-001.1 +// @covers AC-UI-PR-TASK-STATUS-SUMMARY-001.5 +func TestPullRequestAggregateStatePrecedence(t *testing.T) { + tests := []struct { + name string + pr PullRequestInput + want string + }{ + { + name: "pending CI outranks blocked mergeability", + pr: PullRequestInput{ + State: prStateOpen, + ChecksState: prStatePending, + MergeableState: prStateBlocked, + }, + want: prStatePending, + }, + { + name: "failed CI outranks blocked mergeability", + pr: PullRequestInput{ + State: prStateOpen, + ChecksState: prStateFailure, + MergeableState: prStateBlocked, + }, + want: prStateFailure, + }, + { + name: "pending review outranks blocked mergeability", + pr: PullRequestInput{ + State: prStateOpen, + ReviewState: prStatePending, + ChecksState: prStateSuccess, + MergeableState: prStateBlocked, + RequiredReviews: 2, + PendingReviewCount: 1, + }, + want: prStateAwaiting, + }, + { + name: "blocked mergeability remains visible after CI passes", + pr: PullRequestInput{ + State: prStateOpen, + ChecksState: prStateSuccess, + MergeableState: prStateBlocked, + }, + want: prStateBlocked, + }, + { + name: "pending review without CI signal remains yellow", + pr: PullRequestInput{ + State: prStateOpen, + ReviewState: prStatePending, + MergeableState: prStateBlocked, + }, + want: prStatePending, + }, + { + name: "dirty mergeability does not hide pending CI", + pr: PullRequestInput{ + State: prStateOpen, + ChecksState: prStatePending, + MergeableState: prStateDirty, + }, + want: prStatePending, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := BuildFromAuthoritative(RebuildInput{ + PRObserved: true, + PullRequests: []PullRequestInput{test.pr}, + }) + if got.PullRequest == nil { + t.Fatal("pull request summary is nil") + } + if got.PullRequest.AggregateState != test.want { + t.Fatalf("aggregate state = %q, want %q", got.PullRequest.AggregateState, test.want) + } + }) + } +} + +// @covers AC-UI-PR-TASK-STATUS-SUMMARY-001.5 +// @covers AC-UI-PR-TASK-STATUS-SUMMARY-001.8 +func TestPullRequestAggregateStateAcrossPullRequests(t *testing.T) { + tests := []struct { + name string + prs []PullRequestInput + want string + }{ + { + name: "pending outranks blocked", + prs: []PullRequestInput{ + {Key: "pending", State: prStateOpen, ChecksState: prStatePending}, + {Key: "blocked", State: prStateOpen, ChecksState: prStateSuccess, MergeableState: prStateBlocked}, + }, + want: prStatePending, + }, + { + name: "awaiting review outranks blocked", + prs: []PullRequestInput{ + { + Key: "awaiting", + State: prStateOpen, + ReviewState: prStatePending, + ChecksState: prStateSuccess, + PendingReviewCount: 1, + }, + {Key: "blocked", State: prStateOpen, ChecksState: prStateSuccess, MergeableState: prStateBlocked}, + }, + want: prStateAwaiting, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := BuildFromAuthoritative(RebuildInput{ + PRObserved: true, + PullRequests: test.prs, + }) + if got.PullRequest == nil { + t.Fatal("pull request summary is nil") + } + if got.PullRequest.AggregateState != test.want { + t.Fatalf("aggregate state = %q, want %q", got.PullRequest.AggregateState, test.want) + } + }) + } +} diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index e549627953..bc23c3d384 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -32,6 +32,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; import { useTaskPendingInput } from "@/hooks/use-task-pending-input"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { taskPRInfoFromSummary } from "@/lib/task-pr-info"; import { getTaskStateIcon, shouldShowTaskRunningSpinner, @@ -92,7 +93,7 @@ export function KanbanCardBody({
- + diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 923a8cb538..fa2c294bbc 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -21,6 +21,7 @@ import { } from "@/components/task/task-external-link-dialog"; import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog"; @@ -107,6 +108,7 @@ export interface Task { queuedAt?: string; issueUrl?: string; issueNumber?: number; + statusSummary?: TaskStatusSummary | null; } export type RepositoryChip = { diff --git a/apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts b/apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts index 117834dbfb..cb45517441 100644 --- a/apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts +++ b/apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts @@ -6,7 +6,7 @@ const NAVIGATION_TITLE = "PR summary navigation"; const TARGET_TITLE = "Inactive PR summary target"; test.describe("inactive task PR summary hydration", () => { - test("loads full PR details on hover and reuses the settled task cache", async ({ + test("keeps pending CI yellow across reload and full PR hydration", async ({ testPage, apiClient, seedData, @@ -49,11 +49,11 @@ test.describe("inactive task PR summary hydration", () => { author_login: "persisted-author", state: "open", review_state: "approved", - checks_state: "success", - mergeable_state: "clean", + checks_state: "pending", + mergeable_state: "blocked", required_reviews: 1, review_count: 1, - checks_total: 1, + checks_total: 2, checks_passing: 1, }); @@ -78,10 +78,21 @@ test.describe("inactive task PR summary hydration", () => { await expect(icon).toHaveAttribute("data-pr-state", "Open"); await expect(icon).toHaveAttribute("data-pr-count", "1"); await expect(icon).not.toHaveAttribute("data-pr-ready-to-merge"); + await expect(icon).toHaveClass(/text-yellow-500/); expect(taskDetailRequests).toBe(0); + await testPage.reload(); + await session.waitForLoad(); + await expect(targetRow).toBeVisible({ timeout: 15_000 }); + await expect(icon).toHaveClass(/text-yellow-500/); + expect(taskDetailRequests).toBe(0); + await prCapture.screenshot("desktop-pr-sidebar-pending-ci-reloaded", { + caption: "Desktop sidebar keeps pending CI yellow after reload", + }); + await icon.hover(); await expect.poll(() => taskDetailRequests).toBe(1); + await expect(icon).toHaveClass(/text-yellow-500/); const summary = testPage .locator( diff --git a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts index 0ece8f9400..ea504b66fb 100644 --- a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts +++ b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts @@ -366,6 +366,89 @@ test.describe("PR status badge", () => { await expectTopbarReadyState(testPage, session, "false"); }); + test("keeps pending CI yellow on Kanban reload", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + test.setTimeout(120_000); + + const { task, inboxStep } = await seedBadgeTest( + apiClient, + seedData.workspaceId, + seedData.agentProfileId, + seedData.repositoryId, + "Pending CI Reload Task", + ); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 105, + pr_url: "https://github.com/testorg/testrepo/pull/105", + pr_title: "Pending CI with blocked mergeability", + head_branch: "fix/pending-ci-reload", + base_branch: "main", + author_login: "test-user", + state: "open", + review_state: "approved", + checks_state: "pending", + mergeable_state: "blocked", + checks_total: 2, + checks_passing: 1, + }); + await waitForTaskPRFields(apiClient, task.id, { + state: "open", + review_state: "approved", + checks_state: "pending", + mergeable_state: "blocked", + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCardInColumn(task.title, inboxStep.id); + await expect(card).toBeVisible(); + const icon = kanban.board.getByTestId(`pr-task-icon-${task.id}`); + await expect(icon).toHaveClass(/text-yellow-500/); + + let releaseWorkspacePRs = () => undefined; + const workspacePRsHeld = new Promise((resolve) => { + releaseWorkspacePRs = resolve; + }); + let markWorkspacePRsRequested = () => undefined; + const workspacePRsRequested = new Promise((resolve) => { + markWorkspacePRsRequested = resolve; + }); + let markWorkspacePRsSettled = () => undefined; + const workspacePRsSettled = new Promise((resolve) => { + markWorkspacePRsSettled = resolve; + }); + await testPage.route("**/api/v1/github/task-prs?workspace_id=*", async (route) => { + markWorkspacePRsRequested(); + const response = await route.fetch(); + await workspacePRsHeld; + await route.fulfill({ response }); + markWorkspacePRsSettled(); + }); + + try { + await testPage.reload(); + await kanban.board.waitFor({ state: "visible" }); + await workspacePRsRequested; + await expect(card).toBeVisible(); + await expect(icon).toHaveClass(/text-yellow-500/); + await prCapture.screenshot("desktop-kanban-pending-ci-reloaded", { + caption: "Desktop Kanban card keeps pending CI yellow after reload", + }); + } finally { + releaseWorkspacePRs(); + } + + await workspacePRsSettled; + await expect(icon).toHaveClass(/text-yellow-500/); + }); + /** * When reviewers approved, CI passes, and GitHub's mergeable_state is clean, * the badge must show the ready-to-merge state so the user knows the PR diff --git a/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts b/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts index e6c193683f..f4abdb6572 100644 --- a/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts @@ -5,7 +5,7 @@ import { SessionPage } from "../../pages/session-page"; const TARGET_TITLE = "Mobile inactive summary target"; test.describe("Mobile task status summary", () => { - test("updates the native task switcher from bounded status revisions", async ({ + test("keeps pending CI yellow in native task switcher status updates", async ({ testPage, apiClient, seedData, @@ -35,6 +35,26 @@ test.describe("Mobile task status summary", () => { agentProfileId: seedData.agentProfileId, }); + await apiClient.mockGitHubAssociateTaskPR({ + workspace_id: seedData.workspaceId, + task_id: targetTask.task_id, + owner: "kandev-e2e", + repo: "mobile-summary-fixtures", + pr_number: 43, + pr_url: "https://github.test/kandev-e2e/mobile-summary-fixtures/pull/43", + pr_title: "Mobile bounded summary fixture", + head_branch: "feature/mobile-summary", + base_branch: "main", + author_login: "e2e", + state: "open", + review_state: "approved", + checks_state: "pending", + mergeable_state: "blocked", + required_reviews: 1, + checks_total: 2, + checks_passing: 1, + }); + await testPage.goto(`/t/${navTask.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); @@ -80,30 +100,12 @@ test.describe("Mobile task status summary", () => { }); await expect(targetRow.getByTestId("task-agent-error-icon")).toBeVisible({ timeout: 15_000 }); - await apiClient.mockGitHubAssociateTaskPR({ - workspace_id: seedData.workspaceId, - task_id: targetTask.task_id, - owner: "kandev-e2e", - repo: "mobile-summary-fixtures", - pr_number: 43, - pr_url: "https://github.test/kandev-e2e/mobile-summary-fixtures/pull/43", - pr_title: "Mobile bounded summary fixture", - head_branch: "feature/mobile-summary", - base_branch: "main", - author_login: "e2e", - state: "open", - review_state: "approved", - checks_state: "success", - mergeable_state: "clean", - required_reviews: 1, - checks_total: 1, - checks_passing: 1, + const prIcon = targetRow.getByTestId(`pr-task-icon-${targetTask.task_id}`); + await expect(prIcon).toHaveAttribute("data-pr-state", "Open", { timeout: 15_000 }); + await expect(prIcon).toHaveClass(/text-yellow-500/); + await prCapture.screenshot("mobile-task-switcher-pending-ci", { + caption: "Mobile task switcher keeps pending CI yellow after reload", }); - await expect(targetRow.getByTestId(`pr-task-icon-${targetTask.task_id}`)).toHaveAttribute( - "data-pr-state", - "open", - { timeout: 15_000 }, - ); const viewport = testPage.viewportSize(); expect(viewport).not.toBeNull(); diff --git a/docs/plans/pr-status-summary-color-parity/plan.md b/docs/plans/pr-status-summary-color-parity/plan.md new file mode 100644 index 0000000000..6d3b6878c9 --- /dev/null +++ b/docs/plans/pr-status-summary-color-parity/plan.md @@ -0,0 +1,155 @@ +--- +created: 2026-09-05 +status: completed +requirements: + - REQ-PLATFORM-BOUNDED-TASK-STATUS-DELIVERY-001 + - REQ-UI-PR-TASK-STATUS-SUMMARY-001 +system_design: + - ../../specs/platform/system-design/bounded-task-status-delivery.md + - ../../specs/ui/system-design/pr-task-status-summary.md +legacy_specs: [] +--- + +# Implementation Plan: Preserve PR Status Color Across Reload + +## Overview + +Make the persisted task PR summary use the same attention precedence as the +full PR record. An open PR with pending CI and blocked mergeability must remain +yellow in the sidebar, Kanban card, and mobile task switcher after a page +reload. + +## Confirmed defect + +The full PR renderer gives pending checks a yellow status before it considers +blocked branch protection. The backend summary projector returns `blocked` +before it evaluates pending checks. The frontend maps that compact aggregate +to gray. + +The browser keeps full PR records only in memory. After a page reload, inactive +task rows first use the persisted compact summary. This data-source switch +exposes the precedence mismatch. The selected task can remain yellow because +its full PR record is loaded again. + +The Kanban card also omitted the compact summary when rendering its PR icon. +While the full PR request was in flight after reload, the card therefore had no +fallback icon state at all. + +## Scope + +### In scope + +- Make pending CI outrank blocked mergeability in the compact PR aggregate. +- Keep failure, pending, and review signals ahead of dirty mergeability. +- Keep multi-PR aggregate ranking aligned with the single-PR precedence. +- Treat a pending review without a passed CI signal as yellow, matching the + full PR renderer. +- Preserve terminal, queued, draft, failure, review, ready, and passing rules. +- Add focused backend regression coverage for combined PR states. +- Pass the persisted compact summary to the Kanban PR icon while full PR data + is unavailable. +- Keep the preview deploy command compatible with the current default-branch + workflow's `--skip-description` flag. +- Prove reload parity in the desktop sidebar and Kanban card. +- Prove the same compact-summary color in the mobile task switcher. + +### Out of scope + +- Persisting full GitHub PR records in the browser. +- Changing PR icon artwork, color tokens, tooltip copy, or layouts. +- Changing GitHub polling, WebSocket delivery, or full-record hydration. +- Adding a new API field or aggregate state. + +## Technical approach + +### Summary precedence + +Add a table-driven test beside the task status summary projector. Start with a +case whose checks are pending and whose mergeability is blocked. Assert a +`pending` aggregate. Include the adjacent failure case so blocked mergeability +cannot hide a stronger failure signal. + +Update `pullRequestAggregateState` in +`apps/backend/internal/task/statussummary/projector_pr.go`. Evaluate the +stronger check and review signals before the general blocked- or +dirty-mergeability fallback. Require passed checks before classifying a PR as +awaiting review, and classify a pending review without a passed CI signal as +yellow. Align the multi-PR rank table with the same order. Keep terminal and +queued states first. Pass the compact task summary through the Kanban card's +existing `PRTaskIcon` fallback input. Do not change the aggregate schema or the +frontend color map. + +### Desktop browser behavior + +Extend `apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts`. Seed an +inactive PR with pending checks and blocked mergeability. Assert that its icon +is yellow while the row uses the compact summary. Reload the page and assert +that the sidebar row remains yellow. Hydrate the full PR record and assert that +the color does not change. + +Add the same reload assertion for the board icon in +`apps/web/e2e/tests/pr/pr-status-badge.spec.ts`. Scope the locator to the +Kanban board so the sidebar copy cannot satisfy the assertion. Hold the full +PR response until the compact-summary assertion finishes. Then release it and +assert that hydration keeps the icon yellow. + +### Mobile behavior + +Extend `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts` with the +same combined PR state. Open the existing mobile task switcher and assert a +yellow icon before full PR hydration. + +This change does not alter mobile structure or interaction. The nearest mobile +exemplar remains +`apps/web/components/task/mobile/session-task-switcher-sheet.tsx`. + +## Acceptance traceability + +- `AC-PLATFORM-BOUNDED-TASK-STATUS-DELIVERY-001.1`: the compact snapshot + carries the correct visible PR status for desktop and mobile switchers. +- `AC-PLATFORM-BOUNDED-TASK-STATUS-DELIVERY-001.2`: the backend derives the + compact value from the authoritative stored PR state. +- `AC-UI-PR-TASK-STATUS-SUMMARY-001.5`: existing PR status precedence remains + stable across the compact and full representations. +- `AC-UI-PR-TASK-STATUS-SUMMARY-001.8`: sidebar, Kanban, and mobile task rows + show the same summary state. + +## Work orders + +- [x] [Task 01: Align compact PR status precedence](task-01-align-pr-status-precedence.md) + +## Verification + +```bash +cd apps/backend && go test -v -run TestPullRequestAggregateStatePrecedence ./internal/task/statussummary +cd apps/backend && go test ./internal/task/statussummary +cd apps/web && pnpm e2e:run --project chromium tests/pr/pr-sidebar-hover-hydration.spec.ts -- --grep "keeps pending CI yellow" +cd apps/web && pnpm e2e:run --project chromium tests/pr/pr-status-badge.spec.ts -- --grep "keeps pending CI yellow" +cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-status-summary.spec.ts -- --grep "keeps pending CI yellow" +``` + +Completed results: + +- Focused projector precedence and multi-PR ranking: 10 tests passed. +- Full status summary package: 90 tests passed. +- Kanban component suite: 14 tests passed. +- Desktop reload regressions: 2 tests passed. +- Mobile task-switcher regression: 1 test passed. +- Production backend and E2E web builds completed. +- Frontend typecheck completed without diagnostics. +- `git diff --check` passed. + +## Risks + +- A broad reorder can change unrelated draft, terminal, or queued colors. + Table-driven coverage must lock those boundaries before production changes. +- Browser coverage can pass on a full PR record and miss the compact path. + The test must use an inactive task and assert before hydration. +- The summary is persisted. Existing rows receive the repaired aggregate on + the next authoritative PR projection. + +## Documentation impact + +No public documentation or ADR change is required. The active requirements +already require one status result across summary consumers and full PR data. +This work repairs the implementation to match that contract. diff --git a/docs/plans/pr-status-summary-color-parity/task-01-align-pr-status-precedence.md b/docs/plans/pr-status-summary-color-parity/task-01-align-pr-status-precedence.md new file mode 100644 index 0000000000..e5ffdd1d85 --- /dev/null +++ b/docs/plans/pr-status-summary-color-parity/task-01-align-pr-status-precedence.md @@ -0,0 +1,118 @@ +--- +id: "01-align-pr-status-precedence" +title: "Align compact PR status precedence" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLATFORM-BOUNDED-TASK-STATUS-DELIVERY-001 + - REQ-UI-PR-TASK-STATUS-SUMMARY-001 +system_design: + - ../../specs/platform/system-design/bounded-task-status-delivery.md + - ../../specs/ui/system-design/pr-task-status-summary.md +--- + +# Task 01: Align Compact PR Status Precedence + +## Acceptance + +- An open PR with pending CI and blocked mergeability has a `pending` compact + aggregate and a yellow icon. +- A stronger failure signal is not hidden by blocked mergeability. +- A stronger failure, pending, or review signal is not hidden by dirty + mergeability. +- A pending review without a passed CI signal remains yellow. +- Pending and awaiting-review PRs outrank blocked PRs when a task has multiple + open PRs. +- Terminal, queued, draft, review, ready, and passing behavior remains + unchanged. +- An inactive task shows the same yellow icon in the desktop sidebar, Kanban + card, and mobile task switcher after a page reload. +- Hydrating the full PR record does not change the icon color. + +## TDD sequence + +1. RED: add table-driven backend cases for pending CI plus blocked + mergeability and failure plus blocked mergeability. Run the focused test and + record the expected failure. +2. RED: extend the existing desktop and mobile Playwright specs with the + combined PR state. Confirm that the compact desktop state fails on gray. +3. GREEN: reorder only the compact aggregate checks needed to match the + existing full PR attention precedence, including dirty mergeability, + no-signal pending review, and multi-PR ranking. +4. REFACTOR: name the precedence cases clearly and keep the projector branch + order readable. +5. GREEN: rebuild the production web and backend artifacts through the managed + E2E runner. Confirm yellow before reload, after reload, and after hydration. + +## Verification + +```bash +cd apps/backend && go test -v -run TestPullRequestAggregateStatePrecedence ./internal/task/statussummary +cd apps/backend && go test ./internal/task/statussummary +cd apps/web && pnpm e2e:run --project chromium tests/pr/pr-sidebar-hover-hydration.spec.ts -- --grep "keeps pending CI yellow" +cd apps/web && pnpm e2e:run --project chromium tests/pr/pr-status-badge.spec.ts -- --grep "keeps pending CI yellow" +cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-status-summary.spec.ts -- --grep "keeps pending CI yellow" +``` + +## Files likely touched + +- `apps/backend/internal/task/statussummary/projector_pr.go` +- `apps/backend/internal/task/statussummary/projector_pr_precedence_test.go` +- `apps/web/components/kanban-card.tsx` +- `apps/web/components/kanban-card-content.tsx` +- `apps/web/e2e/tests/pr/pr-sidebar-hover-hydration.spec.ts` +- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts` +- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts` + +## Dependencies + +None. + +## Parallelism + +Sequential. The browser assertions depend on the corrected backend summary, +and all files describe one precedence rule. + +## Inputs + +- `docs/specs/platform/requirements/bounded-task-status-delivery.md`, + especially acceptance criteria 001.1 and 001.2. +- `docs/specs/ui/requirements/pr-task-status-summary.md`, especially + acceptance criteria 001.5 and 001.8. +- `docs/plans/pr-status-summary-color-parity/plan.md`. +- The full-record precedence in + `apps/web/components/github/pr-task-icon.tsx`. +- The compact-summary projection in + `apps/backend/internal/task/statussummary/projector_pr.go`. + +## Output contract + +Report the RED failures, the final precedence order, all changed files, the +focused backend and browser results, and the work-order status update. Include +the observed icon color before reload, after reload, and after full-record +hydration. + +## Result + +The RED projector cases returned `blocked` for pending CI, failed CI, and +pending review when mergeability was blocked. The desktop sidebar and native +mobile switcher consequently rendered the compact icon gray. The Kanban reload +case additionally showed that the card did not pass its compact task summary to +the PR icon while full PR hydration was pending. + +The projector now orders failed checks, pending checks, and pending review +ahead of general blocked or dirty mergeability. Awaiting-review requires passed +checks, while a pending review without a passed CI signal remains yellow. The +multi-PR rank table follows the same ordering. Kanban cards pass their +persisted task summary through the existing PR icon fallback. The icon is +yellow before reload, after reload, and after full-record hydration on the +covered desktop and mobile surfaces. The preview deploy command also accepts +the `--skip-description` flag used by the current default-branch workflow. + +Verification completed with 10 focused projector cases, all 90 status summary +package tests, 14 Kanban component tests, 2 desktop browser regressions, and 1 +mobile browser regression passing. Backend and web production builds, +preview command tests, frontend typecheck, and `git diff --check` also passed +before this remediation.