fix(board): CI pill stuck on "CI running" after Forgejo workflow finishes #615

Closed
opened 2026-04-30 22:13:05 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As an operator watching the planner board, I want the PR pill on a card to flip from CI running to CI passed (or CI failed) the moment the workflow run completes on Forgejo, so I don't think a green run is still spinning and waste time investigating a phantom stall.

Repro

Observed on board card for charles/claude-hooks#605, PR #613 (refactor: hoist SSE…), 2026-05-01:

  • Card pill renders 🔄 CI running long after Forgejo Actions reports the run as finished.
  • GET /api/v1/repos/charles/claude-hooks/actions/runs?head_sha=6ceaa8240503efea4d702e3b4bb0c45929545ea3 returns:
{ "id": 2652, "name": null, "status": "success", "conclusion": null }

The run is terminal (status: "success") but the adapter does not recognise this shape.

Root cause

apps/server/src/infrastructure/forge/forgejo-adapter.ts:125-145 (toForgeWorkflowRun) only handles two shapes:

  1. status: "completed" + conclusion: "success"|"failure"|"cancelled"|"skipped"|... — the documented Forgejo / GitHub Actions shape.
  2. status: "queued" | "in_progress" | "waiting" — pre-terminal.

Anything else falls through to "unknown". The Forgejo instance at forge.jacquin.app (v15.something) emits a third shape: status already folded to the conclusion ("success", "failure", etc.) with conclusion: null. The adapter maps these to "unknown".

Downstream, both folders treat "unknown" as "still pending":

  • apps/server/src/domain/views/board.ts:267-282 foldCiStateelse hasPending = true (line 276) → returns "pending" → board pill stays on CI running.
  • apps/server/src/domain/views/pipeline.ts:169-188 foldWorkflowRuns — same else hasPending (line 182) → pipeline view also reports running indefinitely.

The PR-state cache in board.ts:136 (PR_CACHE_TTL_MS = 10_000) refreshes every 10 s, so this is not a staleness bug — every refresh re-fetches the same "success" payload and re-folds it to "unknown""pending".

Acceptance criteria

Adapter (root fix)

  • apps/server/src/infrastructure/forge/forgejo-adapter.ts::toForgeWorkflowRun accepts already-folded statuses as a third shape: when raw.status is "success" | "failure" | "cancelled" | "skipped" it passes through directly without consulting conclusion.
  • "completed" + conclusion shape continues to work unchanged.
  • "queued" | "in_progress" | "waiting" shape continues to work unchanged.
  • Anything else still folds to "unknown" — but "unknown" should be rare now, not the common case.
  • Apply the same logic to the GitHub adapter (github-adapter.ts:722) and GitLab adapter (gitlab-adapter.ts:828) only if they exhibit the same gap; verify by inspection, do not blindly mirror.

