From 86ea2aa4726391a2b1ba9a69a3a561a4c84c6f4b Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 7 May 2026 15:56:47 +0000 Subject: [PATCH] feat(inbox): add low-key repository filter Adds a repo filter to the Inbox toolbar, modeled after the existing suggested reviewer filter. Lists repos pulled from the user's connected GitHub integrations and matches them against each report's selected repository (the `repo_selection` artefact, surfaced via a new `repository` query param on the signal reports endpoint). Generated-By: PostHog Code Task-Id: 7f784087-7d5e-46c8-9cb9-99480b9c7bb0 --- apps/code/src/renderer/api/posthogClient.ts | 3 + .../inbox/components/InboxSignalsTab.tsx | 6 + .../inbox/components/list/RepoFilterMenu.tsx | 157 ++++++++++++++++++ .../inbox/components/list/SignalsToolbar.tsx | 2 + .../stores/inboxSignalsFilterStore.test.ts | 39 +++++ .../inbox/stores/inboxSignalsFilterStore.ts | 25 +++ .../inbox/utils/filterReports.test.ts | 42 +++++ .../features/inbox/utils/filterReports.ts | 14 ++ apps/code/src/shared/types.ts | 2 + 9 files changed, 290 insertions(+) create mode 100644 apps/code/src/renderer/features/inbox/components/list/RepoFilterMenu.tsx 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. */