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 43c432c..aa386d8 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} ; - searchParams: Promise<{ stage?: string; status?: string }>; + searchParams: Promise<{ stage?: string; status?: string; mine?: string }>; }) { - const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]); - const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); + const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]); + 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.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 = 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 = + 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; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -76,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 (
@@ -95,7 +164,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 +203,7 @@ export default async function ProjectStoriesPage({ {activeStatusLabel} @@ -143,6 +212,19 @@ export default async function ProjectStoriesPage({ ) : null} + {viewerLogin ? ( + + mine + + ) : null}
{!pipelineResult.ok ? ( @@ -153,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/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b237..4c7029b 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 679199c..02fa48b 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,70 @@ 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); +} + +/** What the control plane said about the signed-in viewer. */ +export type MeOutcome = + | { ok: true; githubLogin: string | undefined } + | { ok: false; message: string }; + +/** + * 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 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 }; + +/** 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 836e08c..904911b 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 { reviewablePullRequests, storyHref } from "@/lib/pipeline"; +import { + boardHref, + mineFilterState, + ownedBy, + reviewablePullRequests, + storyHref, + storyOwner, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -277,6 +284,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 = [ @@ -287,6 +318,83 @@ 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"); + }); + + it("turns the mine filter on only when the viewer has a GitHub login to match against", () => { + 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 identity. The derived state must stay off so the + // board renders normally and the all chip offers a clean way out. + 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" }); + }); }); function pipelinePull(