Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Eyebrow, PillTag, StatusDot } from "@facility/ui";
import { Avatar, Eyebrow, PillTag, StatusDot } from "@facility/ui";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CiStatusLink } from "@/components/ci-status";
Expand All @@ -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 { avatarInitial, avatarUrlFor, pipelineStories, storyOwner } from "@/lib/pipeline";
import {
detachablePullRequests,
linkableIssues,
Expand Down Expand Up @@ -102,6 +102,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 @@ -165,6 +166,19 @@ export default async function StoryPage({
{label}
</span>
))}
{owner ? (
<span className="inline-flex items-center gap-1.5 font-mono text-[11px] text-(--dim)">
<Avatar
size={16}
src={avatarUrlFor(owner.login) ?? undefined}
initial={avatarInitial(owner.login)}
/>
<span>
@{owner.login}
{owner.extra > 0 ? ` +${owner.extra}` : ""}
</span>
</span>
) : null}
<a
href={story.htmlUrl}
target="_blank"
Expand Down
51 changes: 41 additions & 10 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,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 { pipelineStageStateLabel, pipelineStories } from "@/lib/pipeline";
import {
boardHref,
mineFilterOn,
ownedBy,
pipelineStageStateLabel,
pipelineStories,
} from "@/lib/pipeline";

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

Expand All @@ -35,28 +41,40 @@ 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 <Offline />;

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 = mineFilterOn(mine, viewerLogin);
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)
Expand Down Expand Up @@ -95,7 +113,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 @@ -108,7 +126,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 @@ -121,10 +139,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 @@ -134,7 +152,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 @@ -143,6 +161,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 Down
18 changes: 16 additions & 2 deletions apps/web/components/issues/issue-row.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { Button, ButtonLink, StatusDot, toneFor } from "@facility/ui";
import { Avatar, Button, ButtonLink, StatusDot, toneFor } from "@facility/ui";
import Link from "next/link";
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 { avatarInitial, avatarUrlFor, storyHref, storyOwner } from "@/lib/pipeline";

function fmtAgo(iso: string | null) {
if (!iso) return "—";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -194,6 +195,19 @@ export function IssueRow({
{label}
</span>
))}
{owner ? (
<span className="inline-flex items-center gap-1 font-mono text-[10.5px] text-(--dim)">
<Avatar
size={14}
src={avatarUrlFor(owner.login) ?? undefined}
initial={avatarInitial(owner.login)}
/>
<span>
@{owner.login}
{owner.extra > 0 ? ` +${owner.extra}` : ""}
</span>
</span>
) : null}
<span className="font-mono text-[10.5px] text-(--dim)">{fmtAgo(story.ghUpdatedAt)}</span>
{action()}
</div>
Expand Down
20 changes: 7 additions & 13 deletions apps/web/components/shell/topbar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { PillTag } from "@facility/ui";
import Image from "next/image";
import { Avatar, PillTag } from "@facility/ui";
import { SignOutButton } from "@/components/shell/sign-out";
import { ProjectSwitcher } from "@/components/shell/switcher";
import type { Me, Project } from "@/lib/api";
import { avatarInitial } from "@/lib/pipeline";

export function Topbar({
me,
Expand All @@ -26,17 +26,11 @@ export function Topbar({
className="flex items-center gap-2 font-mono text-[11px] text-(--dim)"
title={me.principal.email}
>
{me.principal.avatarUrl ? (
<Image
src={me.principal.avatarUrl}
alt=""
width={20}
height={20}
unoptimized
referrerPolicy="no-referrer"
className="size-5 rounded-full border border-(--line)"
/>
) : null}
<Avatar
size={20}
src={me.principal.avatarUrl ?? undefined}
initial={avatarInitial(me.principal.githubLogin ?? me.principal.email)}
/>
{me.principal.githubLogin ? `@${me.principal.githubLogin}` : me.principal.email}
</span>
<SignOutButton />
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 @@ -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);
}

/**
* 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. */
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 };
}

/**
* GitHub serves an avatar for any login at this path, so no avatar URL has to
* travel on the wire. It 302s to `avatars.githubusercontent.com`.
*
* `?size=40` rather than the 14–20 CSS px we draw at, so 2× displays stay sharp.
*/
export function avatarUrlFor(login: string): string | null {
const trimmed = login.trim();
if (!trimmed) return null;
return `https://github.com/${encodeURIComponent(trimmed)}.png?size=40`;
}

/** The letter an avatar falls back to when there is no image to draw. */
export function avatarInitial(value: string | null | undefined): string {
const trimmed = (value ?? "").trim();
// Spread, not `[0]`, so an astral first character survives intact.
const [first] = [...trimmed];
return first ? first.toUpperCase() : "?";
}

export function pipelineStories(pipeline: Pipeline): PipelineStory[] {
return pipeline.stages.flatMap((stage) => stage.stories);
}
Expand Down
Loading