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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user