feat(github): add /github page for PRs and issues (#687)

* feat(github): add /github page for PRs and issues

Dedicated page under /github lists pull requests and issues across the
authenticated user's repos with preset filters, custom GitHub search
queries, per-repo narrowing, saved queries, and one-click task launching
for any PR or issue.

Performance:
- Cache user PR/issue search responses for 30s with singleflight to
  coalesce concurrent misses, so preset switching and pagination avoid
  upstream round trips.
- Replace the per-row PR status fetch (N+1) with a batch endpoint
  POST /api/v1/github/prs/statuses that fans out concurrently on the
  backend with a semaphore and caches per-PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: apply Prettier formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address review feedback on PR cache + status batching

- Harden searchCacheKey against separator collisions from
  user-controllable customQuery (length-prefix encoding).
- Drop repositories.loaded hydration when inner workspace fetch fails
  so the client refetches instead of trusting a partial snapshot.
- Guard useGitHubSearch with a monotonic request counter to drop
  stale responses when filter+page changes overlap.
- Fix usePRStatuses teardown: depend only on composed key so a new
  array identity per render doesn't cancel the in-flight batch.
- Correct godocs (duplicate, outdated state:open claim).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address additional review feedback

- Restore state:open for issue watcher: buildIssueSearchQuery no
  longer injects it as a default, so existing watches were starting
  to surface closed issues. Inject state:open in buildIssueFilter so
  only the watcher path is affected; the /github page presets already
  set their own state qualifiers.
- countCheckResults now excludes in-progress checks from the denominator
  so a `1/3` badge reflects pass rate on completed checks, not a mix of
  running and failed. The pending state is still carried via ChecksState.
- usePRStatuses retries after a transient fetch error (only mark the
  key as completed once the fetch resolves). This also fixes missing
  badges under React Strict Mode's intentional double-mount, since the
  first effect's cleanup fires before the response lands and the guard
  stays unset for the second mount to retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(github): add tests for my-github hooks

Covers use-committed-query, buildParams from use-github-search,
use-saved-presets readStorage validation, use-known-repos recordForKey
accumulator semantics, and use-pr-statuses batch fetch lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address review feedback on search cache and repo accumulator

- Clamp perPage before composing the search cache key so identical
  pages (e.g. perPage=150 and =100) share a cache entry instead of
  creating duplicates keyed on the raw value.
- Add resetKnownReposStore and call it on /github unmount so the
  module-level repo accumulator doesn't carry across navigations.
- Rename use-pr-statuses test to match its assertion (it verifies
  stale statuses are cleared when the key changes and fetch fails).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: apply Prettier formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): distinguish blocked from dirty in PR mergeable badge

GitHub's blocked state (required reviews/checks/branch protection)
is not a merge conflict, so don't label it "Conflicts". Surface it
as "Blocked" with the pending tone; keep "Conflicts" for dirty.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address remaining /github code review comments

- Route PR status + batch through handleSearchError so ErrNoClient
  surfaces as 503 instead of 500 (matches the search endpoints).
- Sort workflow steps by position in the quick-task-launcher default
  pick, matching the convention in issue-watch-dialog.
- Make PresetItem keyboard-accessible: role=button, tabIndex, and
  Enter/Space handling on the wrapping div (it contains a nested
  delete button, so it can't itself be a button).
- Extract useRepoOptions out of useGitHubPageState to bring the hook
  back under the 100-line complexity limit.
- Honor ctx.Done() when acquiring the PR-status batch semaphore so a
  cancelled HTTP request releases queued goroutines instead of
  running each upstream fetch to completion.
- Clarify parsePaginationQuery doc: the upper bound (100) is enforced
  by clampSearchPage in the client layer, not here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): address /github review follow-ups

- Merge duplicate @/lib/utils imports in pr-list.
- Use rel="noopener noreferrer" on external PR/issue links (codebase
  convention) and add cursor-pointer per the UI guidelines.
- Rewrite onOpenSaveDialog as an explicit if-block so the callback
  returns void rather than boolean | void.
- Use PaginationEllipsis in its own PaginationItem so the ellipsis
  and the adjacent page link are separate list items instead of
  visually fused into a single cell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(web): derive QuickTaskLauncher dialog state from props

