Milestone cards show no issues on this milestone despite correct counts; View N issues filter does not apply #564

Closed
opened 2026-09-15 12:19:02 +00:00 by crueber · 1 comment
Owner

What's observed

Two screenshots of the milestones UI contradict each other:

  • Milestone card (milestones page): the card header reports correct counts (1 open · 1 closed on milestone 1.1, 1 open · 0 closed on 1.2), the progress bar renders partially filled — but the card body's issue list says "no issues on this milestone" on both cards, with "View 2 issues" / "View 1 issue" buttons promising the issues exist.
  • Issue #3 detail page: the right sidebar renders MILESTONE 1.1 correctly, so the thread-header association exists and resolves.

So: counts know about the issues, the thread header knows about the milestone, but every surface fed by the milestone-filtered issue list shows nothing — and the "View N issues" link lands on the issues page without showing the issues it promised.

Diagnosis (static — code reading + user evidence, no local repro)

The divergence: counts and the card list come from two independent stores

  • Counts ({m.open_issues} open · {m.closed_issues} closed, web/src/pages/Milestones.jsx:151) come from the milestone object's denormalized counters (internal/issues/model.go:169-170; updated by moveMilestone/bumpMilestone, internal/issues/service.go:560-583 / internal/issues/store.go:486-510).
  • Card body list (MilestoneIssues, web/src/pages/Milestones.jsx:33-36) comes from GET …/api/issues?milestone=<id> — the server-side milestone= filter (internal/issues/http.go:397-399 → filterCards, internal/issues/service.go:1049-1060) which matches against the index cards' Card.Milestone field.
  • The issues-page milestone chip renders from the thread header (detail sidebar), which is why it is correct.

The milestone filter matches *c.Milestone != strings.ToLower(f.Milestone) — the card's stored id vs the query id. Verified consistent in the current tree: PATCH stores the canonical m.ID (service.go:494-502, id from allocMilestoneID = lowercase %06x hex, store.go:47-68), the filter lowercases the query, and the SDK sends the id verbatim (web/sdk/src/issues.js qs(); web/src/lib/milestones.js milestoneFilterHref).

Root cause: a lost index-card update persists forever because the fast-path gate checks coverage, not freshness

The list is served index-first (ListIssues, internal/issues/service.go:827-884): when indexComplete(ix, next) is true, the window is filled from the persisted index cards alone and the header-scan LIST fallback never runs. Two structural gaps combine:

  1. updateIndex swallows every failure silently (internal/issues/store.go:227-259): all CAS-retry/store errors return with no signal. A lost index update — documented as expected ("lost index update, crash between the header CAS and the index CAS", service.go:842-848) — leaves the old card in place and nobody notices. bumpMilestone discards its casUpdate result the same way (store.go:490).
  2. indexComplete verifies only that every num < counter has a card (internal/issues/service.go:940-957) — never that the cards carry the current projection. A card written before the milestone field existed in the Card projection, or one whose update was lost, stays "complete" and wins the fast path indefinitely: the header (truth) is never consulted, the card never repairs, and GET issues?milestone=<id> matches nothing → "no issues on this milestone" on every load, while the counters (separate store) stay right.

