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
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 @@ -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,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
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
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 @@ -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 "—";
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,12 @@ export function IssueRow({
{label}
</span>
))}
{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
44 changes: 44 additions & 0 deletions apps/web/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,50 @@ 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 };
}

export function pipelineStories(pipeline: Pipeline): PipelineStory[] {
return pipeline.stages.flatMap((stage) => stage.stories);
}
Expand Down
87 changes: 86 additions & 1 deletion apps/web/test/pipeline-story.test.ts
Original file line number Diff line number Diff line change
@@ -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,
mineFilterOn,
ownedBy,
reviewablePullRequests,
storyHref,
storyOwner,
} from "@/lib/pipeline";
import { deriveStoryTimeline, proposalsForStory } from "@/lib/story";

describe("story presentation contract", () => {
Expand Down Expand Up @@ -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 = [
Expand All @@ -287,6 +318,60 @@ 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(mineFilterOn("1", "alice")).toBe(true);
expect(mineFilterOn("1", undefined)).toBe(false);
expect(mineFilterOn(undefined, "alice")).toBe(false);
expect(mineFilterOn(undefined, undefined)).toBe(false);
});

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 login. The derived flag must stay off so the
// board renders normally and the all chip offers a clean way out.
const mineOn = mineFilterOn("1", undefined);
expect(mineOn).toBe(false);
expect(boardHref("project-1", { mine: mineOn })).toBe("/projects/project-1/stories");
});
});

function pipelinePull(
Expand Down