Replaces the useEffect+setState anti-pattern with useMemo-derived state
driven directly by the payload prop. Drops the eslint suppression that
was needed for react-hooks/set-state-in-effect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-04-24 10:38:49 +01:00
committed by GitHub
parent 7e938f6bdb
commit 85aa3ac0aa
42 changed files with 3688 additions and 27 deletions
@@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { IconCircle, IconCircleCheck, IconPlus, IconChevronDown } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Spinner } from "@kandev/ui/spinner";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import type { GitHubIssue } from "@/lib/types/github";
import { ISSUE_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
type IssueListProps = {
items: GitHubIssue[];
loading: boolean;
error: string | null;
onStartTask: (payload: LaunchPayload) => void;
};
function StartTaskMenu({
issue,
onStartTask,
}: {
issue: GitHubIssue;
onStartTask: IssueListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset });
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="h-7 gap-1 cursor-pointer">
<IconPlus className="h-3.5 w-3.5" />
Task
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{ISSUE_TASK_PRESETS.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
key={p.id}
className="cursor-pointer gap-2 py-1.5"
onSelect={() => launch(p)}
>
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium leading-tight">{p.label}</span>
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function IssueLabels({ labels }: { labels: string[] }) {
if (!labels?.length) return null;
return (
<>
{labels.slice(0, 4).map((l) => (
<Badge key={l} variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{l}
</Badge>
))}
{labels.length > 4 && (
<span className="text-[10px] text-muted-foreground">+{labels.length - 4}</span>
)}
</>
);
}
function IssueRow({
issue,
onStartTask,
}: {
issue: GitHubIssue;
onStartTask: IssueListProps["onStartTask"];
}) {
const StateIcon = issue.state === "open" ? IconCircle : IconCircleCheck;
const stateClass =
issue.state === "open"
? "text-emerald-600 dark:text-emerald-400"
: "text-purple-600 dark:text-purple-400";
return (
<div className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors">
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateClass)} />
<div className="min-w-0 flex-1">
<Link
href={issue.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
>
{issue.title}
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
<span className="whitespace-nowrap">
{issue.repo_owner}/{issue.repo_name}#{issue.number}
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {issue.author_login} · opened {formatRelativeTime(issue.created_at)}
</span>
<IssueLabels labels={issue.labels} />
</div>
</div>
<div className="shrink-0">
<StartTaskMenu issue={issue} onStartTask={onStartTask} />
</div>
</div>
);
}
function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) {
if (loading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (error) {
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No issues match this filter.
</div>
);
}
return (
<div className="divide-y">
{items.map((issue) => (
<IssueRow
key={`${issue.repo_owner}/${issue.repo_name}#${issue.number}`}
issue={issue}
onStartTask={onStartTask}
/>
))}
</div>
);
}
export function IssueList(props: IssueListProps) {
return (
<div className="rounded-md border overflow-hidden">
<IssueListBody {...props} />
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
const ALL_REPOS = "__all__";
type ListToolbarProps = {
title: string;
count: number;
loading: boolean;
lastFetchedAt: Date | null;
customQuery: string;
committedQuery: string;
onCustomQueryChange: (value: string) => void;
onCommitCustomQuery: () => void;
repoFilter: string;
onRepoFilterChange: (value: string) => void;
repoOptions: string[];
onRefresh: () => void;
};
export function ListToolbar({
title,
count,
loading,
lastFetchedAt,
customQuery,
committedQuery,
onCustomQueryChange,
onCommitCustomQuery,
repoFilter,
onRepoFilterChange,
repoOptions,
onRefresh,
}: ListToolbarProps) {
const selectValue = repoFilter || ALL_REPOS;
const dirty = customQuery !== committedQuery;
return (
<div className="px-6 py-2.5 border-b shrink-0 flex items-center gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
<h2 className="text-sm font-semibold truncate">{title}</h2>
<span className="text-xs text-muted-foreground tabular-nums">{loading ? "…" : count}</span>
</div>
<Select
value={selectValue}
onValueChange={(v) => onRepoFilterChange(v === ALL_REPOS ? "" : v)}
>
<SelectTrigger className="w-[220px] h-8 cursor-pointer">
<SelectValue placeholder="All repos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_REPOS} className="cursor-pointer">
All repos
</SelectItem>
{repoOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 min-w-[240px] relative">
<Input
value={customQuery}
onChange={(e) => onCustomQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommitCustomQuery();
}
}}
onBlur={() => {
if (dirty) onCommitCustomQuery();
}}
placeholder='Custom query — press Enter. e.g. "is:open review-requested:@me"'
className="h-8 pr-20"
/>
{dirty && (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground">
Press Enter
</span>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
{lastFetchedAt && !loading && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {formatRelativeTime(lastFetchedAt.toISOString())}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
>
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import {
IconGitPullRequest,
IconGitPullRequestClosed,
IconGitMerge,
IconPlus,
IconChevronDown,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Spinner } from "@kandev/ui/spinner";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { PR_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
import { PRStatusBadges } from "./pr-status-badges";
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
type PRListProps = {
items: GitHubPR[];
loading: boolean;
error: string | null;
onStartTask: (payload: LaunchPayload) => void;
};
function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
if (pr.state === "merged")
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
if (pr.state === "closed")
return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
if (pr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
}
function StartTaskMenu({
pr,
onStartTask,
}: {
pr: GitHubPR;
onStartTask: PRListProps["onStartTask"];
}) {
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="h-7 gap-1 cursor-pointer">
<IconPlus className="h-3.5 w-3.5" />
Task
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{PR_TASK_PRESETS.map((p) => {
const ItemIcon = p.icon;
return (
<DropdownMenuItem
key={p.id}
className="cursor-pointer gap-2 py-1.5"
onSelect={() => launch(p)}
>
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium leading-tight">{p.label}</span>
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function PRRow({
pr,
status,
onStartTask,
}: {
pr: GitHubPR;
status: GitHubPRStatus | null | undefined;
onStartTask: PRListProps["onStartTask"];
}) {
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
return (
<div className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors">
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
<div className="min-w-0 flex-1">
<Link
href={pr.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
>
{pr.title}
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
<span className="whitespace-nowrap">
{pr.repo_owner}/{pr.repo_name}#{pr.number}
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {pr.author_login} · opened {formatRelativeTime(pr.created_at)}
</span>
<PRStatusBadges pr={pr} status={status} />
</div>
</div>
<div className="shrink-0">
<StartTaskMenu pr={pr} onStartTask={onStartTask} />
</div>
</div>
);
}
function PRListBody({ loading, error, items, onStartTask }: PRListProps) {
const statuses = usePRStatuses(items);
if (loading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (error) {
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No pull requests match this filter.
</div>
);
}
return (
<div className="divide-y">
{items.map((pr) => {
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
return <PRRow key={key} pr={pr} status={statuses.get(key)} onStartTask={onStartTask} />;
})}
</div>
);
}
export function PRList(props: PRListProps) {
return (
<div className="rounded-md border overflow-hidden">
<PRListBody {...props} />
</div>
);
}
@@ -0,0 +1,107 @@
"use client";
import {
IconCheck,
IconX,
IconClockHour4,
IconGitMerge,
IconAlertTriangle,
IconGitPullRequestDraft,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
import { cn } from "@/lib/utils";
type StatusChipProps = {
Icon: Icon;
label: string;
tone: "success" | "failure" | "pending" | "neutral";
title?: string;
};
function StatusChip({ Icon, label, tone, title }: StatusChipProps) {
const toneClass = {
success: "text-emerald-600 dark:text-emerald-400",
failure: "text-red-600 dark:text-red-400",
pending: "text-amber-600 dark:text-amber-400",
neutral: "text-muted-foreground",
}[tone];
return (
<span
className={cn("inline-flex items-center gap-1 text-xs", toneClass)}
title={title ?? label}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="whitespace-nowrap tabular-nums">{label}</span>
</span>
);
}
function ChecksChip({ status }: { status: GitHubPRStatus }) {
const { checks_state: state, checks_total: total, checks_passing: passing } = status;
if (!state) return null;
const label = total > 0 ? `${passing}/${total}` : "";
if (state === "success")
return <StatusChip Icon={IconCheck} label={label || "Checks passed"} tone="success" />;
if (state === "failure")
return <StatusChip Icon={IconX} label={label || "Checks failed"} tone="failure" />;
return <StatusChip Icon={IconClockHour4} label={label || "Checks running"} tone="pending" />;
}
function ReviewChip({
state,
pending,
}: {
state: GitHubPRStatus["review_state"];
pending: number;
}) {
if (state === "approved") return <StatusChip Icon={IconCheck} label="Approved" tone="success" />;
if (state === "changes_requested")
return <StatusChip Icon={IconAlertTriangle} label="Changes requested" tone="failure" />;
if (pending > 0)
return (
<StatusChip
Icon={IconClockHour4}
label={`${pending} pending`}
tone="pending"
title={`${pending} pending review(s)`}
/>
);
return null;
}
function MergeableChip({
state,
prState,
}: {
state: GitHubPRStatus["mergeable_state"];
prState: GitHubPR["state"];
}) {
if (prState === "merged") return <StatusChip Icon={IconGitMerge} label="Merged" tone="success" />;
if (state === "draft")
return <StatusChip Icon={IconGitPullRequestDraft} label="Draft" tone="neutral" />;
if (state === "dirty")
return <StatusChip Icon={IconAlertTriangle} label="Conflicts" tone="failure" />;
if (state === "blocked")
return <StatusChip Icon={IconAlertTriangle} label="Blocked" tone="pending" />;
if (state === "behind")
return <StatusChip Icon={IconAlertTriangle} label="Behind base" tone="pending" />;
return null;
}
export function PRStatusBadges({
pr,
status,
}: {
pr: GitHubPR;
status: GitHubPRStatus | null | undefined;
}) {
if (!status) return null;
return (
<>
<ChecksChip status={status} />
<ReviewChip state={status.review_state} pending={status.pending_review_count} />
<MergeableChip state={status.mergeable_state} prState={pr.state} />
</>
);
}
@@ -0,0 +1,238 @@
"use client";
import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
import type { SavedPreset } from "./use-saved-presets";
export type SidebarSelection = {
kind: "pr" | "issue";
source: "preset" | "saved";
id: string;
};
type PresetsSidebarProps = {
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
savedPresets: SavedPreset[];
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
};
function KindToggle({
kind,
onChange,
}: {
kind: "pr" | "issue";
onChange: (k: "pr" | "issue") => void;
}) {
return (
<div className="mx-2 mb-3 grid grid-cols-2 rounded-md border p-0.5 text-xs">
{(["pr", "issue"] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => onChange(value)}
className={cn(
"px-2 py-1 rounded cursor-pointer transition-colors",
kind === value
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{value === "pr" ? "Pull requests" : "Issues"}
</button>
))}
</div>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div className="px-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
{title}
</div>
);
}
function PresetItem({
label,
Icon,
active,
onClick,
trailing,
}: {
label: string;
Icon: Icon;
active: boolean;
onClick: () => void;
trailing?: React.ReactNode;
}) {
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
aria-pressed={active}
className={cn(
"group/item mx-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
onClick={onClick}
onKeyDown={onKeyDown}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate flex-1">{label}</span>
{trailing}
</div>
);
}
function PresetGroupList({
presets,
group,
selected,
onSelect,
kind,
}: {
presets: PresetOption[];
group: PresetGroup;
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
kind: "pr" | "issue";
}) {
const items = presets.filter((p) => p.group === group);
if (items.length === 0) return null;
return (
<>
<SectionHeader title={group === "inbox" ? "Inbox" : "Created"} />
{items.map((p) => (
<PresetItem
key={`${kind}-${p.value}`}
label={p.label}
Icon={p.icon}
active={selected.source === "preset" && selected.id === p.value}
onClick={() => onSelect({ kind, source: "preset", id: p.value })}
/>
))}
</>
);
}
function SavedSection({
saved,
selected,
onSelect,
onDelete,
kind,
canSaveCurrent,
onSaveCurrent,
}: {
saved: SavedPreset[];
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
onDelete: (id: string) => void;
kind: "pr" | "issue";
canSaveCurrent: boolean;
onSaveCurrent: () => void;
}) {
return (
<>
<SectionHeader title="Saved" />
{saved.length === 0 && (
<div className="mx-2 px-2 py-1 text-xs text-muted-foreground/80 italic">
No saved queries yet.
</div>
)}
{saved.map((s) => (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(s.id);
}}
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Delete saved query"
>
<IconX className="h-3.5 w-3.5" />
</button>
}
/>
))}
<button
type="button"
onClick={onSaveCurrent}
disabled={!canSaveCurrent}
className={cn(
"mx-1 mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
canSaveCurrent
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
: "text-muted-foreground/50 cursor-not-allowed",
)}
title={canSaveCurrent ? "Save current query" : "Type a custom query first"}
>
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
<span>Save current query</span>
</button>
</>
);
}
export function PresetsSidebar({
selected,
onSelect,
savedPresets,
onDeleteSaved,
canSaveCurrent,
onSaveCurrent,
}: PresetsSidebarProps) {
const presets = selected.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
const saved = savedPresets.filter((p) => p.kind === selected.kind);
const onKindChange = (kind: "pr" | "issue") => {
const fallback = (kind === "pr" ? PR_PRESETS : ISSUE_PRESETS)[0]?.value ?? "";
onSelect({ kind, source: "preset", id: fallback });
};
return (
<nav className="flex flex-col py-3">
<KindToggle kind={selected.kind} onChange={onKindChange} />
<PresetGroupList
presets={presets}
group="inbox"
selected={selected}
onSelect={onSelect}
kind={selected.kind}
/>
<PresetGroupList
presets={presets}
group="created"
selected={selected}
onSelect={onSelect}
kind={selected.kind}
/>
<SavedSection
saved={saved}
selected={selected}
onSelect={onSelect}
onDelete={onDeleteSaved}
kind={selected.kind}
canSaveCurrent={canSaveCurrent}
onSaveCurrent={onSaveCurrent}
/>
</nav>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import {
IconEye,
IconMessageDots,
IconTool,
IconCode,
IconSearch,
IconBug,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
import type { GitHubPR, GitHubIssue } from "@/lib/types/github";
export type TaskPreset = {
id: string;
label: string;
hint: string;
icon: Icon;
prompt: (opts: { url: string; title: string }) => string;
};
export type LaunchPayload =
| { kind: "pr"; pr: GitHubPR; preset: TaskPreset }
| { kind: "issue"; issue: GitHubIssue; preset: TaskPreset };
export const PR_TASK_PRESETS: TaskPreset[] = [
{
id: "review",
label: "Review",
hint: "Read the diff, flag issues",
icon: IconEye,
prompt: ({ url }) =>
`Review the pull request at ${url}. Provide feedback on code quality, correctness, and suggest improvements.`,
},
{
id: "address_feedback",
label: "Address feedback",
hint: "Apply review comments",
icon: IconMessageDots,
prompt: ({ url }) =>
`Address the review feedback on the pull request at ${url}. Make the requested changes and push them.`,
},
{
id: "fix_ci",
label: "Fix CI",
hint: "Diagnose failing checks",
icon: IconTool,
prompt: ({ url }) =>
`Investigate and fix the CI failures on the pull request at ${url}. Run the failing checks locally, diagnose, and push fixes.`,
},
];
export const ISSUE_TASK_PRESETS: TaskPreset[] = [
{
id: "implement",
label: "Implement",
hint: "Build and open a PR",
icon: IconCode,
prompt: ({ url, title }) =>
`Implement the changes described in the GitHub issue at ${url} (title: "${title}"). Open a pull request when complete.`,
},
{
id: "investigate",
label: "Investigate",
hint: "Find the root cause",
icon: IconSearch,
prompt: ({ url, title }) =>
`Investigate the GitHub issue at ${url} (title: "${title}"). Identify root cause and summarize findings.`,
},
{
id: "reproduce",
label: "Reproduce",
hint: "Document repro steps",
icon: IconBug,
prompt: ({ url, title }) =>
`Reproduce the bug described in the GitHub issue at ${url} (title: "${title}"). Document the reproduction steps.`,
},
];
type DialogState = {
title: string;
description: string;
repositoryId?: string;
branch?: string;
githubUrl?: string;
};
function matchRepo(repos: Repository[], owner: string, name: string): Repository | undefined {
return repos.find(
(r) =>
(r.provider_owner || "").toLowerCase() === owner.toLowerCase() &&
(r.provider_name || "").toLowerCase() === name.toLowerCase(),
);
}
function extractPayload(payload: LaunchPayload) {
if (payload.kind === "pr") {
return {
url: payload.pr.html_url,
title: payload.pr.title,
owner: payload.pr.repo_owner,
name: payload.pr.repo_name,
branch: payload.pr.head_branch,
};
}
return {
url: payload.issue.html_url,
title: payload.issue.title,
owner: payload.issue.repo_owner,
name: payload.issue.repo_name,
branch: undefined as string | undefined,
};
}
function buildDialogState(payload: LaunchPayload, repositories: Repository[]): DialogState {
const data = extractPayload(payload);
const repo = matchRepo(repositories, data.owner, data.name);
const description = payload.preset.prompt({ url: data.url, title: data.title });
const title = `${payload.preset.label}: ${data.title}`;
if (repo) {
return { title, description, repositoryId: repo.id, branch: data.branch };
}
return {
title,
description,
githubUrl: `github.com/${data.owner}/${data.name}`,
branch: data.branch,
};
}
type QuickTaskLauncherProps = {
workspaceId: string | null;
workflows: Workflow[];
steps: WorkflowStep[];
repositories: Repository[];
payload: LaunchPayload | null;
onClose: () => void;
};
export function QuickTaskLauncher({
workspaceId,
workflows,
steps,
repositories,
payload,
onClose,
}: QuickTaskLauncherProps) {
const router = useRouter();
const defaultWorkflow = workflows[0];
const sortedStepsForWorkflow = useMemo(
() =>
steps
.filter((s) => s.workflow_id === defaultWorkflow?.id)
.sort((a, b) => a.position - b.position),
[steps, defaultWorkflow],
);
const defaultStep = sortedStepsForWorkflow[0];
const stepsForWorkflow = useMemo(
() => sortedStepsForWorkflow.map((s) => ({ id: s.id, title: s.name, events: s.events })),
[sortedStepsForWorkflow],
);
const dialog = useMemo(
() => (payload ? buildDialogState(payload, repositories) : null),
[payload, repositories],
);
const handleOpenChange = (open: boolean) => {
if (!open) onClose();
};
const handleSuccess = (task: Task) => {
onClose();
router.push(`/t/${task.id}`);
};
if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null;
return (
<TaskCreateDialog
open={true}
onOpenChange={handleOpenChange}
mode="create"
workspaceId={workspaceId}
workflowId={defaultWorkflow.id}
defaultStepId={defaultStep.id}
steps={stepsForWorkflow}
initialValues={{
title: dialog.title,
description: dialog.description,
repositoryId: dialog.repositoryId,
branch: dialog.branch,
githubUrl: dialog.githubUrl,
}}
onSuccess={handleSuccess}
/>
);
}
@@ -0,0 +1,99 @@
"use client";
import { Fragment } from "react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
// GitHub's search API caps total_count at 1000 regardless of actual results.
const GITHUB_MAX_RESULTS = 1000;
type ResultsPaginationProps = {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
};
function pageWindow(page: number, totalPages: number): number[] {
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
return Array.from(pages)
.filter((p) => p >= 1 && p <= totalPages)
.sort((a, b) => a - b);
}
export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) {
const effectiveTotal = Math.min(total, GITHUB_MAX_RESULTS);
const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
if (totalPages <= 1) return null;
const windowPages = pageWindow(page, totalPages);
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, effectiveTotal);
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{start}–{end} of {total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (page > 1) onPageChange(page - 1);
}}
aria-disabled={page <= 1}
className={page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{windowPages.map((p, i) => {
const prev = windowPages[i - 1];
const needsGap = prev !== undefined && p - prev > 1;
return (
<Fragment key={p}>
{needsGap && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => {
e.preventDefault();
onPageChange(p);
}}
className="cursor-pointer"
>
{p}
</PaginationLink>
</PaginationItem>
</Fragment>
);
})}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (page < totalPages) onPageChange(page + 1);
}}
aria-disabled={page >= totalPages}
className={page >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
type SavePresetDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: "pr" | "issue";
customQuery: string;
repoFilter: string;
suggestedLabel: string;
onSave: (label: string) => void;
};
function SavePresetForm({
kind,
customQuery,
repoFilter,
suggestedLabel,
onSave,
onClose,
}: {
kind: "pr" | "issue";
customQuery: string;
repoFilter: string;
suggestedLabel: string;
onSave: (label: string) => void;
onClose: () => void;
}) {
const [value, setValue] = useState(suggestedLabel);
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = useCallback(() => {
if (!canSubmit) return;
onSave(trimmed);
onClose();
}, [canSubmit, trimmed, onSave, onClose]);
return (
<>
<DialogHeader>
<DialogTitle>Save query</DialogTitle>
<DialogDescription>
Save this {kind === "pr" ? "pull request" : "issue"} query to the sidebar for quick access
later.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="preset-label" className="text-xs">
Name
</Label>
<Input
id="preset-label"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
onFocus={(e) => e.target.select()}
placeholder="e.g. Needs my review"
/>
</div>
<div className="flex flex-col gap-1.5 text-xs">
{customQuery && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Query</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{customQuery}
</code>
</div>
)}
{repoFilter && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Repo</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{repoFilter}
</code>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
Cancel
</Button>
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
Save
</Button>
</DialogFooter>
</>
);
}
export function SavePresetDialog({
open,
onOpenChange,
kind,
customQuery,
repoFilter,
suggestedLabel,
onSave,
}: SavePresetDialogProps) {
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
{open && (
<SavePresetForm
kind={kind}
customQuery={customQuery}
repoFilter={repoFilter}
suggestedLabel={suggestedLabel}
onSave={onSave}
onClose={handleClose}
/>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,89 @@
import {
IconInbox,
IconAt,
IconGitPullRequest,
IconPencil,
IconGitMerge,
IconPlus,
IconCircleCheck,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
export type PresetGroup = "inbox" | "created";
export type PresetOption = {
value: string;
label: string;
filter: string;
group: PresetGroup;
icon: Icon;
};
export const PR_PRESETS: PresetOption[] = [
{
value: "review_requested",
label: "Review requested",
filter: "review-requested:@me is:open",
group: "inbox",
icon: IconInbox,
},
{
value: "mentioned",
label: "Mentions",
filter: "mentions:@me is:open",
group: "inbox",
icon: IconAt,
},
{
value: "open",
label: "Open",
filter: "author:@me is:open",
group: "created",
icon: IconGitPullRequest,
},
{
value: "drafts",
label: "Drafts",
filter: "author:@me is:open draft:true",
group: "created",
icon: IconPencil,
},
{
value: "merged",
label: "Recently merged",
filter: "author:@me is:merged",
group: "created",
icon: IconGitMerge,
},
];
export const ISSUE_PRESETS: PresetOption[] = [
{
value: "assigned",
label: "Assigned",
filter: "assignee:@me is:open",
group: "inbox",
icon: IconInbox,
},
{
value: "mentioned",
label: "Mentions",
filter: "mentions:@me is:open",
group: "inbox",
icon: IconAt,
},
{
value: "created",
label: "Open",
filter: "author:@me is:open",
group: "created",
icon: IconPlus,
},
{
value: "closed",
label: "Recently closed",
filter: "author:@me is:closed",
group: "created",
icon: IconCircleCheck,
},
];
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useCommittedQuery } from "./use-committed-query";
describe("useCommittedQuery", () => {
it("initializes draft and committed to the initial value", () => {
const { result } = renderHook(() => useCommittedQuery("hello"));
expect(result.current.draft).toBe("hello");
expect(result.current.committed).toBe("hello");
});
it("setDraft updates draft without touching committed", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setDraft("typed"));
expect(result.current.draft).toBe("typed");
expect(result.current.committed).toBe("");
});
it("commit syncs committed to the current draft", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setDraft("query"));
act(() => result.current.commit());
expect(result.current.draft).toBe("query");
expect(result.current.committed).toBe("query");
});
it("setImmediate updates draft and committed together", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setImmediate("now"));
expect(result.current.draft).toBe("now");
expect(result.current.committed).toBe("now");
});
it("commit after setImmediate is a no-op for committed", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setImmediate("abc"));
act(() => result.current.commit());
expect(result.current.committed).toBe("abc");
});
});
@@ -0,0 +1,21 @@
"use client";
import { useCallback, useState } from "react";
// Track a user-editable string where the draft only commits when the caller
// explicitly asks. Typing updates `draft` for responsive input display but
// does not trigger downstream fetches on `committed`; Enter or blur in the
// input calls `setImmediate` to commit and run the query.
export function useCommittedQuery(initial: string) {
const [draft, setDraft] = useState(initial);
const [committed, setCommitted] = useState(initial);
const setImmediate = useCallback((value: string) => {
setDraft(value);
setCommitted(value);
}, []);
const commit = useCallback(() => setCommitted(draft), [draft]);
return { draft, committed, setDraft, setImmediate, commit };
}
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { IconGitPullRequest } from "@tabler/icons-react";
import { buildParams } from "./use-github-search";
import type { PresetOption } from "./search-bar";
const presets: PresetOption[] = [
{ value: "open", label: "Open", filter: "is:open", group: "inbox", icon: IconGitPullRequest },
{
value: "closed",
label: "Closed",
filter: "is:closed",
group: "inbox",
icon: IconGitPullRequest,
},
];
describe("buildParams", () => {
it("uses preset filter when custom query is empty", () => {
expect(buildParams(presets, "open", "", "")).toEqual({ filter: "is:open" });
});
it("trims custom query and routes to query branch", () => {
expect(buildParams(presets, "open", " foo bar ", "")).toEqual({ query: "foo bar" });
});
it("appends repo qualifier to custom query", () => {
expect(buildParams(presets, "open", "foo", "acme/widget")).toEqual({
query: "foo repo:acme/widget",
});
});
it("appends repo qualifier to preset filter", () => {
expect(buildParams(presets, "closed", "", "acme/widget")).toEqual({
filter: "is:closed repo:acme/widget",
});
});
it("falls back to empty filter when preset is unknown", () => {
expect(buildParams(presets, "missing", "", "")).toEqual({ filter: "" });
});
it("returns repo-only filter when preset is unknown but repo given", () => {
expect(buildParams(presets, "missing", "", "a/b")).toEqual({ filter: "repo:a/b" });
});
it("whitespace-only custom query treated as empty (uses preset)", () => {
expect(buildParams(presets, "open", " ", "")).toEqual({ filter: "is:open" });
});
});
@@ -0,0 +1,112 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { searchUserPRs, searchUserIssues } from "@/lib/api/domains/github-api";
import type { GitHubPR, GitHubIssue } from "@/lib/types/github";
import type { PresetOption } from "./search-bar";
type SearchKind = "pr" | "issue";
export const SEARCH_PAGE_SIZE = 25;
type SearchState<T> = {
items: T[];
loading: boolean;
error: string | null;
lastFetchedAt: Date | null;
total: number;
};
type FetchArgs = {
preset: string;
customQuery: string;
repoFilter: string;
page: number;
};
export function buildParams(
presets: PresetOption[],
preset: string,
customQuery: string,
repoFilter: string,
) {
const trimmed = customQuery.trim();
const repoQualifier = repoFilter ? `repo:${repoFilter}` : "";
if (trimmed) {
return { query: [trimmed, repoQualifier].filter(Boolean).join(" ") };
}
const found = presets.find((p) => p.value === preset);
const filter = [found?.filter ?? "", repoQualifier].filter(Boolean).join(" ");
return { filter };
}
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
kind: SearchKind,
presets: PresetOption[],
preset: string,
customQuery: string,
repoFilter: string = "",
) {
const [state, setState] = useState<SearchState<T>>({
items: [],
loading: false,
error: null,
lastFetchedAt: null,
total: 0,
});
const [page, setPage] = useState(1);
// Monotonic request counter: responses from older requests are dropped so
// a slow page-N request can't overwrite a fresher page-1 result after a
// filter change.
const requestSeq = useRef(0);
// Reset to page 1 whenever the filter inputs change.
useEffect(() => {
setPage(1);
}, [preset, customQuery, repoFilter, kind]);
const fetchData = useCallback(
async ({ preset: ep, customQuery: ec, repoFilter: er, page: epage }: FetchArgs) => {
const seq = ++requestSeq.current;
setState((s) => ({ ...s, loading: true, error: null }));
try {
const base = buildParams(presets, ep, ec, er);
const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE };
const response =
kind === "pr" ? await searchUserPRs(params) : await searchUserIssues(params);
if (seq !== requestSeq.current) return;
const items = (kind === "pr"
? (response as { prs: GitHubPR[] }).prs
: (response as { issues: GitHubIssue[] }).issues) as unknown as T[];
setState({
items: items ?? [],
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response.total_count ?? (items ?? []).length,
});
} catch (err) {
if (seq !== requestSeq.current) return;
setState((s) => ({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to search GitHub",
lastFetchedAt: s.lastFetchedAt,
total: 0,
}));
}
},
[kind, presets],
);
useEffect(() => {
void fetchData({ preset, customQuery, repoFilter, page });
}, [fetchData, preset, customQuery, repoFilter, page]);
const refresh = useCallback(
() => fetchData({ preset, customQuery, repoFilter, page }),
[fetchData, preset, customQuery, repoFilter, page],
);
return { ...state, page, setPage, pageSize: SEARCH_PAGE_SIZE, refresh };
}
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from "vitest";
import { __getSnapshotForTests, recordForKey } from "./use-known-repos";
describe("recordForKey", () => {
beforeEach(() => {
// Each test starts with a fresh key; since recordForKey clears when the
// key changes, this resets the module-level accumulator.
recordForKey(`__reset__${Math.random().toString(36).slice(2)}`, []);
});
it("accumulates unique repos under the same key", () => {
recordForKey("k1", ["a/b", "c/d"]);
recordForKey("k1", ["e/f"]);
expect(__getSnapshotForTests()).toEqual(["a/b", "c/d", "e/f"]);
});
it("returns sorted output", () => {
recordForKey("k2", ["z/z", "a/a", "m/m"]);
expect(__getSnapshotForTests()).toEqual(["a/a", "m/m", "z/z"]);
});
it("deduplicates across successive calls with the same key", () => {
recordForKey("k3", ["a/b"]);
recordForKey("k3", ["a/b", "c/d"]);
expect(__getSnapshotForTests()).toEqual(["a/b", "c/d"]);
});
it("clears accumulator when key changes", () => {
recordForKey("k4a", ["a/a", "b/b"]);
recordForKey("k4b", ["x/x"]);
expect(__getSnapshotForTests()).toEqual(["x/x"]);
});
it("ignores empty strings", () => {
recordForKey("k5", ["", "a/a", ""]);
expect(__getSnapshotForTests()).toEqual(["a/a"]);
});
it("empty input under an existing key is a no-op", () => {
recordForKey("k6", ["a/a"]);
recordForKey("k6", []);
expect(__getSnapshotForTests()).toEqual(["a/a"]);
});
});
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
// Accumulator of repos seen in the *current* query context. Scoped by a
// reset key (kind + selection + custom query) so repos from one preset don't
// bleed into another. Within a single context it only grows, so narrowing
// the repo filter doesn't hide the remaining options.
let currentKey: string | null = null;
const seen = new Set<string>();
let snapshot: string[] = [];
const emptySnapshot: string[] = [];
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function getSnapshot(): string[] {
return snapshot;
}
function getServerSnapshot(): string[] {
return emptySnapshot;
}
export function recordForKey(key: string, repos: readonly string[]) {
let changed = false;
if (currentKey !== key) {
currentKey = key;
if (seen.size > 0) {
seen.clear();
snapshot = [];
changed = true;
}
}
for (const r of repos) {
if (r && !seen.has(r)) {
seen.add(r);
changed = true;
}
}
if (!changed) return;
snapshot = Array.from(seen).sort();
for (const l of listeners) l();
}
// resetKnownReposStore drops all accumulated repos and the reset-key anchor.
// The /github page calls it on unmount so the store doesn't carry a stale
// set across visits. Also used for test isolation.
export function resetKnownReposStore() {
currentKey = null;
if (seen.size === 0 && snapshot.length === 0) return;
seen.clear();
snapshot = [];
for (const l of listeners) l();
}
export function useKnownRepos(resetKey: string, fromItems: readonly string[]): string[] {
useEffect(() => {
recordForKey(resetKey, fromItems);
}, [resetKey, fromItems]);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// Test-only: read the current module-level snapshot.
export function __getSnapshotForTests(): string[] {
return snapshot;
}
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
const getPRStatusesBatch = vi.fn();
vi.mock("@/lib/api/domains/github-api", () => ({
getPRStatusesBatch: (refs: { owner: string; repo: string; number: number }[]) =>
getPRStatusesBatch(refs) as Promise<{ statuses: Record<string, GitHubPRStatus> }>,
}));
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
function makePR(owner: string, repo: string, number: number): GitHubPR {
return {
number,
title: `PR #${number}`,
url: "",
html_url: "",
state: "open",
head_branch: "feat",
base_branch: "main",
author_login: "u",
repo_owner: owner,
repo_name: repo,
draft: false,
mergeable: true,
additions: 0,
deletions: 0,
requested_reviewers: [],
created_at: "",
updated_at: "",
merged_at: null,
closed_at: null,
};
}
function makeStatus(pr: GitHubPR): GitHubPRStatus {
return {
pr,
review_state: "",
checks_state: "",
mergeable_state: "unknown",
review_count: 0,
pending_review_count: 0,
checks_total: 0,
checks_passing: 0,
} as GitHubPRStatus;
}
describe("prStatusKey", () => {
it("encodes owner/repo#number", () => {
expect(prStatusKey("acme", "widget", 7)).toBe("acme/widget#7");
});
});
describe("usePRStatuses", () => {
beforeEach(() => {
getPRStatusesBatch.mockReset();
});
it("skips fetch when prs list is empty", () => {
renderHook(() => usePRStatuses([]));
expect(getPRStatusesBatch).not.toHaveBeenCalled();
});
it("fetches and populates map keyed by prStatusKey", async () => {
const pr = makePR("acme", "widget", 7);
getPRStatusesBatch.mockResolvedValueOnce({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { result } = renderHook(() => usePRStatuses([pr]));
await waitFor(() => {
expect(result.current.size).toBe(1);
});
expect(result.current.get("acme/widget#7")?.pr.number).toBe(7);
});
it("does not refetch when list identity changes but key stays the same", async () => {
const pr = makePR("acme", "widget", 7);
getPRStatusesBatch.mockResolvedValue({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { rerender, result } = renderHook(({ prs }) => usePRStatuses(prs), {
initialProps: { prs: [pr] },
});
await waitFor(() => expect(result.current.size).toBe(1));
expect(getPRStatusesBatch).toHaveBeenCalledTimes(1);
// New array reference, same content.
rerender({ prs: [makePR("acme", "widget", 7)] });
// Give effects a tick.
await act(async () => {
await Promise.resolve();
});
expect(getPRStatusesBatch).toHaveBeenCalledTimes(1);
});
it("refetches when the list key changes", async () => {
const pr1 = makePR("acme", "widget", 7);
const pr2 = makePR("acme", "widget", 8);
getPRStatusesBatch.mockResolvedValue({
statuses: { "acme/widget#7": makeStatus(pr1), "acme/widget#8": makeStatus(pr2) },
});
const { rerender } = renderHook(({ prs }) => usePRStatuses(prs), {
initialProps: { prs: [pr1] },
});
await waitFor(() => expect(getPRStatusesBatch).toHaveBeenCalledTimes(1));
rerender({ prs: [pr1, pr2] });
await waitFor(() => expect(getPRStatusesBatch).toHaveBeenCalledTimes(2));
});
it("clears stale statuses when key changes and fetch fails", async () => {
const pr = makePR("acme", "widget", 7);
getPRStatusesBatch.mockResolvedValueOnce({
statuses: { "acme/widget#7": makeStatus(pr) },
});
const { result, rerender } = renderHook(({ prs }) => usePRStatuses(prs), {
initialProps: { prs: [pr] },
});
await waitFor(() => expect(result.current.size).toBe(1));
// New key, then fetch rejects: the stale badges from pr #7 belong to the
// old key and would be misleading, so the catch handler clears them.
const pr2 = makePR("acme", "widget", 8);
getPRStatusesBatch.mockRejectedValueOnce(new Error("boom"));
rerender({ prs: [pr2] });
await waitFor(() => expect(result.current.size).toBe(0));
});
});
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getPRStatusesBatch, type PRStatusRef } from "@/lib/api/domains/github-api";
import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github";
export function prStatusKey(owner: string, repo: string, number: number): string {
return `${owner}/${repo}#${number}`;
}
// usePRStatuses fetches PR review/checks/mergeable summaries in a single
// batch request instead of N per-row fetches. Results are keyed by
// prStatusKey(owner, repo, number). When the PR list changes (new page,
// different preset) we refetch; the backend caches per-PR so fast
// pagination stays cheap.
export function usePRStatuses(prs: GitHubPR[]): Map<string, GitHubPRStatus> {
const [statuses, setStatuses] = useState<Map<string, GitHubPRStatus>>(new Map());
// `completedKey` is set only after a fetch resolves (success or failure), so
// a transient error can be retried on the next render, and React Strict
// Mode's intentional unmount+remount doesn't cause the batch fetch to be
// skipped — the first mount's cleanup fires before the response lands, so
// completedKey stays empty and the second mount retries.
const completedKey = useRef<string>("");
const key = prs.map((p) => prStatusKey(p.repo_owner, p.repo_name, p.number)).join(",");
// We deliberately depend only on `key`: `prs` gets a new array identity on
// every render, and including it would cancel an in-flight request whose
// content hasn't actually changed. The composed `key` string is the
// authoritative content signal; when it matches we reuse the latest `prs`
// closure for building request refs.
useEffect(() => {
if (key === "") return;
if (completedKey.current === key) return;
const refs: PRStatusRef[] = prs.map((p) => ({
owner: p.repo_owner,
repo: p.repo_name,
number: p.number,
}));
let cancelled = false;
getPRStatusesBatch(refs)
.then((resp) => {
if (cancelled) return;
completedKey.current = key;
setStatuses(new Map(Object.entries(resp.statuses ?? {})));
})
.catch(() => {
if (cancelled) return;
// Leave completedKey untouched so the next render retries; only
// clear the currently-rendered statuses if they belong to a now-stale
// key, so a transient error doesn't wipe otherwise-useful badges.
setStatuses((prev) => (prev.size === 0 ? prev : new Map()));
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return statuses;
}
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from "vitest";
import { readStorage, type SavedPreset } from "./use-saved-presets";
const STORAGE_KEY = "kandev:github-presets:v1";
function set(raw: string | null) {
if (raw === null) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, raw);
}
const valid: SavedPreset = {
id: "p_1",
kind: "pr",
label: "My PRs",
customQuery: "author:@me",
repoFilter: "",
createdAt: "2026-01-01T00:00:00Z",
};
describe("readStorage", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns empty array when no value is stored", () => {
expect(readStorage()).toEqual([]);
});
it("returns empty array for malformed JSON", () => {
set("not-json{");
expect(readStorage()).toEqual([]);
});
it("returns empty array when parsed value is not an array", () => {
set(JSON.stringify({ id: "p_1" }));
expect(readStorage()).toEqual([]);
});
it("keeps valid entries", () => {
set(JSON.stringify([valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries missing an id", () => {
const missingId = { ...valid } as Partial<SavedPreset>;
delete missingId.id;
set(JSON.stringify([missingId, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries with invalid kind", () => {
set(JSON.stringify([{ ...valid, kind: "commit" }, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops non-object entries", () => {
set(JSON.stringify(["string", 42, null, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries with non-string label", () => {
set(JSON.stringify([{ ...valid, label: 123 }, valid]));
expect(readStorage()).toEqual([valid]);
});
it("accepts issue kind", () => {
const issue: SavedPreset = { ...valid, kind: "issue" };
set(JSON.stringify([issue]));
expect(readStorage()).toEqual([issue]);
});
});
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "kandev:github-presets:v1";
export type SavedPreset = {
id: string;
kind: "pr" | "issue";
label: string;
customQuery: string;
repoFilter: string;
createdAt: string;
};
export function readStorage(): SavedPreset[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(p): p is SavedPreset =>
typeof p === "object" &&
p !== null &&
typeof (p as SavedPreset).id === "string" &&
((p as SavedPreset).kind === "pr" || (p as SavedPreset).kind === "issue") &&
typeof (p as SavedPreset).label === "string",
);
} catch {
return [];
}
}
function writeStorage(presets: SavedPreset[]) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
} catch {
/* ignore quota / access errors */
}
}
// External store: single source of truth shared across all hook consumers.
const listeners = new Set<() => void>();
let snapshot: SavedPreset[] | null = null;
const emptySnapshot: SavedPreset[] = [];
function getSnapshot(): SavedPreset[] {
if (snapshot === null) snapshot = readStorage();
return snapshot;
}
function getServerSnapshot(): SavedPreset[] {
return emptySnapshot;
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function publish(next: SavedPreset[]) {
snapshot = next;
writeStorage(next);
for (const l of listeners) l();
}
export function useSavedPresets() {
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const save = useCallback((input: Omit<SavedPreset, "id" | "createdAt">) => {
const preset: SavedPreset = {
...input,
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
publish([...(snapshot ?? readStorage()), preset]);
return preset;
}, []);
const remove = useCallback((id: string) => {
publish((snapshot ?? readStorage()).filter((p) => p.id !== id));
}, []);
return { presets, save, remove };
}