diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index cf3595faa..e4903c821 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1853,6 +1853,9 @@ export class PostHogAPIClient { if (params?.suggested_reviewers) { url.searchParams.set("suggested_reviewers", params.suggested_reviewers); } + if (params?.repository) { + url.searchParams.set("repository", params.repository); + } const response = await this.api.fetcher.fetch({ method: "get", diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 2c40b35f1..2579927b6 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -17,6 +17,7 @@ import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsF import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; import { + buildRepoFilterParam, buildSignalReportListOrdering, buildStatusFilterParam, buildSuggestedReviewerFilterParam, @@ -56,6 +57,7 @@ export function InboxSignalsTab() { const suggestedReviewerFilter = useInboxSignalsFilterStore( (s) => s.suggestedReviewerFilter, ); + const repoFilter = useInboxSignalsFilterStore((s) => s.repoFilter); // ── GitHub integration ─────────────────────────────────────────────── const { hasGithubIntegration } = useRepositoryIntegration(); @@ -112,6 +114,8 @@ export function InboxSignalsTab() { suggestedReviewerFilter.length > 0 ? buildSuggestedReviewerFilterParam(suggestedReviewerFilter) : undefined, + repository: + repoFilter.length > 0 ? buildRepoFilterParam(repoFilter) : undefined, }), [ statusFilter, @@ -119,6 +123,7 @@ export function InboxSignalsTab() { sortDirection, sourceProductFilter, suggestedReviewerFilter, + repoFilter, ], ); @@ -343,6 +348,7 @@ export function InboxSignalsTab() { const hasActiveFilters = sourceProductFilter.length > 0 || suggestedReviewerFilter.length > 0 || + repoFilter.length > 0 || statusFilter.length < 5; const shouldShowTwoPane = hasReports || diff --git a/apps/code/src/renderer/features/inbox/components/list/RepoFilterMenu.tsx b/apps/code/src/renderer/features/inbox/components/list/RepoFilterMenu.tsx new file mode 100644 index 000000000..fe61cdbbd --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/list/RepoFilterMenu.tsx @@ -0,0 +1,157 @@ +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + useRepositoryIntegration, + useUserRepositoryIntegration, +} from "@hooks/useIntegrations"; +import { Check, GitBranch, MagnifyingGlass } from "@phosphor-icons/react"; +import { Box, Flex, Popover, Spinner, Text } from "@radix-ui/themes"; +import { useDeferredValue, useMemo, useState } from "react"; + +export function RepoFilterMenu() { + const [open, setOpen] = useState(false); + const [repoQuery, setRepoQuery] = useState(""); + const deferredRepoQuery = useDeferredValue(repoQuery); + + const { repositories: orgRepositories, isLoadingRepos: orgLoading } = + useRepositoryIntegration(); + const { repositories: userRepositories, isLoadingRepos: userLoading } = + useUserRepositoryIntegration(); + + const repoFilter = useInboxSignalsFilterStore((s) => s.repoFilter); + const toggleRepo = useInboxSignalsFilterStore((s) => s.toggleRepo); + const setRepoFilter = useInboxSignalsFilterStore((s) => s.setRepoFilter); + + // Merge org-level + user-level repos and de-duplicate (case-insensitive). The + // signal report `repo_selection` artefact stores `owner/repo` in lowercase, and + // the filter is matched case-insensitively, so we present a single canonical + // lowercase entry per repo regardless of which integration source it came from. + const availableRepos = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const repo of [...orgRepositories, ...userRepositories]) { + const key = repo.trim().toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out.sort((a, b) => a.localeCompare(b)); + }, [orgRepositories, userRepositories]); + + const visibleRepos = useMemo(() => { + const trimmed = deferredRepoQuery.trim().toLowerCase(); + if (!trimmed) return availableRepos; + return availableRepos.filter((r) => r.includes(trimmed)); + }, [availableRepos, deferredRepoQuery]); + + const isFetching = orgLoading || userLoading; + const selectedCount = repoFilter.length; + const hasSelected = selectedCount > 0; + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + setRepoQuery(""); + } + }} + > + + + + + + + + Repository + + {hasSelected ? ( + + ) : null} + + + + + setRepoQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-gray-12 outline-none placeholder:text-gray-9" + /> + + + + {isFetching && availableRepos.length === 0 ? ( + + + + ) : visibleRepos.length === 0 ? ( + + {availableRepos.length === 0 + ? "No repos available." + : "No repos match."} + + ) : ( + + {visibleRepos.map((repo) => { + const isSelected = repoFilter.includes(repo); + return ( + + ); + })} + + )} + + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx index 389984d28..4964411f3 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -24,6 +24,7 @@ import { IS_DEV } from "@shared/constants/environment"; import type { SignalReport } from "@shared/types"; import { useState } from "react"; import { FilterSortMenu } from "./FilterSortMenu"; +import { RepoFilterMenu } from "./RepoFilterMenu"; import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; interface SignalsToolbarProps { @@ -233,6 +234,7 @@ export function SignalsToolbar({ {!hideFilters && ( + )} diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts index 00693aa44..d27ca7f80 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts @@ -18,6 +18,7 @@ describe("inboxSignalsFilterStore", () => { ], sourceProductFilter: [], suggestedReviewerFilter: [], + repoFilter: [], }); }); @@ -36,6 +37,7 @@ describe("inboxSignalsFilterStore", () => { ]); expect(state.sourceProductFilter).toEqual([]); expect(state.suggestedReviewerFilter).toEqual([]); + expect(state.repoFilter).toEqual([]); }); it("setSort updates field and direction", () => { @@ -106,12 +108,48 @@ describe("inboxSignalsFilterStore", () => { ]); }); + it("toggleRepo adds and removes repos and lower-cases them", () => { + useInboxSignalsFilterStore.getState().toggleRepo("PostHog/posthog"); + expect(useInboxSignalsFilterStore.getState().repoFilter).toEqual([ + "posthog/posthog", + ]); + + useInboxSignalsFilterStore.getState().toggleRepo("posthog/posthog"); + expect(useInboxSignalsFilterStore.getState().repoFilter).toEqual([]); + }); + + it("setRepoFilter de-duplicates and lower-cases repos", () => { + useInboxSignalsFilterStore + .getState() + .setRepoFilter([ + "PostHog/posthog", + "posthog/posthog-js", + "posthog/posthog", + ]); + + expect(useInboxSignalsFilterStore.getState().repoFilter).toEqual([ + "posthog/posthog", + "posthog/posthog-js", + ]); + }); + + it("persists repoFilter", () => { + useInboxSignalsFilterStore.getState().setRepoFilter(["posthog/posthog"]); + + const raw = localStorage.getItem("inbox-signals-filter-storage"); + expect(raw).toBeTruthy(); + const persisted = JSON.parse(raw as string); + + expect(persisted.state.repoFilter).toEqual(["posthog/posthog"]); + }); + it("resetFilters restores defaults across all filter fields", () => { const store = useInboxSignalsFilterStore.getState(); store.setSearchQuery("hello"); store.setStatusFilter(["ready"]); store.toggleSourceProduct("github"); store.setSuggestedReviewerFilter(["reviewer-1"]); + store.setRepoFilter(["posthog/posthog"]); useInboxSignalsFilterStore.getState().resetFilters(); @@ -127,6 +165,7 @@ describe("inboxSignalsFilterStore", () => { ]); expect(state.sourceProductFilter).toEqual([]); expect(state.suggestedReviewerFilter).toEqual([]); + expect(state.repoFilter).toEqual([]); }); it("resetFilters preserves sort preferences", () => { diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index c53c4c9e5..2a45120bd 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -39,6 +39,8 @@ interface InboxSignalsFilterState { sourceProductFilter: SourceProduct[]; /** Empty array means "all suggested reviewers" (no filter). Stored as PostHog user UUID strings. */ suggestedReviewerFilter: string[]; + /** Empty array means "all repositories" (no filter). Stored as `owner/repo` strings (lower-case). */ + repoFilter: string[]; } interface InboxSignalsFilterActions { @@ -49,6 +51,8 @@ interface InboxSignalsFilterActions { toggleSourceProduct: (source: SourceProduct) => void; toggleSuggestedReviewer: (reviewerUuid: string) => void; setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; + toggleRepo: (repo: string) => void; + setRepoFilter: (repos: string[]) => void; /** Reset all filters when a deep link arrives so the linked report isn't hidden. */ resetFilters: () => void; } @@ -65,6 +69,7 @@ export const useInboxSignalsFilterStore = create()( statusFilter: DEFAULT_STATUS_FILTER, sourceProductFilter: [], suggestedReviewerFilter: [], + repoFilter: [], setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setSearchQuery: (searchQuery) => set({ searchQuery }), setStatusFilter: (statusFilter) => set({ statusFilter }), @@ -96,12 +101,31 @@ export const useInboxSignalsFilterStore = create()( set({ suggestedReviewerFilter: Array.from(new Set(reviewerUuids)), }), + toggleRepo: (repo) => + set((state) => { + const normalized = repo.trim().toLowerCase(); + if (!normalized) return {}; + const current = state.repoFilter; + const next = current.includes(normalized) + ? current.filter((r) => r !== normalized) + : [...current, normalized]; + return { repoFilter: next }; + }), + setRepoFilter: (repos) => + set({ + repoFilter: Array.from( + new Set( + repos.map((repo) => repo.trim().toLowerCase()).filter(Boolean), + ), + ), + }), resetFilters: () => set({ searchQuery: "", statusFilter: DEFAULT_STATUS_FILTER, sourceProductFilter: [], suggestedReviewerFilter: [], + repoFilter: [], }), }), { @@ -112,6 +136,7 @@ export const useInboxSignalsFilterStore = create()( statusFilter: state.statusFilter, sourceProductFilter: state.sourceProductFilter, suggestedReviewerFilter: state.suggestedReviewerFilter, + repoFilter: state.repoFilter, }), }, ), diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts index 6042daec0..8f5fd88be 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts @@ -1,6 +1,7 @@ import type { SignalReport } from "@shared/types"; import { describe, expect, it } from "vitest"; import { + buildRepoFilterParam, buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, @@ -153,3 +154,44 @@ describe("buildSuggestedReviewerFilterParam", () => { ).toBe("reviewer-1,reviewer-2"); }); }); + +describe("buildRepoFilterParam", () => { + it("returns undefined for an empty array", () => { + expect(buildRepoFilterParam([])).toBeUndefined(); + }); + + it("lower-cases, trims and joins repos with commas", () => { + expect( + buildRepoFilterParam([ + " PostHog/posthog ", + "PostHog/posthog-js", + " PostHog/code", + ]), + ).toBe("posthog/posthog,posthog/posthog-js,posthog/code"); + }); + + it("deduplicates repos case-insensitively", () => { + expect( + buildRepoFilterParam([ + "PostHog/posthog", + "posthog/posthog", + "POSTHOG/POSTHOG", + ]), + ).toBe("posthog/posthog"); + }); + + it("drops blank repos", () => { + expect( + buildRepoFilterParam([ + "posthog/posthog", + " ", + "posthog/posthog-js", + "", + ]), + ).toBe("posthog/posthog,posthog/posthog-js"); + }); + + it("returns undefined when only blank repos are supplied", () => { + expect(buildRepoFilterParam(["", " "])).toBeUndefined(); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.ts index 82848f4ae..ed813c36d 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.ts @@ -70,3 +70,17 @@ export function buildSuggestedReviewerFilterParam( return Array.from(new Set(normalizedIds)).join(","); } + +export function buildRepoFilterParam(repos: string[]): string | undefined { + // Repos are matched case-insensitively on the backend, so normalize to lowercase + // here to maximize de-duplication (e.g. "PostHog/posthog" and "posthog/posthog"). + const normalized = repos + .map((repo) => repo.trim().toLowerCase()) + .filter(Boolean); + + if (normalized.length === 0) { + return undefined; + } + + return Array.from(new Set(normalized)).join(","); +} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index dc5cd119f..e13d93492 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -474,6 +474,8 @@ export interface SignalReportsQueryParams { source_product?: string; /** Comma-separated PostHog user UUIDs — only returns reports with these suggested reviewers. */ suggested_reviewers?: string; + /** Comma-separated `owner/repo` strings — only returns reports whose selected repository matches. */ + repository?: string; } /** Values match `SignalReportTask.Relationship` on the PostHog API. */