Folders (defensive secondary fix)

  • domain/views/board.ts::foldCiState and domain/views/pipeline.ts::foldWorkflowRuns both have an else hasPending = true for unknown. Document why (terminal-but-unrecognised → render as still running so we don't false-claim success). Decision is fine, but add an inline comment so a future reader does not flip it. No behaviour change here — the adapter fix is what actually resolves the bug.

Tests

  • Adapter unit test: toForgeWorkflowRun({ status: "success", conclusion: null, ... })status: "success". Repeat for failure, cancelled, skipped.
  • Adapter unit test: existing "completed" + conclusion cases still pass.
  • pipeline.test.ts already has CI-fold coverage — extend to assert the new adapter-output shape folds to success cleanly.
  • Board view test: a PR whose run returns the pre-folded success shape produces pr.ci === "success" (not "pending").

Manual verification

  • Open /planner/board with a card whose PR has a recently-finished workflow run.
  • Pill flips to ✓ CI passed within one cache cycle (≤ 10 s).
  • Failed run flips to CI failed correctly (find a PR with a failing run, or fake one in the API mock).

Out of scope

  • Forgejo-version detection / version-gating the adapter logic — pass-through is forward- and backward-compatible, no version branch needed.
  • Polling cadence / SSE push for CI completion — out of scope here, separate ticket if 10 s feels laggy.
  • The container.lazy_* SSE events from M26-5 (#592) — unrelated to this card pill.

References

  • apps/server/src/infrastructure/forge/forgejo-adapter.ts:125-145 — adapter mapper, root site of the bug
  • apps/server/src/infrastructure/forge/forgejo-api.ts:553-584WorkflowRunSummary shape + listWorkflowRuns (no change needed; the raw API can carry either shape)
  • apps/server/src/domain/views/board.ts:267-282foldCiState
  • apps/server/src/domain/views/pipeline.ts:169-188foldWorkflowRuns
  • apps/web/src/components/board/board-card.tsx:307-369 — card pill rendering (no change needed)
  • Repro PR: charles/claude-hooks#613 head 6ceaa8240503efea4d702e3b4bb0c45929545ea3
## User story As an operator watching the planner board, I want the PR pill on a card to flip from `CI running` to `CI passed` (or `CI failed`) the moment the workflow run completes on Forgejo, so I don't think a green run is still spinning and waste time investigating a phantom stall. ## Repro Observed on board card for `charles/claude-hooks#605`, PR #613 (`refactor: hoist SSE…`), 2026-05-01: - Card pill renders `🔄 CI running` long after Forgejo Actions reports the run as finished. - `GET /api/v1/repos/charles/claude-hooks/actions/runs?head_sha=6ceaa8240503efea4d702e3b4bb0c45929545ea3` returns: ```json { "id": 2652, "name": null, "status": "success", "conclusion": null } ``` The run is terminal (`status: "success"`) but the adapter does not recognise this shape. ## Root cause `apps/server/src/infrastructure/forge/forgejo-adapter.ts:125-145` (`toForgeWorkflowRun`) only handles two shapes: 1. `status: "completed"` + `conclusion: "success"|"failure"|"cancelled"|"skipped"|...` — the documented Forgejo / GitHub Actions shape. 2. `status: "queued" | "in_progress" | "waiting"` — pre-terminal. Anything else falls through to `"unknown"`. The Forgejo instance at `forge.jacquin.app` (v15.something) emits a third shape: `status` already folded to the conclusion (`"success"`, `"failure"`, etc.) with `conclusion: null`. The adapter maps these to `"unknown"`. Downstream, both folders treat `"unknown"` as "still pending": - `apps/server/src/domain/views/board.ts:267-282` `foldCiState` — `else hasPending = true` (line 276) → returns `"pending"` → board pill stays on `CI running`. - `apps/server/src/domain/views/pipeline.ts:169-188` `foldWorkflowRuns` — same `else hasPending` (line 182) → pipeline view also reports `running` indefinitely. The PR-state cache in `board.ts:136` (`PR_CACHE_TTL_MS = 10_000`) refreshes every 10 s, so this is not a staleness bug — every refresh re-fetches the same `"success"` payload and re-folds it to `"unknown"` → `"pending"`. ## Acceptance criteria ### Adapter (root fix) - [ ] `apps/server/src/infrastructure/forge/forgejo-adapter.ts::toForgeWorkflowRun` accepts already-folded statuses as a third shape: when `raw.status` is `"success" | "failure" | "cancelled" | "skipped"` it passes through directly without consulting `conclusion`. - [ ] `"completed"` + `conclusion` shape continues to work unchanged. - [ ] `"queued" | "in_progress" | "waiting"` shape continues to work unchanged. - [ ] Anything else still folds to `"unknown"` — but `"unknown"` should be rare now, not the common case. - [ ] Apply the same logic to the GitHub adapter (`github-adapter.ts:722`) and GitLab adapter (`gitlab-adapter.ts:828`) only if they exhibit the same gap; verify by inspection, do not blindly mirror. ### Folders (defensive secondary fix) - [ ] `domain/views/board.ts::foldCiState` and `domain/views/pipeline.ts::foldWorkflowRuns` both have an `else hasPending = true` for unknown. Document why (terminal-but-unrecognised → render as still running so we don't false-claim success). Decision is fine, but add an inline comment so a future reader does not flip it. No behaviour change here — the adapter fix is what actually resolves the bug. ### Tests - [ ] Adapter unit test: `toForgeWorkflowRun({ status: "success", conclusion: null, ... })` → `status: "success"`. Repeat for `failure`, `cancelled`, `skipped`. - [ ] Adapter unit test: existing `"completed" + conclusion` cases still pass. - [ ] `pipeline.test.ts` already has CI-fold coverage — extend to assert the new adapter-output shape folds to `success` cleanly. - [ ] Board view test: a PR whose run returns the pre-folded `success` shape produces `pr.ci === "success"` (not `"pending"`). ### Manual verification - [ ] Open `/planner/board` with a card whose PR has a recently-finished workflow run. - [ ] Pill flips to `✓ CI passed` within one cache cycle (≤ 10 s). - [ ] Failed run flips to `CI failed` correctly (find a PR with a failing run, or fake one in the API mock). ## Out of scope - Forgejo-version detection / version-gating the adapter logic — pass-through is forward- and backward-compatible, no version branch needed. - Polling cadence / SSE push for CI completion — out of scope here, separate ticket if 10 s feels laggy. - The `container.lazy_*` SSE events from M26-5 (#592) — unrelated to this card pill. ## References - `apps/server/src/infrastructure/forge/forgejo-adapter.ts:125-145` — adapter mapper, root site of the bug - `apps/server/src/infrastructure/forge/forgejo-api.ts:553-584` — `WorkflowRunSummary` shape + `listWorkflowRuns` (no change needed; the raw API can carry either shape) - `apps/server/src/domain/views/board.ts:267-282` — `foldCiState` - `apps/server/src/domain/views/pipeline.ts:169-188` — `foldWorkflowRuns` - `apps/web/src/components/board/board-card.tsx:307-369` — card pill rendering (no change needed) - Repro PR: `charles/claude-hooks#613` head `6ceaa8240503efea4d702e3b4bb0c45929545ea3`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#615
No description provided.