From c1bcd3d13422d579ceb1ac5a984599160d45be1f Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:10:10 -0300 Subject: [PATCH 1/5] feat(web): show story owner on the backlog row storyOwner() picks the first assignee (GitHub's order, not sorted) and reports how many are left over, returning null when a story has no assignees so no call site can render an "unassigned" placeholder by accident. issue-row.tsx renders "@login" plus "+N" between the label chips and the relative-time stamp, and nothing when the story is unassigned. --- apps/web/components/issues/issue-row.tsx | 9 +++++++- apps/web/lib/pipeline.ts | 10 +++++++++ apps/web/test/pipeline-story.test.ts | 26 +++++++++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b2375..4c7029b9 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref } from "@/lib/pipeline"; +import { storyHref, storyOwner } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -61,6 +61,7 @@ export function IssueRow({ } const current = story.currentRun; + const owner = storyOwner(story.assignees); const openPull = story.prs.find((pull) => pull.state === "open") ?? null; const failedAgent = current?.mode.includes("architect") ? "architect" @@ -194,6 +195,12 @@ export function IssueRow({ {label} ))} + {owner ? ( + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + ) : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c5..8d1f897a 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,16 @@ export function storyHref( return `/projects/${projectId}/stories/${story.number}?${storyQuery(story)}`; } +export type StoryOwner = { login: string; extra: number }; + +/** The story's lead assignee, GitHub-ordered, with a count of the rest. */ +export function storyOwner(assignees: string[]): StoryOwner | null { + const logins = assignees.map((login) => login.trim()).filter(Boolean); + const [login] = logins; + if (!login) return null; + return { login, extra: logins.length - 1 }; +} + export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c5..de60a4ba 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { reviewablePullRequests, storyHref } from "@/lib/pipeline"; +import { reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -277,6 +277,30 @@ describe("story presentation contract", () => { expect(proposalsForStory([linked, unrelated], detail, false)).toEqual([linked]); }); + it("names no owner for an unassigned story", () => { + expect(storyOwner([])).toBeNull(); + }); + + it("names the sole assignee with nothing left over", () => { + expect(storyOwner(["a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("counts the remaining assignees past the first", () => { + expect(storyOwner(["a", "b", "c"])).toEqual({ login: "a", extra: 2 }); + }); + + it("keeps GitHub's assignee order rather than sorting it", () => { + expect(storyOwner(["zoe", "adam"])).toEqual({ login: "zoe", extra: 1 }); + }); + + it("drops empty and blank assignees before naming an owner", () => { + expect(storyOwner(["", " ", "a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("trims whitespace around an assignee's login", () => { + expect(storyOwner([" a "])).toEqual({ login: "a", extra: 0 }); + }); + it("does not count draft pull requests as waiting for human review", () => { const story = storyDetail(); story.prs = [ From ed444e6b46b9fd4d172c70266579b75b0ff0fc17 Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:13:56 -0300 Subject: [PATCH 2/5] feat(web): show story owner in the story header Render the story's lead assignee beside the label chips in the story header, using the same @login (+N) grammar already used on the Backlog row. The header now reads story.assignees from StoryDetail, which previously arrived from the API and was dropped on the floor. --- .../(app)/projects/[projectId]/stories/[number]/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx index 43c432c9..aa386d80 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -9,7 +9,7 @@ import { PullRequestLinks } from "@/components/story/pull-request-links"; import { StoryTimeline } from "@/components/story/timeline"; import { StoryTriggerButtons } from "@/components/story/trigger-buttons"; import { api } from "@/lib/api"; -import { pipelineStories } from "@/lib/pipeline"; +import { pipelineStories, storyOwner } from "@/lib/pipeline"; import { detachablePullRequests, linkableIssues, @@ -102,6 +102,7 @@ export default async function StoryPage({ stageLabels, }); const stage = story.stage; + const owner = storyOwner(story.assignees); const prLinks = new Map(); for (const pr of story.prs) prLinks.set(pr.number, pr.url); @@ -165,6 +166,12 @@ export default async function StoryPage({ {label} ))} + {owner ? ( + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + ) : null} Date: Sun, 16 Aug 2026 16:17:25 -0300 Subject: [PATCH 3/5] feat(web): add a mine filter chip to the stories board Adds ownedBy() and boardHref() as pure helpers in lib/pipeline.ts, and uses boardHref for all four board filter chips (all, stage, status clear, mine) instead of hand-built URL strings. The mine chip narrows each stage's stories to the signed-in viewer's GitHub login, composes with the existing stage/status filters, and only renders when the viewer has a GitHub login to match against. Stage chip counts and the active-open-stories subtitle now read from the mine-scoped stories so they never go stale relative to what's shown. --- .../projects/[projectId]/stories/page.tsx | 45 ++++++++++++++----- apps/web/lib/pipeline.ts | 23 ++++++++++ apps/web/test/pipeline-story.test.ts | 40 ++++++++++++++++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index 839a5b69..cf12e611 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -8,7 +8,7 @@ import { StageSection } from "@/components/project/stage-section"; import { LiveRefresh } from "@/components/shell/live-refresh"; import { api } from "@/lib/api"; import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline"; -import { pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; +import { boardHref, ownedBy, pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; export const metadata = { title: "stories" }; @@ -35,9 +35,9 @@ export default async function ProjectStoriesPage({ searchParams, }: { params: Promise<{ projectId: string }>; - searchParams: Promise<{ stage?: string; status?: string }>; + searchParams: Promise<{ stage?: string; status?: string; mine?: string }>; }) { - const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]); + const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]); const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); if (!pipelineResult.ok && pipelineResult.offline) return ; @@ -45,18 +45,30 @@ export default async function ProjectStoriesPage({ const permissions = me.ok ? me.data.permissions : []; const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); + const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; + const mineOn = mine === "1"; const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null; + // Validated against every story, not just the mine-scoped set, so a status filter + // never silently drops out of the URL when "mine" empties the board. const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : []; const stageStates = new Set(items.map((story) => story.stageState)); const activeStatus = activeStage && status && stageStates.has(status as PipelineStageState) ? (status as PipelineStageState) : null; - const counts = [...stages].reverse(); - const activeOpenStoryCount = items.filter((story) => story.state === "open").length; + const scoped = mineOn + ? stages.map((s) => ({ + ...s, + stories: s.stories.filter((story) => ownedBy(story.assignees, viewerLogin)), + })) + : stages; + const counts = [...scoped].reverse(); + const activeOpenStoryCount = scoped + .flatMap((s) => s.stories) + .filter((story) => story.state === "open").length; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -95,7 +107,7 @@ export default async function ProjectStoriesPage({
( 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", + s.stories.length > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", )} > - {s.count} + {s.stories.length} ))} @@ -134,7 +146,7 @@ export default async function ProjectStoriesPage({ {activeStatusLabel} @@ -143,6 +155,19 @@ export default async function ProjectStoriesPage({ ) : null} + {viewerLogin ? ( + + mine + + ) : null}
{!pipelineResult.ok ? ( diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 8d1f897a..8ade27f6 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,29 @@ export function storyHref( return `/projects/${projectId}/stories/${story.number}?${storyQuery(story)}`; } +export type BoardFilter = { + stage?: PipelineStageKey | null; + status?: PipelineStageState | null; + mine?: boolean; +}; + +/** The stories board URL for a given combination of filter chips. */ +export function boardHref(projectId: string, filter: BoardFilter = {}) { + const params = new URLSearchParams(); + if (filter.stage) params.set("stage", filter.stage); + if (filter.status) params.set("status", filter.status); + if (filter.mine) params.set("mine", "1"); + const query = params.toString(); + return `/projects/${projectId}/stories${query ? `?${query}` : ""}`; +} + +/** Whether a story's assignees include the signed-in viewer, by GitHub login. */ +export function ownedBy(assignees: string[], login: string | undefined): boolean { + if (!login) return false; + const target = login.toLowerCase(); + return assignees.some((assignee) => assignee.toLowerCase() === target); +} + export type StoryOwner = { login: string; extra: number }; /** The story's lead assignee, GitHub-ordered, with a count of the rest. */ diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index de60a4ba..43b4ddf8 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; +import { boardHref, ownedBy, reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -311,6 +311,44 @@ describe("story presentation contract", () => { expect(reviewablePullRequests([story]).map(({ pull }) => pull.number)).toEqual([22]); }); + + it("never counts a story as owned when the viewer has no GitHub login", () => { + expect(ownedBy(["alice"], undefined)).toBe(false); + expect(ownedBy([], undefined)).toBe(false); + }); + + it("matches an assignee to the viewer's login regardless of case", () => { + expect(ownedBy(["Alice"], "alice")).toBe(true); + }); + + it("finds no owner in an empty assignee list", () => { + expect(ownedBy([], "alice")).toBe(false); + }); + + it("does not match an assignee who isn't the viewer", () => { + expect(ownedBy(["bob"], "alice")).toBe(false); + }); + + it("builds a mine-only board link with no other filters", () => { + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); + + it("combines the stage and mine filters in one board link", () => { + expect(boardHref("project-1", { stage: "backlog", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&mine=1", + ); + }); + + it("omits the mine key entirely when mine is off", () => { + expect(boardHref("project-1", { mine: false })).toBe("/projects/project-1/stories"); + }); + + it("keeps mine on when the all chip clears the stage", () => { + expect(boardHref("project-1", { stage: "backlog", status: "ready_to_plan", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&status=ready_to_plan&mine=1", + ); + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); }); function pipelinePull( From 7012ddc28fdf09c4bb4fa8360d5340e385adc07d Mon Sep 17 00:00:00 2001 From: guzmonne Date: Sun, 16 Aug 2026 16:20:15 -0300 Subject: [PATCH 4/5] fix(web): stop the mine filter from trapping a login-less viewer mineOn previously read straight from the mine=1 query param, so a viewer with no GitHub login (a key principal, or any user whose principal.githubLogin is unset) who arrived at ?mine=1 via a shared link, bookmark, or browser history landed on a board with every story filtered out by ownedBy(), no mine chip to undo it (it only renders when a login exists), and no other chip to recover with, since all four preserve mine. Lift the derivation into mineFilterOn(mine, login) in lib/pipeline.ts, which is false whenever the viewer has no login to match against regardless of the raw query param. The board now renders normally for such a viewer even with ?mine=1 in the URL, and every chip link emits a clean, mine-free href. --- .../projects/[projectId]/stories/page.tsx | 10 ++++++-- apps/web/lib/pipeline.ts | 11 ++++++++ apps/web/test/pipeline-story.test.ts | 25 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index cf12e611..bf6c8d3b 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -8,7 +8,13 @@ import { StageSection } from "@/components/project/stage-section"; import { LiveRefresh } from "@/components/shell/live-refresh"; import { api } from "@/lib/api"; import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline"; -import { boardHref, ownedBy, pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline"; +import { + boardHref, + mineFilterOn, + ownedBy, + pipelineStageStateLabel, + pipelineStories, +} from "@/lib/pipeline"; export const metadata = { title: "stories" }; @@ -46,7 +52,7 @@ export default async function ProjectStoriesPage({ const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; - const mineOn = mine === "1"; + const mineOn = mineFilterOn(mine, viewerLogin); const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 8ade27f6..c6d44990 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -127,6 +127,17 @@ export function ownedBy(assignees: string[], login: string | undefined): boolean return assignees.some((assignee) => assignee.toLowerCase() === target); } +/** + * Whether the mine filter should actually apply. It is inert — never on — + * for a viewer with no GitHub login to match against, even if `?mine=1` + * is already sitting in the URL (a shared link, a bookmark, browser + * history), so such a viewer is never trapped on a board with every + * story filtered out and no chip left to undo it. + */ +export function mineFilterOn(mine: string | undefined, login: string | undefined): boolean { + return mine === "1" && Boolean(login); +} + export type StoryOwner = { login: string; extra: number }; /** The story's lead assignee, GitHub-ordered, with a count of the rest. */ diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 43b4ddf8..01c49716 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { boardHref, ownedBy, reviewablePullRequests, storyHref, storyOwner } from "@/lib/pipeline"; +import { + boardHref, + mineFilterOn, + ownedBy, + reviewablePullRequests, + storyHref, + storyOwner, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -349,6 +356,22 @@ describe("story presentation contract", () => { ); expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); }); + + it("turns the mine filter on only when the viewer has a GitHub login to match against", () => { + expect(mineFilterOn("1", "alice")).toBe(true); + expect(mineFilterOn("1", undefined)).toBe(false); + expect(mineFilterOn(undefined, "alice")).toBe(false); + expect(mineFilterOn(undefined, undefined)).toBe(false); + }); + + it("recovers a login-less viewer who arrives with ?mine=1 already in the URL", () => { + // A shared link, bookmark, or browser history can carry `mine=1` for a + // viewer with no GitHub login. The derived flag must stay off so the + // board renders normally and the all chip offers a clean way out. + const mineOn = mineFilterOn("1", undefined); + expect(mineOn).toBe(false); + expect(boardHref("project-1", { mine: mineOn })).toBe("/projects/project-1/stories"); + }); }); function pipelinePull( From 6c21a71336b1a0f0eebcca45aae336f63d78d683 Mon Sep 17 00:00:00 2001 From: guzmonne Date: Tue, 25 Aug 2026 15:52:45 -0300 Subject: [PATCH 5/5] fix(web): surface a failed /v1/me instead of silently dropping the mine filter A failed identity request used to be collapsed into 'viewer has no GitHub login', so ?mine=1 showed every story while removing the chip that could undo it. mineFilterState now returns off/on/blocked so the board can say the identity check failed rather than pretend the filter found nothing. Also adds the empty state the reviewer asked for: when the mine filter is active and nothing is assigned to the viewer, the board says so and links back to the unfiltered board instead of rendering every stage empty. --- .../projects/[projectId]/stories/page.tsx | 130 +++++++++++------- apps/web/lib/pipeline.ts | 34 ++++- apps/web/test/pipeline-story.test.ts | 41 ++++-- 3 files changed, 140 insertions(+), 65 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index bf6c8d3b..22233ab9 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -10,7 +10,8 @@ import { api } from "@/lib/api"; import type { PipelineStageKey, PipelineStageKind, PipelineStageState } from "@/lib/pipeline"; import { boardHref, - mineFilterOn, + type MeOutcome, + mineFilterState, ownedBy, pipelineStageStateLabel, pipelineStories, @@ -44,15 +45,21 @@ export default async function ProjectStoriesPage({ searchParams: Promise<{ stage?: string; status?: string; mine?: string }>; }) { const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]); - const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); + const [pipelineResult, meResult] = await Promise.all([api.pipeline(projectId), api.me()]); if (!pipelineResult.ok && pipelineResult.offline) return ; - const permissions = me.ok ? me.data.permissions : []; + // The identity request is allowed to fail independently of the pipeline: a + // partial failure must not silently downgrade `?mine=1` into "show + // everything", so the outcome — not just the login — feeds the filter. + const me: MeOutcome = meResult.ok + ? { ok: true, githubLogin: meResult.data.principal.githubLogin } + : { ok: false, message: meResult.message }; + const permissions = me.ok && meResult.ok ? meResult.data.permissions : []; const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); - const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; - const mineOn = mineFilterOn(mine, viewerLogin); + const viewerLogin = me.ok ? me.githubLogin : undefined; + const mineState = mineFilterState(mine, me); const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = @@ -65,13 +72,16 @@ export default async function ProjectStoriesPage({ activeStage && status && stageStates.has(status as PipelineStageState) ? (status as PipelineStageState) : null; - const scoped = mineOn - ? stages.map((s) => ({ - ...s, - stories: s.stories.filter((story) => ownedBy(story.assignees, viewerLogin)), - })) - : stages; + const scoped = + mineState.kind === "on" + ? stages.map((s) => ({ + ...s, + stories: s.stories.filter((story) => ownedBy(story.assignees, mineState.login)), + })) + : stages; + const mineOn = mineState.kind === "on"; const counts = [...scoped].reverse(); + const scopedTotal = scoped.reduce((total, s) => total + s.stories.length, 0); const activeOpenStoryCount = scoped .flatMap((s) => s.stories) .filter((story) => story.state === "open").length; @@ -94,6 +104,47 @@ export default async function ProjectStoriesPage({ ) : null; + const boardBody = () => ( +
+ {visibleStages.map((s) => { + const stageItems = s.stories; + return ( + story.runState === "live").length} + failedCount={ + stageItems.filter( + (story) => story.runState === "failed" || story.ciState === "failure", + ).length + } + defaultOpen={activeStage !== null || s.key !== "shipped"} + > + {stageItems.length === 0 ? ( +

+ Nothing here right now. +

+ ) : ( +
+ {stageItems.map((story) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); + return (
@@ -184,50 +235,31 @@ export default async function ProjectStoriesPage({ : `Couldn't load stories — ${pipelineResult.message}` } /> + ) : mineState.kind === "blocked" ? ( + + ) : mineState.kind === "on" && items.length > 0 && scopedTotal === 0 ? ( +
+

+ Nothing is assigned to{" "} + @{mineState.login} right now. Stories + you're assigned to in GitHub will appear here after the next sync. +

+ + show all stories + +
) : items.length === 0 ? (

No active stories right now. Closed and merged stories leave Shipped after seven days; sync refreshes the GitHub mirror.

) : ( -
- {visibleStages.map((s) => { - const stageItems = s.stories; - return ( - story.runState === "live").length} - failedCount={ - stageItems.filter( - (story) => story.runState === "failed" || story.ciState === "failure", - ).length - } - defaultOpen={activeStage !== null || s.key !== "shipped"} - > - {stageItems.length === 0 ? ( -

- Nothing here right now. -

- ) : ( -
- {stageItems.map((story) => ( - - ))} -
- )} -
- ); - })} -
+ boardBody() )}
); diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index c6d44990..02fa48bf 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -127,15 +127,35 @@ export function ownedBy(assignees: string[], login: string | undefined): boolean return assignees.some((assignee) => assignee.toLowerCase() === target); } +/** What the control plane said about the signed-in viewer. */ +export type MeOutcome = + | { ok: true; githubLogin: string | undefined } + | { ok: false; message: string }; + /** - * Whether the mine filter should actually apply. It is inert — never on — - * for a viewer with no GitHub login to match against, even if `?mine=1` - * is already sitting in the URL (a shared link, a bookmark, browser - * history), so such a viewer is never trapped on a board with every - * story filtered out and no chip left to undo it. + * Why the mine filter is or isn't applied: + * + * - `off` — not requested, or requested by a viewer with no GitHub login to + * match against (a shared link, a bookmark, browser history). Such a viewer + * is never trapped on a board with every story filtered out and no chip + * left to undo it. + * - `on` — requested and matchable; `login` is the identity to match. + * - `blocked` — requested, but the `/v1/me` request itself failed. This is + * kept distinct from `off` on purpose: silently showing the unfiltered + * board would read as "the filter found nothing", and dropping the + * parameter from every chip URL would erase the reader's intent. The board + * must say the identity check failed instead. */ -export function mineFilterOn(mine: string | undefined, login: string | undefined): boolean { - return mine === "1" && Boolean(login); +export type MineFilterState = + | { kind: "off" } + | { kind: "on"; login: string } + | { kind: "blocked"; reason: string }; + +export function mineFilterState(requested: string | undefined, me: MeOutcome): MineFilterState { + if (requested !== "1") return { kind: "off" }; + if (!me.ok) return { kind: "blocked", reason: me.message }; + if (!me.githubLogin) return { kind: "off" }; + return { kind: "on", login: me.githubLogin }; } export type StoryOwner = { login: string; extra: number }; diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 01c49716..904911ba 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -3,7 +3,7 @@ import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; import { boardHref, - mineFilterOn, + mineFilterState, ownedBy, reviewablePullRequests, storyHref, @@ -358,19 +358,42 @@ describe("story presentation contract", () => { }); it("turns the mine filter on only when the viewer has a GitHub login to match against", () => { - expect(mineFilterOn("1", "alice")).toBe(true); - expect(mineFilterOn("1", undefined)).toBe(false); - expect(mineFilterOn(undefined, "alice")).toBe(false); - expect(mineFilterOn(undefined, undefined)).toBe(false); + expect(mineFilterState("1", { ok: true, githubLogin: "alice" })).toEqual({ + kind: "on", + login: "alice", + }); + expect(mineFilterState("1", { ok: true, githubLogin: undefined })).toEqual({ kind: "off" }); + expect(mineFilterState(undefined, { ok: true, githubLogin: "alice" })).toEqual({ kind: "off" }); + expect(mineFilterState(undefined, { ok: true, githubLogin: undefined })).toEqual({ + kind: "off", + }); }); it("recovers a login-less viewer who arrives with ?mine=1 already in the URL", () => { // A shared link, bookmark, or browser history can carry `mine=1` for a - // viewer with no GitHub login. The derived flag must stay off so the + // viewer with no GitHub identity. The derived state must stay off so the // board renders normally and the all chip offers a clean way out. - const mineOn = mineFilterOn("1", undefined); - expect(mineOn).toBe(false); - expect(boardHref("project-1", { mine: mineOn })).toBe("/projects/project-1/stories"); + const state = mineFilterState("1", { ok: true, githubLogin: undefined }); + expect(state).toEqual({ kind: "off" }); + expect(boardHref("project-1", { mine: state.kind === "on" })).toBe( + "/projects/project-1/stories", + ); + }); + + it("reports a failed /v1/me as blocked rather than as a filter that found nothing", () => { + // Regression: a partial failure (pipeline loads, identity request fails) + // used to be collapsed into "no login", which silently showed every story + // under `?mine=1` while removing the chip that could undo it. The board + // must surface the failure instead. + const state = mineFilterState("1", { ok: false, message: "identity lookup timed out" }); + expect(state).toEqual({ kind: "blocked", reason: "identity lookup timed out" }); + expect(boardHref("project-1", { mine: state.kind === "on" })).toBe( + "/projects/project-1/stories", + ); + }); + + it("keeps an unrequested mine filter off even when the identity request failed", () => { + expect(mineFilterState(undefined, { ok: false, message: "down" })).toEqual({ kind: "off" }); }); });