From 925efb5b548de8d014cad268f10eb5c22c9044ff Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 19 Aug 2026 15:32:12 +0200 Subject: [PATCH] feat: support excluding issue and PR authors --- src/App.tsx | 2 ++ src/components/SidebarControls.tsx | 12 +++++++++++ src/i18n/de.ts | 3 +++ src/i18n/en.ts | 3 +++ src/i18n/es.ts | 3 +++ src/i18n/fr.ts | 3 +++ src/i18n/it.ts | 3 +++ src/i18n/zh.ts | 3 +++ src/styles/layout-sidebar.css | 1 + src/utils/dashboard.ts | 12 +++++++++-- src/utils/filtersCache.ts | 9 ++++++++ tests/utils/dashboard.test.ts | 34 ++++++++++++++++++++++++++++++ tests/utils/filtersCache.test.ts | 22 +++++++++++++++---- 13 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bb547c0..8cc2b05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -124,6 +124,7 @@ const defaultIssueFilters = (): IssueFilters => ({ repos: new Set(), labels: new Set(), authors: new Set(), + authorMode: "include", assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "", @@ -135,6 +136,7 @@ const defaultPrFilters = (): PullRequestFilters => ({ repos: new Set(), labels: new Set(), authors: new Set(), + authorMode: "include", assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "", diff --git a/src/components/SidebarControls.tsx b/src/components/SidebarControls.tsx index c0ca80b..d065ce3 100644 --- a/src/components/SidebarControls.tsx +++ b/src/components/SidebarControls.tsx @@ -269,6 +269,18 @@ export function SidebarControls({ onActiveFiltersChange({ ...activeFilters, labels: toggleSetValue(activeFilters.labels, value) })} /> onActiveFiltersChange({ ...activeFilters, authors: new Set() })}> +
+ {(["include", "exclude"] as const).map((mode) => ( + + ))} +
onActiveFiltersChange({ ...activeFilters, authors: toggleSetValue(activeFilters.authors, value) })} />
onActiveFiltersChange({ ...activeFilters, assignees: new Set() })}> diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 4217ff3..776351d 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -132,6 +132,9 @@ export const de: Record = { "sidebar.repositories": "Repositories", "sidebar.labels": "Labels", "sidebar.authors": "Autoren", + "sidebar.authorFilterMode": "Autorenfiltermodus", + "sidebar.includeAuthors": "Nur einschließen", + "sidebar.excludeAuthors": "Ausschließen", "sidebar.assignees": "Zugewiesene", "sidebar.languages": "Sprachen", "sidebar.visibility": "Sichtbarkeit", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5782a1f..8dc7b97 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -130,6 +130,9 @@ export const en = { "sidebar.repositories": "Repositories", "sidebar.labels": "Labels", "sidebar.authors": "Authors", + "sidebar.authorFilterMode": "Author filter mode", + "sidebar.includeAuthors": "Include only", + "sidebar.excludeAuthors": "Exclude", "sidebar.assignees": "Assignees", "sidebar.languages": "Languages", "sidebar.visibility": "Visibility", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 50dadf5..c252930 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -132,6 +132,9 @@ export const es: Record = { "sidebar.repositories": "Repositorios", "sidebar.labels": "Etiquetas", "sidebar.authors": "Autores", + "sidebar.authorFilterMode": "Modo de filtro de autores", + "sidebar.includeAuthors": "Solo incluir", + "sidebar.excludeAuthors": "Excluir", "sidebar.assignees": "Asignados", "sidebar.languages": "Lenguajes", "sidebar.visibility": "Visibilidad", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index b145bd6..42f8a7e 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -132,6 +132,9 @@ export const fr: Record = { "sidebar.repositories": "Dépôts", "sidebar.labels": "Labels", "sidebar.authors": "Auteurs", + "sidebar.authorFilterMode": "Mode de filtrage des auteurs", + "sidebar.includeAuthors": "Inclure uniquement", + "sidebar.excludeAuthors": "Exclure", "sidebar.assignees": "Assignés", "sidebar.languages": "Langages", "sidebar.visibility": "Visibilité", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 2a0259f..00fd3b1 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -132,6 +132,9 @@ export const it: Record = { "sidebar.repositories": "Repository", "sidebar.labels": "Etichette", "sidebar.authors": "Autori", + "sidebar.authorFilterMode": "Modalità filtro autori", + "sidebar.includeAuthors": "Solo inclusi", + "sidebar.excludeAuthors": "Escludi", "sidebar.assignees": "Assegnatari", "sidebar.languages": "Linguaggi", "sidebar.visibility": "Visibilità", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c026f22..644ad6e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -132,6 +132,9 @@ export const zh: Record = { "sidebar.repositories": "仓库", "sidebar.labels": "标签", "sidebar.authors": "作者", + "sidebar.authorFilterMode": "作者筛选模式", + "sidebar.includeAuthors": "仅包含", + "sidebar.excludeAuthors": "排除", "sidebar.assignees": "负责人", "sidebar.languages": "语言", "sidebar.visibility": "可见性", diff --git a/src/styles/layout-sidebar.css b/src/styles/layout-sidebar.css index 72de1f7..33bb0b6 100644 --- a/src/styles/layout-sidebar.css +++ b/src/styles/layout-sidebar.css @@ -156,6 +156,7 @@ /* Radio-like option groups */ .opt-group { display: flex; gap: 4px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 3px; } + .opt-group.author-mode { margin-bottom: 6px; } .opt-group button { flex: 1; background: transparent; border: none; color: var(--muted); cursor: pointer; padding: 6px 8px; border-radius: 5px; font-size: 12px; font-weight: 600; diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index dc6db32..1314927 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -14,6 +14,7 @@ export interface IssueFilters { repos: Set; labels: Set; authors: Set; + authorMode: "include" | "exclude"; assignees: Set; dates: DateFilters; preset: string; @@ -25,6 +26,7 @@ export interface PullRequestFilters { repos: Set; labels: Set; authors: Set; + authorMode: "include" | "exclude"; assignees: Set; dates: DateFilters; preset: string; @@ -144,7 +146,10 @@ export function filterIssues(issues: GhIssue[], filters: IssueFilters, userLogin if (filters.orgs.size && !filters.orgs.has(owner)) return false; if (filters.repos.size && !filters.repos.has(issue.repository.nameWithOwner)) return false; if (filters.labels.size && ![...(issue.labels || []).map((label) => label.name)].some((label) => filters.labels.has(label))) return false; - if (filters.authors.size && !filters.authors.has(issue.author?.login || "")) return false; + if (filters.authors.size) { + const authorSelected = filters.authors.has(issue.author?.login || ""); + if (filters.authorMode === "exclude" ? authorSelected : !authorSelected) return false; + } if (filters.assignees.size && ![...(issue.assignees || []).map((assignee) => assignee.login)].some((login) => filters.assignees.has(login))) return false; const created = new Date(issue.createdAt).getTime(); @@ -213,7 +218,10 @@ export function filterPullRequests(prs: GhPullRequest[], filters: PullRequestFil if (filters.orgs.size && !filters.orgs.has(owner)) return false; if (filters.repos.size && !filters.repos.has(pr.repository.nameWithOwner)) return false; if (filters.labels.size && ![...(pr.labels || []).map((label) => label.name)].some((label) => filters.labels.has(label))) return false; - if (filters.authors.size && !filters.authors.has(pr.author?.login || "")) return false; + if (filters.authors.size) { + const authorSelected = filters.authors.has(pr.author?.login || ""); + if (filters.authorMode === "exclude" ? authorSelected : !authorSelected) return false; + } if (filters.assignees.size && ![...(pr.assignees || []).map((assignee) => assignee.login)].some((login) => filters.assignees.has(login))) return false; const created = new Date(pr.createdAt).getTime(); diff --git a/src/utils/filtersCache.ts b/src/utils/filtersCache.ts index 87bbdc4..7f9325c 100644 --- a/src/utils/filtersCache.ts +++ b/src/utils/filtersCache.ts @@ -23,6 +23,7 @@ export interface CachedFilters { repos: string[]; labels: string[]; authors: string[]; + authorMode?: "include" | "exclude"; assignees: string[]; dates: { cf: string; ct: string; uf: string; ut: string }; preset: string; @@ -33,6 +34,7 @@ export interface CachedFilters { repos: string[]; labels: string[]; authors: string[]; + authorMode?: "include" | "exclude"; assignees: string[]; dates: { cf: string; ct: string; uf: string; ut: string }; preset: string; @@ -47,6 +49,7 @@ export interface CachedFilters { } const VALID_VISIBILITY = new Set(["all", "public", "private"]); +const VALID_AUTHOR_MODES = new Set(["include", "exclude"]); function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); @@ -82,6 +85,7 @@ function validateShape(parsed: unknown): parsed is CachedFilters { if (!isStringArray(issueF.repos)) return false; if (!isStringArray(issueF.labels)) return false; if (!isStringArray(issueF.authors)) return false; + if (issueF.authorMode !== undefined && (typeof issueF.authorMode !== "string" || !VALID_AUTHOR_MODES.has(issueF.authorMode))) return false; if (!isStringArray(issueF.assignees)) return false; if (!isDateFilters(issueF.dates)) return false; if (typeof issueF.preset !== "string") return false; @@ -95,6 +99,7 @@ function validateShape(parsed: unknown): parsed is CachedFilters { if (!isStringArray(prF.repos)) return false; if (!isStringArray(prF.labels)) return false; if (!isStringArray(prF.authors)) return false; + if (prF.authorMode !== undefined && (typeof prF.authorMode !== "string" || !VALID_AUTHOR_MODES.has(prF.authorMode))) return false; if (!isStringArray(prF.assignees)) return false; if (!isDateFilters(prF.dates)) return false; if (typeof prF.preset !== "string") return false; @@ -150,6 +155,7 @@ export function hydrateFilters(cached: CachedFilters): { repos: new Set(cached.issueFilters.repos), labels: new Set(cached.issueFilters.labels), authors: new Set(cached.issueFilters.authors), + authorMode: cached.issueFilters.authorMode ?? "include", assignees: new Set(cached.issueFilters.assignees), dates: { ...cached.issueFilters.dates }, preset: cached.issueFilters.preset, @@ -160,6 +166,7 @@ export function hydrateFilters(cached: CachedFilters): { repos: new Set(cached.prFilters.repos), labels: new Set(cached.prFilters.labels), authors: new Set(cached.prFilters.authors), + authorMode: cached.prFilters.authorMode ?? "include", assignees: new Set(cached.prFilters.assignees), dates: { ...cached.prFilters.dates }, preset: cached.prFilters.preset, @@ -193,6 +200,7 @@ export function writeFiltersCache( repos: [...issueFilters.repos], labels: [...issueFilters.labels], authors: [...issueFilters.authors], + authorMode: issueFilters.authorMode, assignees: [...issueFilters.assignees], dates: { ...issueFilters.dates }, preset: issueFilters.preset, @@ -203,6 +211,7 @@ export function writeFiltersCache( repos: [...prFilters.repos], labels: [...prFilters.labels], authors: [...prFilters.authors], + authorMode: prFilters.authorMode, assignees: [...prFilters.assignees], dates: { ...prFilters.dates }, preset: prFilters.preset, diff --git a/tests/utils/dashboard.test.ts b/tests/utils/dashboard.test.ts index 02735b3..0503799 100644 --- a/tests/utils/dashboard.test.ts +++ b/tests/utils/dashboard.test.ts @@ -153,6 +153,7 @@ describe("dashboard utilities", () => { repos: new Set(["acme/app"]), labels: new Set(["bug"]), authors: new Set(), + authorMode: "include", assignees: new Set(["bob"]), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "", @@ -161,6 +162,22 @@ describe("dashboard utilities", () => { expect(result.map((issue) => issue.number)).toEqual([1]); }); + it("excludes selected issue authors", () => { + const result = filterIssues(issues, { + search: "", + orgs: new Set(), + repos: new Set(), + labels: new Set(), + authors: new Set(["alice"]), + authorMode: "exclude", + assignees: new Set(), + dates: { cf: "", ct: "", uf: "", ut: "" }, + preset: "", + }, "alice"); + + expect(result.map((issue) => issue.number)).toEqual([2]); + }); + it("filters repositories by visibility and language", () => { const result = filterRepos(repos, issues, { search: "", @@ -253,6 +270,7 @@ describe("dashboard utilities", () => { repos: new Set(["acme/app"]), labels: new Set(), authors: new Set(["alice"]), + authorMode: "include", assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "", @@ -260,6 +278,22 @@ describe("dashboard utilities", () => { expect(result.map((pr) => pr.number)).toEqual([11]); }); + it("excludes selected pull request authors", () => { + const result = filterPullRequests(pullRequests, { + search: "", + orgs: new Set(), + repos: new Set(), + labels: new Set(), + authors: new Set(["bob"]), + authorMode: "exclude", + assignees: new Set(), + dates: { cf: "", ct: "", uf: "", ut: "" }, + preset: "", + }, "alice"); + + expect(result.map((pr) => pr.number)).toEqual([10, 11]); + }); + it("sorts PRs by review-pending and diff size", () => { const byReview = sortPullRequests(pullRequests, "review_pending").map((pr) => pr.number); expect(byReview[0]).toBe(11); diff --git a/tests/utils/filtersCache.test.ts b/tests/utils/filtersCache.test.ts index 77bf6c9..2d0b1de 100644 --- a/tests/utils/filtersCache.test.ts +++ b/tests/utils/filtersCache.test.ts @@ -11,11 +11,11 @@ function makeDefaultRepoFilters() { } function makeDefaultIssueFilters() { - return { search: "", orgs: new Set(), repos: new Set(), labels: new Set(), authors: new Set(), assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" }; + return { search: "", orgs: new Set(), repos: new Set(), labels: new Set(), authors: new Set(), authorMode: "include" as const, assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" }; } function makeDefaultPrFilters() { - return { search: "", orgs: new Set(), repos: new Set(), labels: new Set(), authors: new Set(), assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" }; + return { search: "", orgs: new Set(), repos: new Set(), labels: new Set(), authors: new Set(), authorMode: "include" as const, assignees: new Set(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" }; } describe("filtersCache", () => { @@ -33,8 +33,8 @@ describe("filtersCache", () => { it("round-trips write then read", () => { const repo = { ...makeDefaultRepoFilters(), orgs: new Set(["acme"]), languages: new Set(["Go"]), visibility: "private" as const }; - const issue = { ...makeDefaultIssueFilters(), search: "bug", orgs: new Set(["acme"]) }; - const pr = { ...makeDefaultPrFilters(), preset: "draft" }; + const issue = { ...makeDefaultIssueFilters(), search: "bug", orgs: new Set(["acme"]), authors: new Set(["renovate"]), authorMode: "exclude" as const }; + const pr = { ...makeDefaultPrFilters(), preset: "draft", authors: new Set(["alice"]), authorMode: "exclude" as const }; writeFiltersCache(repo, issue, pr); const result = readFiltersCache(); @@ -45,7 +45,9 @@ describe("filtersCache", () => { expect(result!.repoFilters.visibility).toBe("private"); expect(result!.issueFilters.search).toBe("bug"); expect(result!.issueFilters.orgs).toEqual(["acme"]); + expect(result!.issueFilters.authorMode).toBe("exclude"); expect(result!.prFilters.preset).toBe("draft"); + expect(result!.prFilters.authorMode).toBe("exclude"); expect(result!.savedAt).toBeGreaterThan(0); }); @@ -62,6 +64,18 @@ describe("filtersCache", () => { expect(hydrated.repoFilters.languages.has("TypeScript")).toBe(true); }); + it("hydrates legacy caches with inclusive author filters", () => { + writeFiltersCache(makeDefaultRepoFilters(), makeDefaultIssueFilters(), makeDefaultPrFilters()); + const raw = JSON.parse(localStorage.getItem("gh-dash.cache.filters")!); + delete raw.issueFilters.authorMode; + delete raw.prFilters.authorMode; + localStorage.setItem("gh-dash.cache.filters", JSON.stringify(raw)); + + const hydrated = hydrateFilters(readFiltersCache()!); + expect(hydrated.issueFilters.authorMode).toBe("include"); + expect(hydrated.prFilters.authorMode).toBe("include"); + }); + it("returns null for corrupted JSON", () => { localStorage.setItem("gh-dash.cache.filters", "not-json{{{"); expect(readFiltersCache()).toBeNull();