Files
kandev/apps/web/app/github/github-page-client.tsx
T
João Salavisa 85aa3ac0aa 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>
2026-04-24 10:38:49 +01:00

353 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconArrowLeft, IconBrandGithub } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http";
import type { GitHubIssue, GitHubPR } from "@/lib/types/github";
import { PRList } from "@/components/github/my-github/pr-list";
import { IssueList } from "@/components/github/my-github/issue-list";
import {
PresetsSidebar,
type SidebarSelection,
} from "@/components/github/my-github/presets-sidebar";
import { PR_PRESETS, ISSUE_PRESETS } from "@/components/github/my-github/search-bar";
import { useGitHubSearch } from "@/components/github/my-github/use-github-search";
import { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets";
import { useKnownRepos, resetKnownReposStore } from "@/components/github/my-github/use-known-repos";
import { useCommittedQuery } from "@/components/github/my-github/use-committed-query";
import { ListToolbar } from "@/components/github/my-github/list-toolbar";
import { ResultsPagination } from "@/components/github/my-github/results-pagination";
import { SavePresetDialog } from "@/components/github/my-github/save-preset-dialog";
import {
QuickTaskLauncher,
type LaunchPayload,
} from "@/components/github/my-github/quick-task-launcher";
type GitHubPageClientProps = {
workspaceId?: string;
workflows: Workflow[];
steps: WorkflowStep[];
repositories: Repository[];
};
function PageHeader() {
return (
<header className="flex items-center gap-3 px-4 py-3 border-b shrink-0">
<Link href="/" className="text-muted-foreground hover:text-foreground cursor-pointer">
<IconArrowLeft className="h-4 w-4" />
</Link>
<IconBrandGithub className="h-5 w-5" />
<h1 className="text-lg font-semibold">My GitHub</h1>
<span className="text-xs text-muted-foreground ml-2">
Pull requests and issues across your repos.
</span>
</header>
);
}
function NotAuthenticatedNotice() {
return (
<Alert>
<AlertDescription>
GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token)
in{" "}
<Link href="/settings" className="underline font-medium">
Settings → GitHub
</Link>{" "}
to see your pull requests and issues.
</AlertDescription>
</Alert>
);
}
function NoWorkspaceNotice() {
return (
<div className="px-6 py-3 border-b shrink-0">
<Alert>
<AlertDescription>
No workspace configured. Create a workspace first to start tasks from PRs/issues.
</AlertDescription>
</Alert>
</div>
);
}
function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string {
if (selection.source === "saved") {
return saved.find((p) => p.id === selection.id)?.label ?? "Saved query";
}
const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
return (
presets.find((p) => p.value === selection.id)?.label ??
(selection.kind === "pr" ? "Pull requests" : "Issues")
);
}
function ResultsList({
selection,
items,
loading,
error,
onStartTask,
}: {
selection: SidebarSelection;
items: Array<GitHubPR | GitHubIssue>;
loading: boolean;
error: string | null;
onStartTask: (payload: LaunchPayload) => void;
}) {
if (selection.kind === "pr") {
return (
<PRList
items={items as GitHubPR[]}
loading={loading}
error={error}
onStartTask={onStartTask}
/>
);
}
return (
<IssueList
items={items as GitHubIssue[]}
loading={loading}
error={error}
onStartTask={onStartTask}
/>
);
}
function defaultSelection(kind: "pr" | "issue"): SidebarSelection {
const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
return { kind, source: "preset", id: presets[0]?.value ?? "" };
}
function defaultQuery(kind: "pr" | "issue"): string {
const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
return presets[0]?.filter ?? "";
}
type GitHubPageState = ReturnType<typeof useGitHubPageState>;
function useRepoOptions(
selection: SidebarSelection,
committedQuery: string,
items: Array<GitHubPR | GitHubIssue>,
repoFilter: string,
): string[] {
const pageRepos = useMemo(
() =>
items
.filter((it) => it.repo_owner && it.repo_name)
.map((it) => `${it.repo_owner}/${it.repo_name}`),
[items],
);
// Reset the accumulator whenever the query context changes (preset, saved,
// custom query). Repo filter is deliberately excluded so narrowing doesn't
// reset — that's the whole point of the accumulator.
const reposResetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`;
const knownRepos = useKnownRepos(reposResetKey, pageRepos);
return useMemo(() => {
const set = new Set(knownRepos);
if (repoFilter) set.add(repoFilter);
return Array.from(set).sort();
}, [knownRepos, repoFilter]);
}
function useGitHubPageState() {
const [selection, setSelection] = useState<SidebarSelection>(() => defaultSelection("pr"));
const {
draft: customQuery,
committed: committedQuery,
setDraft: setCustomQuery,
setImmediate: setQueryImmediate,
commit: commitCustomQuery,
} = useCommittedQuery(defaultQuery("pr"));
const [repoFilter, setRepoFilter] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets();
const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
const search = useGitHubSearch<GitHubPR | GitHubIssue>(
selection.kind,
presets,
selection.source === "preset" ? selection.id : "",
committedQuery,
repoFilter,
);
const repoOptions = useRepoOptions(selection, committedQuery, search.items, repoFilter);
const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]);
const onSelect = useCallback(
(s: SidebarSelection) => {
setSelection(s);
if (s.source === "saved") {
const found = savedPresets.find((p) => p.id === s.id);
setQueryImmediate(found?.customQuery ?? "");
setRepoFilter(found?.repoFilter ?? "");
return;
}
const presetList = s.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS;
const preset = presetList.find((p) => p.value === s.id);
setQueryImmediate(preset?.filter ?? "");
setRepoFilter("");
},
[savedPresets, setQueryImmediate],
);
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
const onOpenSaveDialog = () => {
if (canSaveCurrent) setSaveDialogOpen(true);
};
const onConfirmSave = (label: string) => {
const created = saveSavedPreset({ kind: selection.kind, label, customQuery, repoFilter });
setSelection({ kind: selection.kind, source: "saved", id: created.id });
};
const onDeleteSaved = (id: string) => {
removeSavedPreset(id);
if (selection.source === "saved" && selection.id === id) {
setSelection(defaultSelection(selection.kind));
setQueryImmediate(defaultQuery(selection.kind));
setRepoFilter("");
}
};
return {
selection,
customQuery,
committedQuery,
setCustomQuery,
commitCustomQuery,
repoFilter,
setRepoFilter,
savedPresets,
search,
repoOptions,
title,
onSelect,
canSaveCurrent,
suggestedLabel,
saveDialogOpen,
setSaveDialogOpen,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
function AuthenticatedLayout({
workspaceId,
state,
onStartTask,
}: {
workspaceId: string | undefined;
state: GitHubPageState;
onStartTask: (payload: LaunchPayload) => void;
}) {
const { selection, search, repoOptions, title } = state;
return (
<div className="flex-1 flex min-h-0">
<aside className="w-60 border-r overflow-y-auto shrink-0">
<PresetsSidebar
selected={selection}
onSelect={state.onSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={state.onOpenSaveDialog}
/>
</aside>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<ListToolbar
title={title}
count={search.total}
loading={search.loading}
lastFetchedAt={search.lastFetchedAt}
customQuery={state.customQuery}
committedQuery={state.committedQuery}
onCustomQueryChange={state.setCustomQuery}
onCommitCustomQuery={state.commitCustomQuery}
repoFilter={state.repoFilter}
onRepoFilterChange={state.setRepoFilter}
repoOptions={repoOptions}
onRefresh={search.refresh}
/>
{!workspaceId && <NoWorkspaceNotice />}
<div className="flex-1 overflow-auto px-6 py-4">
<ResultsList
selection={selection}
items={search.items}
loading={search.loading}
error={search.error}
onStartTask={onStartTask}
/>
</div>
<ResultsPagination
page={search.page}
pageSize={search.pageSize}
total={search.total}
onPageChange={search.setPage}
/>
</main>
</div>
);
}
export function GitHubPageClient({
workspaceId,
workflows,
steps,
repositories,
}: GitHubPageClientProps) {
const { status, loaded } = useGitHubStatus();
const [launchPayload, setLaunchPayload] = useState<LaunchPayload | null>(null);
const state = useGitHubPageState();
// Drop the module-level repo accumulator on page unmount so a later visit
// doesn't inherit a stale set from the previous navigation.
useEffect(() => resetKnownReposStore, []);
const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []);
const onCloseLaunch = useCallback(() => setLaunchPayload(null), []);
const authed = !!status?.authenticated;
return (
<div className="h-screen w-full flex flex-col bg-background">
<PageHeader />
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking GitHub status…</div>}
{loaded && !authed && (
<div className="p-6 max-w-2xl">
<NotAuthenticatedNotice />
</div>
)}
{loaded && authed && (
<AuthenticatedLayout workspaceId={workspaceId} state={state} onStartTask={onStartTask} />
)}
<QuickTaskLauncher
workspaceId={workspaceId ?? null}
workflows={workflows}
steps={steps}
repositories={repositories}
payload={launchPayload}
onClose={onCloseLaunch}
/>
<SavePresetDialog
open={state.saveDialogOpen}
onOpenChange={state.setSaveDialogOpen}
kind={state.selection.kind}
customQuery={state.customQuery}
repoFilter={state.repoFilter}
suggestedLabel={state.suggestedLabel}
onSave={state.onConfirmSave}
/>
</div>
);
}