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,352 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user