Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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: "",
Expand Down
12 changes: 12 additions & 0 deletions src/components/SidebarControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,18 @@ export function SidebarControls({
<CheckList entries={[...activeFacets.labels.entries()]} selected={activeFilters.labels} showSwatch onToggle={(value) => onActiveFiltersChange({ ...activeFilters, labels: toggleSetValue(activeFilters.labels, value) })} />
</FilterSection>
<FilterSection title={t("sidebar.authors")} activeCount={activeFilters.authors.size} dataFor={prMode ? "prs-only" : "issues-only"} onClear={() => onActiveFiltersChange({ ...activeFilters, authors: new Set() })}>
<div className="opt-group author-mode" role="group" aria-label={t("sidebar.authorFilterMode")}>
{(["include", "exclude"] as const).map((mode) => (
<button
type="button"
key={mode}
className={activeFilters.authorMode === mode ? "active" : ""}
onClick={() => onActiveFiltersChange({ ...activeFilters, authorMode: mode })}
>
{t(mode === "include" ? "sidebar.includeAuthors" : "sidebar.excludeAuthors")}
</button>
))}
</div>
<CheckList entries={[...activeFacets.authors.entries()]} selected={activeFilters.authors} onToggle={(value) => onActiveFiltersChange({ ...activeFilters, authors: toggleSetValue(activeFilters.authors, value) })} />
</FilterSection>
<FilterSection title={t("sidebar.assignees")} activeCount={activeFilters.assignees.size} dataFor={prMode ? "prs-only" : "issues-only"} onClear={() => onActiveFiltersChange({ ...activeFilters, assignees: new Set() })}>
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export const de: Record<keyof typeof en, string> = {
"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",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export const es: Record<keyof typeof en, string> = {
"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",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export const fr: Record<keyof typeof en, string> = {
"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é",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export const it: Record<keyof typeof en, string> = {
"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à",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export const zh: Record<keyof typeof en, string> = {
"sidebar.repositories": "仓库",
"sidebar.labels": "标签",
"sidebar.authors": "作者",
"sidebar.authorFilterMode": "作者筛选模式",
"sidebar.includeAuthors": "仅包含",
"sidebar.excludeAuthors": "排除",
"sidebar.assignees": "负责人",
"sidebar.languages": "语言",
"sidebar.visibility": "可见性",
Expand Down
1 change: 1 addition & 0 deletions src/styles/layout-sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions src/utils/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface IssueFilters {
repos: Set<string>;
labels: Set<string>;
authors: Set<string>;
authorMode: "include" | "exclude";
assignees: Set<string>;
dates: DateFilters;
preset: string;
Expand All @@ -25,6 +26,7 @@ export interface PullRequestFilters {
repos: Set<string>;
labels: Set<string>;
authors: Set<string>;
authorMode: "include" | "exclude";
assignees: Set<string>;
dates: DateFilters;
preset: string;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/utils/filtersCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/utils/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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: "",
Expand Down Expand Up @@ -253,13 +270,30 @@ 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: "",
}, "alice");
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);
Expand Down
22 changes: 18 additions & 4 deletions tests/utils/filtersCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ function makeDefaultRepoFilters() {
}

function makeDefaultIssueFilters() {
return { search: "", orgs: new Set<string>(), repos: new Set<string>(), labels: new Set<string>(), authors: new Set<string>(), assignees: new Set<string>(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" };
return { search: "", orgs: new Set<string>(), repos: new Set<string>(), labels: new Set<string>(), authors: new Set<string>(), authorMode: "include" as const, assignees: new Set<string>(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" };
}

function makeDefaultPrFilters() {
return { search: "", orgs: new Set<string>(), repos: new Set<string>(), labels: new Set<string>(), authors: new Set<string>(), assignees: new Set<string>(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" };
return { search: "", orgs: new Set<string>(), repos: new Set<string>(), labels: new Set<string>(), authors: new Set<string>(), authorMode: "include" as const, assignees: new Set<string>(), dates: { cf: "", ct: "", uf: "", ut: "" }, preset: "" };
}

describe("filtersCache", () => {
Expand All @@ -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();
Expand All @@ -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);
});

Expand All @@ -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();
Expand Down