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)
@@ -143,6 +150,26 @@ export default async function ProjectStoriesPage({
>
) : null}
+ {githubLogin ? (
+
+
+
+ mine
+
+
+ ) : null}
{!pipelineResult.ok ? (
diff --git a/apps/web/components/issues/assignee-chip.tsx b/apps/web/components/issues/assignee-chip.tsx
new file mode 100644
index 00000000..56be78a1
--- /dev/null
+++ b/apps/web/components/issues/assignee-chip.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
+import { assigneeInitial, githubAvatarUrl } from "@/lib/pipeline";
+
+export function AssigneeChip({ login }: { login: string }) {
+ const [broken, setBroken] = useState(false);
+ return (
+
+ {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(