From 20e00a9b8778ce0d61991ba518a353cd3388d49b Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sat, 15 Aug 2026 09:30:28 +0100 Subject: [PATCH 1/2] feat(web): render GitHub assignees on stories Facility already mirrors gh_issues.assignees end to end and ships it on both the pipeline and story-detail responses, but apps/web never read the field, so ownership was invisible without opening GitHub. - show @login chips (first + N) on the story row and story header, with a GitHub avatar that falls back to an initial when the browser can't reach github.com - add a "mine" filter on the Stories board matching the signed-in user's githubLogin --- .../[projectId]/stories/[number]/page.tsx | 4 +++ .../projects/[projectId]/stories/page.tsx | 32 ++++++++++++++++--- apps/web/components/issues/assignee-chip.tsx | 30 +++++++++++++++++ apps/web/components/issues/issue-row.tsx | 17 +++++++++- apps/web/lib/pipeline.ts | 14 ++++++++ apps/web/test/pipeline-story.test.ts | 23 ++++++++++++- 6 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/issues/assignee-chip.tsx 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..2efd2fa1 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -2,6 +2,7 @@ import { Eyebrow, PillTag, StatusDot } from "@facility/ui"; import Link from "next/link"; import { notFound } from "next/navigation"; import { CiStatusLink } from "@/components/ci-status"; +import { AssigneeChip } from "@/components/issues/assignee-chip"; import { Markdown } from "@/components/markdown"; import { ErrorNotice, Offline } from "@/components/offline"; import { LiveRefresh } from "@/components/shell/live-refresh"; @@ -165,6 +166,9 @@ export default async function StoryPage({ {label} ))} + {story.assignees.map((login) => ( + + ))} ; - 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 ; @@ -50,13 +50,20 @@ export default async function ProjectStoriesPage({ const activeStage = stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null; const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : []; + const githubLogin = me.ok ? me.data.principal.githubLogin : undefined; + const mineLogin = mine === "1" ? githubLogin : undefined; 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 counts = [...stages].reverse().map((candidate) => { + if (mineLogin === undefined) return candidate; + const stories = candidate.stories.filter((story) => story.assignees.includes(mineLogin)); + return { ...candidate, count: stories.length, stories }; + }); + const mineFilteredItems = counts.flatMap((candidate) => candidate.stories); + const activeOpenStoryCount = mineFilteredItems.filter((story) => story.state === "open").length; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -105,6 +112,23 @@ export default async function ProjectStoriesPage({ > all + {githubLogin ? ( + + mine + + ) : null} {counts.map((s) => ( + {broken ? ( + + {assigneeInitial(login)} + + ) : ( + setBroken(true)} + className="size-4 rounded-full border border-(--line)" + /> + )} + @{login} + + ); +} diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b2375..597b5e98 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -5,8 +5,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; +import { AssigneeChip } from "@/components/issues/assignee-chip"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref } from "@/lib/pipeline"; +import { assigneeSummary, storyHref } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -194,6 +195,20 @@ export function IssueRow({ {label} ))} + {assigneeSummary(story.assignees).shown.map((login) => ( + + ))} + {assigneeSummary(story.assignees).extra > 0 ? ( + `@${login}`) + .join(", ")} + > + +{assigneeSummary(story.assignees).extra} + + ) : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c5..6859f605 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -108,6 +108,20 @@ export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } +/** GitHub avatar for an assignee login; needs no sync column and no new table. */ +export function githubAvatarUrl(login: string) { + return `https://github.com/${login}.png?size=40`; +} + +/** Initial-letter fallback for deployments where the browser cannot reach github.com. */ +export function assigneeInitial(login: string) { + return login.charAt(0).toUpperCase(); +} + +export function assigneeSummary(assignees: string[], maxShown = 1) { + return { shown: assignees.slice(0, maxShown), extra: Math.max(0, assignees.length - maxShown) }; +} + /** Open, non-draft pull requests that are genuinely waiting on a human review. */ export function reviewablePullRequests>( stories: Story[], diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c5..9ec27b71 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,13 @@ 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 { + assigneeInitial, + assigneeSummary, + githubAvatarUrl, + reviewablePullRequests, + storyHref, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -287,6 +293,21 @@ describe("story presentation contract", () => { expect(reviewablePullRequests([story]).map(({ pull }) => pull.number)).toEqual([22]); }); + + it("renders nothing for an unassigned story", () => { + expect(assigneeSummary([])).toEqual({ shown: [], extra: 0 }); + }); + + it("shows the first assignee and collapses the rest as +N", () => { + expect(assigneeSummary(["ada"])).toEqual({ shown: ["ada"], extra: 0 }); + expect(assigneeSummary(["ada", "grace", "linus"])).toEqual({ shown: ["ada"], extra: 2 }); + }); + + it("builds the avatar URL from the login and falls back to the initial", () => { + expect(githubAvatarUrl("ada")).toBe("https://github.com/ada.png?size=40"); + expect(assigneeInitial("ada")).toBe("A"); + expect(assigneeInitial("grace")).toBe("G"); + }); }); function pipelinePull( From a98dd212b86f66ebb47b8238b369e7ade9bfb241 Mon Sep 17 00:00:00 2001 From: Nxssie Date: Sat, 15 Aug 2026 10:19:49 +0100 Subject: [PATCH 2/2] fix(web): move mine filter out of the stage-tab group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "mine" filters by assignee, an axis independent from stage — grouping it inline with the single-select stage tabs read as if it belonged to the same selection. Move it to the end of the row, set off with a vertical divider, so it reads as its own filter. --- .../projects/[projectId]/stories/page.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index 0b2d92b0..004d606f 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -112,23 +112,6 @@ export default async function ProjectStoriesPage({ > all - {githubLogin ? ( - - mine - - ) : null} {counts.map((s) => ( ) : null} + {githubLogin ? ( +
+ + + mine + +
+ ) : null} {!pipelineResult.ok ? (