The thread header keeps the correct milestone id (hence the #3 sidebar), but the card the filter reads is stale/missing the id.

Secondary, in-tree defect: "View N issues" promises more than the landing shows

milestoneFilterHref emits ?milestone=<id> with no state= param (web/src/lib/milestones.js). Issues.jsx resolves an absent state to open-only (web/src/pages/Issues.jsx:198-211, the #323 default, noted as "deliberate" there). So even with the filter returning rows, a "View 2 issues" button (1 open + 1 closed) lands on a page showing only the open one — the count promise and the landing disagree by construction. The #416 milestone select binds search.milestone verbatim (resolveMilestoneFilter, web/src/lib/issueFilters.js:50-56), so the dropdown shows the milestone correctly once rows come back.

Fix prescription

  1. Detect and repair stale index cards. Stamp the persisted Index with a Card-projection version; when absent or older than the current projection, indexComplete returns false so the LIST fallback heals the window (header wins). Stop swallowing updateIndex/bumpMilestone failures (log + counters/repair sweep). Provide a one-shot backfill that rebuilds any card disagreeing with its thread header — verify the live instance first: compare GET …/api/issues?milestone=<id> against the unfiltered list and the per-thread GET for the affected repo.
  2. Align the deep link with its promise. Make milestoneFilterHref carry the state the count implies (e.g. state=all for the open+closed total) — or count only open issues in the button label. Pick one and note it; do not leave the half-promise.

Acceptance criteria

  • Milestone card body lists the milestone's issues, matching the header counts (closed rows per design) — verified against a repo where counters > 0.
  • "View N issues" lands on the issues page filtered to that milestone; the #416 milestone dropdown shows it selected.
  • The landing shows the same set the N promised (state carried or label honest) and survives a page refresh (deep link re-hydrates).
  • A stale/absent Card projection in the index no longer suppresses the list: index fast path falls back to the header scan when the projection version is missing/old.
  • Lost updateIndex/bumpMilestone failures are logged (or surfaced), with a repair path backfilled from thread headers.
  • Headless test: card-list fetch + link-param round trip (milestoneFilterHref output → Issues.jsx query → filter semantics) in web/test/unit/.
  • Both themes verified on the milestones page cards.
  • 390px viewport: cards, buttons, and filtered list wrap without overflow.

References: #314 (milestones UI cards/View button), #416 (milestone dropdown deep-link binding), #484 (milestone icons/chips), #119 (milestone picker + milestone= filter introduction), #323 (open-only list default), #318 (cross-page cache invalidation).

## What's observed Two screenshots of the milestones UI contradict each other: - **Milestone card (milestones page):** the card header reports correct counts (`1 open · 1 closed` on milestone 1.1, `1 open · 0 closed` on 1.2), the progress bar renders partially filled — but the card body's issue list says **"no issues on this milestone"** on both cards, with "View 2 issues" / "View 1 issue" buttons promising the issues exist. - **Issue #3 detail page:** the right sidebar renders **MILESTONE 1.1** correctly, so the thread-header association exists and resolves. So: counts know about the issues, the thread header knows about the milestone, but every surface fed by the milestone-filtered issue **list** shows nothing — and the "View N issues" link lands on the issues page without showing the issues it promised. ## Diagnosis (static — code reading + user evidence, no local repro) ### The divergence: counts and the card list come from two independent stores - **Counts** (`{m.open_issues} open · {m.closed_issues} closed`, `web/src/pages/Milestones.jsx:151`) come from the milestone object's **denormalized counters** (`internal/issues/model.go:169-170`; updated by `moveMilestone`/`bumpMilestone`, `internal/issues/service.go:560-583` / `internal/issues/store.go:486-510`). - **Card body list** (`MilestoneIssues`, `web/src/pages/Milestones.jsx:33-36`) comes from `GET …/api/issues?milestone=<id>` — the server-side `milestone=` filter (`internal/issues/http.go:397-399` → `filterCards`, `internal/issues/service.go:1049-1060`) which matches against the **index cards'** `Card.Milestone` field. - The issues-page milestone chip renders from the thread header (detail sidebar), which is why it is correct. The milestone filter matches `*c.Milestone != strings.ToLower(f.Milestone)` — the card's stored id vs the query id. Verified consistent in the current tree: PATCH stores the canonical `m.ID` (`service.go:494-502`, id from `allocMilestoneID` = lowercase `%06x` hex, `store.go:47-68`), the filter lowercases the query, and the SDK sends the id verbatim (`web/sdk/src/issues.js` `qs()`; `web/src/lib/milestones.js` `milestoneFilterHref`). ### Root cause: a lost index-card update persists forever because the fast-path gate checks coverage, not freshness The list is served **index-first** (`ListIssues`, `internal/issues/service.go:827-884`): when `indexComplete(ix, next)` is true, the window is filled from the persisted index cards alone and the header-scan LIST fallback never runs. Two structural gaps combine: 1. **`updateIndex` swallows every failure silently** (`internal/issues/store.go:227-259`): all CAS-retry/store errors `return` with no signal. A lost index update — documented as expected ("lost index update, crash between the header CAS and the index CAS", `service.go:842-848`) — leaves the old card in place and nobody notices. `bumpMilestone` discards its `casUpdate` result the same way (`store.go:490`). 2. **`indexComplete` verifies only that every `num < counter` has *a* card** (`internal/issues/service.go:940-957`) — never that the cards carry the *current* projection. A card written before the `milestone` field existed in the Card projection, or one whose update was lost, stays "complete" and wins the fast path **indefinitely**: the header (truth) is never consulted, the card never repairs, and `GET issues?milestone=<id>` matches nothing → "no issues on this milestone" on every load, while the counters (separate store) stay right. The thread header keeps the correct milestone id (hence the #3 sidebar), but the card the filter reads is stale/missing the id. ### Secondary, in-tree defect: "View N issues" promises more than the landing shows `milestoneFilterHref` emits `?milestone=<id>` with **no `state=` param** (`web/src/lib/milestones.js`). Issues.jsx resolves an absent `state` to **open-only** (`web/src/pages/Issues.jsx:198-211`, the #323 default, noted as "deliberate" there). So even with the filter returning rows, a "View 2 issues" button (1 open + 1 closed) lands on a page showing only the open one — the count promise and the landing disagree by construction. The #416 milestone select binds `search.milestone` verbatim (`resolveMilestoneFilter`, `web/src/lib/issueFilters.js:50-56`), so the dropdown shows the milestone correctly once rows come back. ## Fix prescription 1. **Detect and repair stale index cards.** Stamp the persisted Index with a Card-projection version; when absent or older than the current projection, `indexComplete` returns false so the LIST fallback heals the window (header wins). Stop swallowing `updateIndex`/`bumpMilestone` failures (log + counters/repair sweep). Provide a one-shot backfill that rebuilds any card disagreeing with its thread header — verify the live instance first: compare `GET …/api/issues?milestone=<id>` against the unfiltered list and the per-thread GET for the affected repo. 2. **Align the deep link with its promise.** Make `milestoneFilterHref` carry the state the count implies (e.g. `state=all` for the open+closed total) — or count only open issues in the button label. Pick one and note it; do not leave the half-promise. ## Acceptance criteria - [ ] Milestone card body lists the milestone's issues, matching the header counts (closed rows per design) — verified against a repo where counters > 0. - [ ] "View N issues" lands on the issues page filtered to that milestone; the #416 milestone dropdown shows it selected. - [ ] The landing shows the same set the N promised (state carried or label honest) and survives a page refresh (deep link re-hydrates). - [ ] A stale/absent Card projection in the index no longer suppresses the list: index fast path falls back to the header scan when the projection version is missing/old. - [ ] Lost `updateIndex`/`bumpMilestone` failures are logged (or surfaced), with a repair path backfilled from thread headers. - [ ] Headless test: card-list fetch + link-param round trip (milestoneFilterHref output → Issues.jsx query → filter semantics) in `web/test/unit/`. - [ ] Both themes verified on the milestones page cards. - [ ] 390px viewport: cards, buttons, and filtered list wrap without overflow. References: #314 (milestones UI cards/View button), #416 (milestone dropdown deep-link binding), #484 (milestone icons/chips), #119 (milestone picker + `milestone=` filter introduction), #323 (open-only list default), #318 (cross-page cache invalidation).
crueber added this to the v1 milestone 2026-09-15 12:19:11 +00:00
Author
Owner

Fixed by #565 (merged): Index carries additive card_version so stale/absent projections fall back to the header scan (self-healing on next filtered read, no operator step); updateIndex/bumpMilestone failures logged+counted with RepairIndex backfill; View-N-issues deep link carries state=all matching the open+closed label. Verified: issues suite -race green (96%+ coverage), web full-minus-smoke green, vite/esbuild green, independent review APPROVE.

Fixed by #565 (merged): Index carries additive card_version so stale/absent projections fall back to the header scan (self-healing on next filtered read, no operator step); updateIndex/bumpMilestone failures logged+counted with RepairIndex backfill; View-N-issues deep link carries state=all matching the open+closed label. Verified: issues suite -race green (96%+ coverage), web full-minus-smoke green, vite/esbuild green, independent review APPROVE.
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
crueber/walhub#564
No description provided.