Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +103,7 @@ export default async function StoryPage({
stageLabels,
});
const stage = story.stage;
const owner = storyOwner(story.assignees);

const prLinks = new Map<number, string>();
for (const pr of story.prs) prLinks.set(pr.number, pr.url);
Expand Down Expand Up @@ -167,6 +168,12 @@ export default async function StoryPage({
{label}
</span>
))}
{owner ? (
<span className="font-mono text-[11px] text-(--dim)">
@{owner.login}
{owner.extra > 0 ? ` +${owner.extra}` : ""}
</span>
) : null}
<a
href={story.htmlUrl}
target="_blank"
Expand Down
169 changes: 116 additions & 53 deletions apps/web/app/(app)/projects/[projectId]/stories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ 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,
type MeOutcome,
mineFilterState,
ownedBy,
pipelineStageStateLabel,
pipelineStories,
} from "@/lib/pipeline";

export const metadata = { title: "stories" };

Expand All @@ -35,32 +42,53 @@ 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 [pipelineResult, me, project] = await Promise.all([
const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]);
const [pipelineResult, meResult, project] = await Promise.all([
api.pipeline(projectId),
api.me(),
api.project(projectId),
]);

if (!pipelineResult.ok && pipelineResult.offline) return <Offline />;

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)
Expand All @@ -80,6 +108,50 @@ export default async function ProjectStoriesPage({
)
: null;

const boardBody = () => (
<div className="flex flex-col gap-6">
{visibleStages.map((s) => {
const stageItems = s.stories;
return (
<StageSection
key={s.key}
label={s.label}
sub={s.sub}
kind={s.kind}
total={stageItems.length}
liveCount={stageItems.filter((story) => story.runState === "live").length}
failedCount={
stageItems.filter(
(story) => story.runState === "failed" || story.ciState === "failure",
).length
}
defaultOpen={activeStage !== null || s.key !== "shipped"}
>
{stageItems.length === 0 ? (
<p className="border border-(--line) px-5 py-3.5 text-[12.5px] text-(--dim)">
Nothing here right now.
</p>
) : (
<div className="flex flex-col border border-(--line)">
{stageItems.map((story) => (
<IssueRow
key={story.key}
projectId={projectId}
story={story}
canTrigger={canTrigger}
builderPlanRequired={
!project.ok || project.data.builderPlanPolicy === "required"
}
/>
))}
</div>
)}
</StageSection>
);
})}
</div>
);

return (
<div className="flex flex-col gap-8">
<LiveRefresh seconds={30} />
Expand All @@ -99,7 +171,7 @@ export default async function ProjectStoriesPage({

<div className="flex flex-wrap items-center gap-2">
<Link
href={`/projects/${projectId}/stories`}
href={boardHref(projectId, { mine: mineOn })}
className={cx(
"border px-3 py-1.5 text-[12px] font-medium transition-colors",
!activeStage
Expand All @@ -112,7 +184,7 @@ export default async function ProjectStoriesPage({
{counts.map((s) => (
<Link
key={s.key}
href={`/projects/${projectId}/stories?stage=${s.key}`}
href={boardHref(projectId, { stage: s.key, mine: mineOn })}
className={cx(
"inline-flex items-center gap-2 border px-3 py-1.5 text-[12px] font-medium transition-colors",
activeStage === s.key
Expand All @@ -125,10 +197,10 @@ export default async function ProjectStoriesPage({
<span
className={cx(
"font-mono text-[11px]",
s.count > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)",
s.stories.length > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)",
)}
>
{s.count}
{s.stories.length}
</span>
</Link>
))}
Expand All @@ -138,7 +210,7 @@ export default async function ProjectStoriesPage({
<span className="inline-flex items-center gap-2 border border-(--line-strong) px-3 py-1.5 text-[12px] font-medium text-(--ink)">
{activeStatusLabel}
<Link
href={`/projects/${projectId}/stories?stage=${activeStage}`}
href={boardHref(projectId, { stage: activeStage, mine: mineOn })}
aria-label="clear status filter"
className="text-(--dim) hover:text-(--ink)"
>
Expand All @@ -147,6 +219,19 @@ export default async function ProjectStoriesPage({
</span>
</>
) : null}
{viewerLogin ? (
<Link
href={boardHref(projectId, { stage: activeStage, status: activeStatus, mine: !mineOn })}
className={cx(
"border px-3 py-1.5 text-[12px] font-medium transition-colors",
mineOn
? "border-(--line-strong) text-(--ink)"
: "border-(--line) text-(--mut) hover:text-(--ink)",
)}
>
mine
</Link>
) : null}
</div>

{!pipelineResult.ok ? (
Expand All @@ -157,53 +242,31 @@ export default async function ProjectStoriesPage({
: `Couldn't load stories — ${pipelineResult.message}`
}
/>
) : mineState.kind === "blocked" ? (
<ErrorNotice
message={`Couldn't apply the "mine" filter — couldn't confirm who you are (${mineState.reason}). Reload to try again.`}
/>
) : mineState.kind === "on" && items.length > 0 && scopedTotal === 0 ? (
<div className="flex flex-col items-start gap-3 border border-(--line) bg-(--bg-subtle) p-8">
<p className="max-w-lg text-sm leading-relaxed text-(--mut)">
Nothing is assigned to{" "}
<span className="font-mono text-[12.5px]">@{mineState.login}</span> right now. Stories
you're assigned to in GitHub will appear here after the next sync.
</p>
<Link
href={boardHref(projectId, { stage: activeStage, status: activeStatus })}
className="border border-(--line-strong) px-3 py-1.5 text-[12px] font-medium text-(--ink) transition-colors hover:bg-(--bg-subtle)"
>
show all stories
</Link>
</div>
) : items.length === 0 ? (
<p className="max-w-lg text-sm leading-relaxed text-(--dim)">
No active stories right now. Closed and merged stories leave Shipped after seven days;
sync refreshes the GitHub mirror.
</p>
) : (
<div className="flex flex-col gap-6">
{visibleStages.map((s) => {
const stageItems = s.stories;
return (
<StageSection
key={s.key}
label={s.label}
sub={s.sub}
kind={s.kind}
total={stageItems.length}
liveCount={stageItems.filter((story) => story.runState === "live").length}
failedCount={
stageItems.filter(
(story) => story.runState === "failed" || story.ciState === "failure",
).length
}
defaultOpen={activeStage !== null || s.key !== "shipped"}
>
{stageItems.length === 0 ? (
<p className="border border-(--line) px-5 py-3.5 text-[12.5px] text-(--dim)">
Nothing here right now.
</p>
) : (
<div className="flex flex-col border border-(--line)">
{stageItems.map((story) => (
<IssueRow
key={story.key}
projectId={projectId}
story={story}
canTrigger={canTrigger}
builderPlanRequired={
!project.ok || project.data.builderPlanPolicy === "required"
}
/>
))}
</div>
)}
</StageSection>
);
})}
</div>
boardBody()
)}
</div>
);
Expand Down
9 changes: 8 additions & 1 deletion apps/web/components/issues/issue-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useState } from "react";
import { CiStatusLink } from "@/components/ci-status";
import { WsjfChip } from "@/components/issues/wsjf-chip";
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 "—";
Expand Down Expand Up @@ -64,6 +64,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"
Expand Down Expand Up @@ -212,6 +213,12 @@ export function IssueRow({
</span>
))}
{story.wsjf ? <WsjfChip wsjf={story.wsjf} /> : null}
{owner ? (
<span className="font-mono text-[10.5px] text-(--dim)">
@{owner.login}
{owner.extra > 0 ? ` +${owner.extra}` : ""}
</span>
) : null}
<span className="font-mono text-[10.5px] text-(--dim)">{fmtAgo(story.ghUpdatedAt)}</span>
{action()}
</div>
Expand Down
64 changes: 64 additions & 0 deletions apps/web/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,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);
}
Expand Down
Loading