From 890fe1d2799a7266bd6a64291a391694e256d2d1 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 13 Aug 2026 16:21:46 -0400 Subject: [PATCH 01/10] feat(dependencies): adds excludedOrgs/excludedRepos to config schema --- src/shared/schemas.ts | 4 +- tests/components/DashboardPage.test.tsx | 4 +- tests/components/IssuesTab.test.tsx | 8 ++-- .../components/settings/SettingsPage.test.tsx | 18 +++---- tests/stores/config.test.ts | 48 +++++++++++++++++++ 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 0aa97d5c..22e3f13c 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -86,6 +86,8 @@ export type JiraConfig = z.infer; export const DependencyConfigSchema = z.object({ enabled: z.boolean().default(true), rebaseLabel: z.string().min(1).max(50).default("rebase"), + excludedOrgs: z.array(z.string().regex(REPO_SEGMENT)).max(100).default([]), + excludedRepos: z.array(RepoRefSchema).max(100).default([]), }); export type DependencyConfig = z.infer; @@ -121,7 +123,7 @@ export const ConfigSchema = z.object({ mcpRelayPort: z.number().int().min(1024).max(65535).default(9876), // Explicit defaults (NOT .default({})) — inner field defaults don't apply with .default({}) per BUG-001 jira: JiraConfigSchema.default({ enabled: false, authMethod: "oauth", issueKeyDetection: true, expandIssueDetails: false, customFields: [], customScopes: [] }), - dependencies: DependencyConfigSchema.default({ enabled: true, rebaseLabel: "rebase" }), + dependencies: DependencyConfigSchema.default({ enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] }), }); export type Config = z.infer; diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index d3775f74..73a557d2 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2043,7 +2043,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { errors: [], }); - configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); render(() => ); await waitFor(() => { @@ -2068,7 +2068,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); it("dep PRs appear on Pull Requests tab when dependencies feature is disabled", async () => { - configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], pullRequests: [ diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index ec826bf5..f68373b1 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -577,7 +577,7 @@ describe("IssuesTab", () => { describe("IssuesTab — hideDepDashboard + dependencies.enabled interaction", () => { it("shows Dependency Dashboard in custom tab even when hideDepDashboard=true and deps disabled", () => { viewStore.updateViewState({ hideDepDashboard: true }); - updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); const issues = [ makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo", userLogin: "me" }), ]; @@ -590,7 +590,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled interaction", () describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => { it("hides Dependency Dashboard issue when hideDepDashboard=true and dependencies.enabled=false", () => { viewStore.updateViewState({ hideDepDashboard: true }); - updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); const issues = [ makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }), @@ -603,7 +603,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => { it("shows Dependency Dashboard issue when hideDepDashboard=true but dependencies.enabled=true", () => { viewStore.updateViewState({ hideDepDashboard: true }); - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); const issues = [ makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }), @@ -616,7 +616,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => { it("shows Dependency Dashboard issue when hideDepDashboard=false regardless of dependencies.enabled", () => { viewStore.updateViewState({ hideDepDashboard: false }); - updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); const issues = [ makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), ]; diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index ac5240da..1a432bd3 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -1167,21 +1167,21 @@ describe("Dependencies settings section", () => { }); it("renders Dependencies tab toggle checked when enabled", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); expect(toggle.checked).toBe(true); }); it("renders Dependencies tab toggle unchecked when disabled", () => { - updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); expect(toggle.checked).toBe(false); }); it("toggles dependencies.enabled when checkbox is clicked", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); fireEvent.click(toggle); @@ -1189,7 +1189,7 @@ describe("Dependencies settings section", () => { }); it("disabling dependencies resets defaultTab to 'issues' when it was 'dependencies'", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" }, defaultTab: "dependencies" }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] }, defaultTab: "dependencies" }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); fireEvent.click(toggle); @@ -1198,7 +1198,7 @@ describe("Dependencies settings section", () => { }); it("disabling dependencies preserves defaultTab when it was not 'dependencies'", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" }, defaultTab: "pullRequests" }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] }, defaultTab: "pullRequests" }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); fireEvent.click(toggle); @@ -1207,7 +1207,7 @@ describe("Dependencies settings section", () => { }); it("disabling dependencies resets lastActiveTab to 'issues' when it was 'dependencies'", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); updateViewState({ lastActiveTab: "dependencies" }); renderSettings(); const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); @@ -1217,14 +1217,14 @@ describe("Dependencies settings section", () => { }); it("renders rebase label input with current value", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase-please" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase-please", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const input = screen.getByRole("textbox", { name: /Rebase label/i }); expect(input.value).toBe("rebase-please"); }); it("updates dependencies.rebaseLabel on input change", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const input = screen.getByRole("textbox", { name: /Rebase label/i }); fireEvent.input(input, { target: { value: "rebase-please" } }); @@ -1232,7 +1232,7 @@ describe("Dependencies settings section", () => { }); it("rebase label input falls back to 'rebase' when cleared", () => { - updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase", excludedOrgs: [], excludedRepos: [] } }); renderSettings(); const input = screen.getByRole("textbox", { name: /Rebase label/i }); fireEvent.input(input, { target: { value: "" } }); diff --git a/tests/stores/config.test.ts b/tests/stores/config.test.ts index 19fbebfd..5a8ad8f1 100644 --- a/tests/stores/config.test.ts +++ b/tests/stores/config.test.ts @@ -55,6 +55,21 @@ describe("ConfigSchema", () => { expect(result.onboardingComplete).toBe(false); expect(result.authMethod).toBe("oauth"); expect(result.enableActions).toBe(true); + expect(result.dependencies.excludedOrgs).toEqual([]); + expect(result.dependencies.excludedRepos).toEqual([]); + }); + + it("round-trips excludedOrgs/excludedRepos when provided", () => { + const result = ConfigSchema.parse({ + dependencies: { + enabled: true, + rebaseLabel: "rebase", + excludedOrgs: ["some-org"], + excludedRepos: [{ owner: "some-org", name: "repo", fullName: "some-org/repo" }], + }, + }); + expect(result.dependencies.excludedOrgs).toEqual(["some-org"]); + expect(result.dependencies.excludedRepos).toEqual([{ owner: "some-org", name: "repo", fullName: "some-org/repo" }]); }); it("enableActions defaults to true (Actions tab enabled by default)", () => { @@ -660,6 +675,39 @@ describe("updateConfig — customTabs scope pruning on selectedRepos change", () // curated via explicit add/remove UI, never auto-merged from a live fetch. // - selectedOrgs: reconciled against a live fetchOrgs() result rather than // a sibling config field — covered separately in OrgSelector.test.tsx. +// - dependencies.excludedOrgs / dependencies.excludedRepos: their available +// pool is the union of selectedRepos + upstreamRepos + monitoredRepos, not +// selectedRepos alone, so pruning against selectedRepos shrinkage alone +// would incorrectly drop valid exclusions for a repo still present via +// upstreamRepos or monitoredRepos. +// +// Contrast with customTabs.orgScope/repoScope above, which IS pruned +// under this same `if ("selectedRepos" in partial)` guard — structurally +// it looks identical (an org-string array plus a repo-array, both living +// in a settings scope picker), which could tempt a future implementer or +// reviewer into "fixing" the asymmetry by copying that pruning onto +// excludedOrgs/excludedRepos. Don't: customTabs is safely prunable +// against selectedRepos alone specifically because its own +// available-options pool (availableOrgs/availableRepos, passed into +// CustomTabModal from config.selectedRepos only) IS selectedRepos alone, +// with no upstreamRepos/monitoredRepos contribution. excludedOrgs/ +// excludedRepos have no such narrow pool; applying customTabs' pruning +// technique to them would silently drop valid exclusions for repos +// tracked only via upstreamRepos or monitoredRepos. +// +// The two exclusion fields are NOT equally inert when stale. A stale +// excludedRepos entry (referencing a repo no longer in any of the three +// pools) has no effect unless that exact repo is re-added later, and is +// incidentally pruned the next time the exclusion modal is saved +// (buildExcludedRepos filters against availableRepos at save time — the +// same asymmetry CustomTabModal already has between its own repoScope, +// filtered via buildRepoScope() on save, and orgScope, saved verbatim). +// A stale excludedOrgs entry is more persistent: because org-level +// matching is intentionally dynamic and re-evaluated against +// pr.repoFullName on every render, a leftover org name silently +// re-excludes any repo added under that org later — including repos +// unrelated to the original exclusion — and, like CustomTabModal's +// orgScope, is never filtered against availableOrgs on save either. describe("updateConfig — structural invariant: repo-referencing fields reconcile with selectedRepos", () => { beforeEach(() => { resetConfig(); From b3e93d83228372a54030eebda3c2adcbd934e0b0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 13 Aug 2026 16:49:26 -0400 Subject: [PATCH 02/10] feat(dependencies): adds isRepoExcludedFromDependencies matcher --- src/app/lib/dependency-exclusion.ts | 12 ++++++++++ tests/lib/dependency-exclusion.test.ts | 31 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/app/lib/dependency-exclusion.ts create mode 100644 tests/lib/dependency-exclusion.test.ts diff --git a/src/app/lib/dependency-exclusion.ts b/src/app/lib/dependency-exclusion.ts new file mode 100644 index 00000000..33a2b5df --- /dev/null +++ b/src/app/lib/dependency-exclusion.ts @@ -0,0 +1,12 @@ +import type { RepoRef } from "../../shared/types.js"; + +export function isRepoExcludedFromDependencies( + repoFullName: string, + excludedOrgs: readonly string[], + excludedRepos: readonly Pick[] +): boolean { + const lower = repoFullName.toLowerCase(); + if (excludedRepos.some((r) => r.fullName.toLowerCase() === lower)) return true; + const org = lower.split("/")[0]; + return excludedOrgs.some((o) => o.toLowerCase() === org); +} diff --git a/tests/lib/dependency-exclusion.test.ts b/tests/lib/dependency-exclusion.test.ts new file mode 100644 index 00000000..67cea166 --- /dev/null +++ b/tests/lib/dependency-exclusion.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { isRepoExcludedFromDependencies } from "../../src/app/lib/dependency-exclusion.js"; + +describe("isRepoExcludedFromDependencies", () => { + it("returns true when the repo is in excludedRepos (exact fullName match)", () => { + const excludedRepos = [{ fullName: "owner/repo" }]; + expect(isRepoExcludedFromDependencies("owner/repo", [], excludedRepos)).toBe(true); + }); + + it("returns true when the repo's owner is in excludedOrgs", () => { + expect(isRepoExcludedFromDependencies("owner/repo", ["owner"], [])).toBe(true); + }); + + it("returns false when neither list contains the repo", () => { + const excludedRepos = [{ fullName: "other-owner/other-repo" }]; + expect(isRepoExcludedFromDependencies("owner/repo", ["other-org"], excludedRepos)).toBe(false); + }); + + it("matches org exclusion case-insensitively", () => { + expect(isRepoExcludedFromDependencies("some-org/repo", ["Some-Org"], [])).toBe(true); + }); + + it("matches repo exclusion case-insensitively", () => { + const excludedRepos = [{ fullName: "Some-Org/Repo" }]; + expect(isRepoExcludedFromDependencies("some-org/repo", [], excludedRepos)).toBe(true); + }); + + it("returns false when excludedOrgs and excludedRepos are both empty", () => { + expect(isRepoExcludedFromDependencies("owner/repo", [], [])).toBe(false); + }); +}); From 07bf576d1869dcf8b2460701feb1792dd4d1b6ec Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 13 Aug 2026 16:50:22 -0400 Subject: [PATCH 03/10] feat(dependencies): filters excluded repos from Dependencies tab Preserves exclusivity claiming on the unfiltered classification so excluded repos' bot PRs vanish entirely instead of reappearing in the Pull Requests tab. --- .../components/dashboard/DashboardPage.tsx | 26 ++- tests/components/DashboardPage.test.tsx | 189 +++++++++++++++++- 2 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 6b96ad1b..99be3529 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -12,6 +12,7 @@ import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, isTab import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import DependenciesTab from "./DependenciesTab"; import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection"; +import { isRepoExcludedFromDependencies } from "../../lib/dependency-exclusion"; import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; import { fetchDashboardIssueBodies, fetchDepPRBodies } from "../../services/api"; import type { SortOption } from "../shared/SortDropdown"; @@ -857,10 +858,22 @@ export default function DashboardPage() { const trackedBotLogins = createMemo(() => expandBotLogins(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) ); + // Unfiltered classification — for rendering/counting use visibleDependencyPullRequests below. const dependencyPullRequests = createMemo(() => { if (!config.dependencies.enabled) return []; return dashboardData.pullRequests.filter((pr) => pr.state === "OPEN" && isDependencyPr(pr, trackedBotLogins())); }); + + // Dependencies-tab display filter — applied on top of the unfiltered + // dependencyPullRequests classification above. dependencyPrIds (below) and + // exclusiveOwnership deliberately keep using the UNFILTERED memo, so an + // excluded repo's bot PRs still don't leak into the main Pull Requests tab — + // they vanish entirely instead of reappearing elsewhere. + const visibleDependencyPullRequests = createMemo(() => + dependencyPullRequests().filter( + (pr) => !isRepoExcludedFromDependencies(pr.repoFullName, config.dependencies.excludedOrgs, config.dependencies.excludedRepos) + ) + ); const dependencyPrIds = createMemo(() => new Set(dependencyPullRequests().map((pr) => pr.id)) ); @@ -917,7 +930,7 @@ export default function DashboardPage() { } const enableDependencies = createMemo(() => - config.dependencies.enabled && dependencyPullRequests().length > 0 + config.dependencies.enabled && visibleDependencyPullRequests().length > 0 ); // Visible data for built-in tabs — filters out exclusively-owned items @@ -1070,7 +1083,7 @@ export default function DashboardPage() { return true; }).length }; })() : {}), - ...(enableDependencies() ? { dependencies: dependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), + ...(enableDependencies() ? { dependencies: visibleDependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), ...customCounts, }; }); @@ -1163,7 +1176,7 @@ export default function DashboardPage() { void (async () => { try { const dashboardIssues = findDashboardIssues(dashboardData.issues, trackedBotLogins()); - const depRepos = new Set(dependencyPullRequests().map((pr) => pr.repoFullName)); + const depRepos = new Set(visibleDependencyPullRequests().map((pr) => pr.repoFullName)); const relevant = dashboardIssues.filter((di) => depRepos.has(di.repoFullName)); if (relevant.length === 0) return; @@ -1202,7 +1215,8 @@ export default function DashboardPage() { const meta = depMeta(); const depPrs = dependencyPullRequests(); - const toFetch = depPrs.filter((pr) => !meta.has(pr.id) && needsBodyFallback(pr)); + const visibleDepPrs = visibleDependencyPullRequests(); + const toFetch = visibleDepPrs.filter((pr) => !meta.has(pr.id) && needsBodyFallback(pr)); if (toFetch.length === 0) return; _fetchingDepBodies = true; @@ -1323,7 +1337,7 @@ export default function DashboardPage() { ({ vi.mock("../../src/app/services/github", () => ({ getCoreRateLimit: () => null, getGraphqlRateLimit: () => null, - getClient: () => null, + getClient: vi.fn(() => null), })); // Mock notifications lib @@ -2192,6 +2193,192 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); }); +// ── Dependencies tab — repo/org exclusion filtering ────────────────────────── + +describe("DashboardPage — dependency exclusions", () => { + it("Test A: excludedRepos hides that repo's dependency PR from the Dependencies tab", async () => { + configStore.updateConfig({ + dependencies: { + ...configStore.config.dependencies, + excludedRepos: [{ owner: "owner", name: "excluded-repo", fullName: "owner/excluded-repo" }], + }, + }); + + const user = userEvent.setup(); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ + repoFullName: "owner/excluded-repo", + title: "Bump lodash from 4.1 to 4.2", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.2", + }), + makePullRequest({ + repoFullName: "owner/other-repo", + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => { + expect(screen.getByText("axios: 0.27 → 1.0")).toBeDefined(); + expect(screen.queryByText("lodash: 4.1 → 4.2")).toBeNull(); + }); + }); + + it("Test B: excluded repo's dependency PR does not leak into the Pull Requests tab (exclusivity preserved)", async () => { + configStore.updateConfig({ + dependencies: { + ...configStore.config.dependencies, + excludedRepos: [{ owner: "owner", name: "excluded-repo", fullName: "owner/excluded-repo" }], + }, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ + repoFullName: "owner/excluded-repo", + title: "Bump lodash from 4.1 to 4.2", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.2", + }), + makePullRequest({ + repoFullName: "owner/other-repo", + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const prTab = screen.getByRole("tab", { name: /^Pull Requests/ }); + expect(prTab.textContent?.replace(/\D+/g, "")).toBe("0"); + }); + }); + + it("Test C: excludedOrgs hides that org's repo's dependency PR from the Dependencies tab", async () => { + configStore.updateConfig({ + dependencies: { + ...configStore.config.dependencies, + excludedOrgs: ["excluded-org"], + }, + }); + + const user = userEvent.setup(); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ + repoFullName: "excluded-org/repo-a", + title: "Bump lodash from 4.1 to 4.2", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.2", + }), + makePullRequest({ + repoFullName: "other-org/repo-b", + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => { + expect(screen.getByText("axios: 0.27 → 1.0")).toBeDefined(); + expect(screen.queryByText("lodash: 4.1 → 4.2")).toBeNull(); + }); + }); + + it("Test D: excluded repo's Dependency Dashboard issue is skipped for abandoned-package detection", async () => { + configStore.updateConfig({ + dependencies: { + ...configStore.config.dependencies, + excludedRepos: [{ owner: "owner", name: "excluded-repo", fullName: "owner/excluded-repo" }], + }, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_excluded", + repoFullName: "owner/excluded-repo", + state: "OPEN", + }), + makeIssue({ + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_other", + repoFullName: "owner/other-repo", + state: "OPEN", + }), + ], + pullRequests: [ + makePullRequest({ + repoFullName: "owner/excluded-repo", + title: "Bump lodash from 4.1 to 4.2", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.2", + }), + makePullRequest({ + repoFullName: "owner/other-repo", + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + }), + ], + workflowRuns: [], + errors: [], + }); + + const githubService = await import("../../src/app/services/github"); + const graphqlSpy = vi.fn().mockResolvedValue({ nodes: [], rateLimit: null }); + vi.mocked(githubService.getClient).mockReturnValue({ graphql: graphqlSpy } as unknown as ReturnType); + + vi.mocked(pollService.createPollCoordinator).mockImplementation((_getInterval, fetchAll) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + void fetchAll().then(() => setLastRefreshAt(new Date())).catch(() => {}); + return { isRefreshing: () => false, lastRefreshAt, manualRefresh: vi.fn(), destroy: vi.fn() }; + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalled()); + + const [, variables] = graphqlSpy.mock.calls[0] as [string, { ids: string[] }]; + expect(variables.ids).toContain("DASH_other"); + expect(variables.ids).not.toContain("DASH_excluded"); + }); +}); + // ── Dependencies tab — abandonedDepsMap + dashboardIssueUrls reset on auth clear ─ describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { From 9fa62df09096493db24a9fd5efe726ff530facc7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 13 Aug 2026 16:51:14 -0400 Subject: [PATCH 04/10] feat(dependencies): extracts OrgRepoCheckboxTree, adds exclusion modal OrgRepoCheckboxTree is a shared, checked-semantics-agnostic org/repo checkbox tree extracted from CustomTabModal's inline JSX. It is reused by both CustomTabModal (unchanged behavior) and the new DependencyExclusionModal, which manages excludedOrgs/excludedRepos with inverted checkbox semantics (checked = excluded). --- .../settings/DependencyExclusionModal.tsx | 134 ++++++++++++ src/app/components/shared/CustomTabModal.tsx | 59 ++---- .../components/shared/OrgRepoCheckboxTree.tsx | 58 ++++++ .../DependencyExclusionModal.test.tsx | 178 ++++++++++++++++ .../shared/OrgRepoCheckboxTree.test.tsx | 191 ++++++++++++++++++ 5 files changed, 573 insertions(+), 47 deletions(-) create mode 100644 src/app/components/settings/DependencyExclusionModal.tsx create mode 100644 src/app/components/shared/OrgRepoCheckboxTree.tsx create mode 100644 tests/components/settings/DependencyExclusionModal.test.tsx create mode 100644 tests/components/shared/OrgRepoCheckboxTree.test.tsx diff --git a/src/app/components/settings/DependencyExclusionModal.tsx b/src/app/components/settings/DependencyExclusionModal.tsx new file mode 100644 index 00000000..7c20aee7 --- /dev/null +++ b/src/app/components/settings/DependencyExclusionModal.tsx @@ -0,0 +1,134 @@ +import { createSignal, createEffect } from "solid-js"; +import { Dialog } from "@kobalte/core/dialog"; +import type { RepoRef } from "../../services/api"; +import OrgRepoCheckboxTree from "../shared/OrgRepoCheckboxTree"; + +interface DependencyExclusionModalProps { + open: boolean; + onClose: () => void; + availableOrgs: string[]; + availableRepos: RepoRef[]; + excludedOrgs: string[]; + excludedRepos: RepoRef[]; + onSave: (excludedOrgs: string[], excludedRepos: RepoRef[]) => void; +} + +export default function DependencyExclusionModal(props: DependencyExclusionModalProps) { + const [excludedOrgs, setExcludedOrgs] = createSignal>(new Set(props.excludedOrgs)); + const [excludedRepos, setExcludedRepos] = createSignal>( + new Set(props.excludedRepos.map((r) => r.fullName)) + ); + + // Reinitialize when the modal reopens — mirrors CustomTabModal's reinit effect + // (props.open false→true), so stale in-progress edits don't leak between opens. + createEffect(() => { + if (!props.open) return; + setExcludedOrgs(new Set(props.excludedOrgs)); + setExcludedRepos(new Set(props.excludedRepos.map((r) => r.fullName))); + }); + + function toggleOrg(org: string) { + setExcludedOrgs((prev) => { + const next = new Set(prev); + if (next.has(org)) { + next.delete(org); + // Also remove repos in this org from the repo-exclusion set — mirrors + // CustomTabModal's toggleOrg cleanup on DESELECT exactly: deselecting + // an org clears its repos; selecting an org never touches the repo set + // (OrgRepoCheckboxTree's checked-attribute OR handles display without + // needing individual repo entries). + setExcludedRepos((prevRepos) => { + const next2 = new Set(prevRepos); + for (const r of props.availableRepos) { + if (r.owner === org) next2.delete(r.fullName); + } + return next2; + }); + } else { + next.add(org); + } + return next; + }); + } + + function toggleRepo(repoFullName: string) { + setExcludedRepos((prev) => { + const next = new Set(prev); + if (next.has(repoFullName)) { + next.delete(repoFullName); + } else { + next.add(repoFullName); + } + return next; + }); + } + + function buildExcludedRepos(): RepoRef[] { + return props.availableRepos.filter((r) => excludedRepos().has(r.fullName)); + } + + function handleSave() { + props.onSave([...excludedOrgs()], buildExcludedRepos()); + props.onClose(); + } + + return ( + !open && props.onClose()} modal> + + + + + Manage repos and orgs excluded from the Dependencies tab + + + {/* Header */} +
+ + Exclude from Dependencies + + +
+ + {/* Scrollable body */} +
+

+ Repos and orgs checked below are hidden from the Dependencies tab only — they'll still appear in Issues, Pull Requests, and Actions. +

+
+ +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/components/shared/CustomTabModal.tsx b/src/app/components/shared/CustomTabModal.tsx index b3e0e7c3..4a2488aa 100644 --- a/src/app/components/shared/CustomTabModal.tsx +++ b/src/app/components/shared/CustomTabModal.tsx @@ -5,6 +5,7 @@ import type { CustomTab } from "../../stores/config"; import { resetCustomTabFilters } from "../../stores/view"; import type { RepoRef } from "../../services/api"; import { formatScopeSummary } from "../../lib/format"; +import OrgRepoCheckboxTree from "./OrgRepoCheckboxTree"; import { scopeFilterGroup, issueFilterGroups, @@ -275,53 +276,17 @@ export default function CustomTabModal(props: CustomTabModalProps) {

Leave empty to match no repos — the tab will show a warning icon until scoped. Org selection includes all repos in that org.

- 0} - fallback={

No orgs available.

} - > -
- - {(org) => { - const orgRepos = createMemo(() => - props.availableRepos.filter((r) => r.owner === org) - ); - return ( -
- {/* Org header checkbox */} - - {/* Repo checkboxes under org */} - 0}> -
- - {(repo) => ( - - )} - -
-
-
- ); - }} -
-
-
+
+ +
diff --git a/src/app/components/shared/OrgRepoCheckboxTree.tsx b/src/app/components/shared/OrgRepoCheckboxTree.tsx new file mode 100644 index 00000000..c3de092c --- /dev/null +++ b/src/app/components/shared/OrgRepoCheckboxTree.tsx @@ -0,0 +1,58 @@ +import { createMemo, For, Show } from "solid-js"; +import type { RepoRef } from "../../services/api"; + +export interface OrgRepoCheckboxTreeProps { + availableOrgs: string[]; + availableRepos: RepoRef[]; + checkedOrgs: Set; + checkedRepos: Set; + onToggleOrg: (org: string) => void; + onToggleRepo: (repoFullName: string) => void; + emptyMessage?: string; +} + +export default function OrgRepoCheckboxTree(props: OrgRepoCheckboxTreeProps) { + return ( + 0} + fallback={

{props.emptyMessage ?? "No orgs available."}

} + > + + {(org) => { + const orgRepos = createMemo(() => props.availableRepos.filter((r) => r.owner === org)); + return ( +
+ + 0}> +
+ + {(repo) => ( + + )} + +
+
+
+ ); + }} +
+
+ ); +} diff --git a/tests/components/settings/DependencyExclusionModal.test.tsx b/tests/components/settings/DependencyExclusionModal.test.tsx new file mode 100644 index 00000000..54cdcc25 --- /dev/null +++ b/tests/components/settings/DependencyExclusionModal.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import DependencyExclusionModal from "../../../src/app/components/settings/DependencyExclusionModal"; +import type { RepoRef } from "../../../src/app/services/api"; + +const availableOrgs = ["orgA", "orgB"]; +const availableRepos: RepoRef[] = [ + { owner: "orgA", name: "repoA1", fullName: "orgA/repoA1" }, + { owner: "orgA", name: "repoA2", fullName: "orgA/repoA2" }, + { owner: "orgB", name: "repoB1", fullName: "orgB/repoB1" }, +]; + +function findCheckboxByLabelText(text: string): HTMLInputElement { + const checkbox = screen + .getAllByRole("checkbox") + .find((cb) => cb.closest("label")?.textContent?.includes(text)); + if (!checkbox) throw new Error(`No checkbox found for label text "${text}"`); + return checkbox as HTMLInputElement; +} + +function renderModal(overrides: Partial[0]> = {}) { + const onClose = vi.fn(); + const onSave = vi.fn(); + render(() => ( + + )); + return { onClose, onSave }; +} + +describe("DependencyExclusionModal — open/close", () => { + it("renders dialog when open is true", () => { + renderModal(); + expect(screen.getByRole("dialog")).toBeDefined(); + }); + + it("does not render dialog content when open is false", () => { + renderModal({ open: false }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("calls onClose when Cancel button is clicked, without calling onSave", () => { + const { onClose, onSave } = renderModal(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("calls onClose when the X button is clicked", () => { + const { onClose } = renderModal(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("DependencyExclusionModal — pre-checked state", () => { + it("pre-checks org/repo checkboxes matching the excludedOrgs/excludedRepos props", () => { + renderModal({ + excludedOrgs: ["orgB"], + excludedRepos: [{ owner: "orgA", name: "repoA2", fullName: "orgA/repoA2" }], + }); + + expect(findCheckboxByLabelText("orgA").checked).toBe(false); + expect(findCheckboxByLabelText("orgB").checked).toBe(true); + expect(findCheckboxByLabelText("repoA1").checked).toBe(false); + expect(findCheckboxByLabelText("repoA2").checked).toBe(true); + // repoB1 is covered by the orgB exclusion (checked+disabled via the OR-logic) + expect(findCheckboxByLabelText("repoB1").checked).toBe(true); + expect(findCheckboxByLabelText("repoB1").disabled).toBe(true); + }); +}); + +describe("DependencyExclusionModal — toggling", () => { + it("checking an org excludes all of that org's nested repos", () => { + renderModal(); + fireEvent.click(findCheckboxByLabelText("orgA")); + + expect(findCheckboxByLabelText("repoA1").checked).toBe(true); + expect(findCheckboxByLabelText("repoA1").disabled).toBe(true); + expect(findCheckboxByLabelText("repoA2").checked).toBe(true); + expect(findCheckboxByLabelText("repoA2").disabled).toBe(true); + expect(findCheckboxByLabelText("repoB1").checked).toBe(false); + }); + + it("deselecting an org un-checks and re-enables all of that org's nested repos", () => { + renderModal(); + const orgA = findCheckboxByLabelText("orgA"); + fireEvent.click(orgA); // select + fireEvent.click(orgA); // deselect + + expect(findCheckboxByLabelText("repoA1").checked).toBe(false); + expect(findCheckboxByLabelText("repoA1").disabled).toBe(false); + expect(findCheckboxByLabelText("repoA2").checked).toBe(false); + expect(findCheckboxByLabelText("repoA2").disabled).toBe(false); + }); + + it("clicking a repo checkbox directly (org not excluded) toggles only that repo", () => { + renderModal(); + fireEvent.click(findCheckboxByLabelText("repoA1")); + + expect(findCheckboxByLabelText("repoA1").checked).toBe(true); + expect(findCheckboxByLabelText("repoA2").checked).toBe(false); + expect(findCheckboxByLabelText("orgA").checked).toBe(false); + }); +}); + +describe("DependencyExclusionModal — save", () => { + it("calls onSave with the current excluded orgs and repos, then calls onClose", () => { + const { onClose, onSave } = renderModal(); + + fireEvent.click(findCheckboxByLabelText("orgB")); + fireEvent.click(findCheckboxByLabelText("repoA1")); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const [orgs, repos] = onSave.mock.calls[0] as [string[], RepoRef[]]; + expect(orgs).toEqual(["orgB"]); + expect(repos).toEqual([{ owner: "orgA", name: "repoA1", fullName: "orgA/repoA1" }]); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("filters excluded repos against availableRepos on save (unfiltered orgs)", () => { + const { onSave } = renderModal(); + + fireEvent.click(findCheckboxByLabelText("repoA1")); + fireEvent.click(findCheckboxByLabelText("repoB1")); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + const [, repos] = onSave.mock.calls[0] as [string[], RepoRef[]]; + expect(repos).toEqual([ + { owner: "orgA", name: "repoA1", fullName: "orgA/repoA1" }, + { owner: "orgB", name: "repoB1", fullName: "orgB/repoB1" }, + ]); + }); +}); + +describe("DependencyExclusionModal — reopening with different props", () => { + it("resets local checkbox state to match new props when reopened", () => { + type Props = Parameters[0]; + const [modalProps, setModalProps] = createSignal({ + open: true, + onClose: vi.fn(), + availableOrgs, + availableRepos, + excludedOrgs: ["orgA"], + excludedRepos: [], + onSave: vi.fn(), + }); + + render(() => ); + + expect(findCheckboxByLabelText("orgA").checked).toBe(true); + expect(findCheckboxByLabelText("orgB").checked).toBe(false); + + // Close, then reopen with different exclusions + setModalProps((prev) => ({ ...prev, open: false })); + setModalProps((prev) => ({ + ...prev, + open: true, + excludedOrgs: ["orgB"], + excludedRepos: [{ owner: "orgA", name: "repoA1", fullName: "orgA/repoA1" }], + })); + + expect(findCheckboxByLabelText("orgA").checked).toBe(false); + expect(findCheckboxByLabelText("orgB").checked).toBe(true); + expect(findCheckboxByLabelText("repoA1").checked).toBe(true); + }); +}); diff --git a/tests/components/shared/OrgRepoCheckboxTree.test.tsx b/tests/components/shared/OrgRepoCheckboxTree.test.tsx new file mode 100644 index 00000000..fb771a71 --- /dev/null +++ b/tests/components/shared/OrgRepoCheckboxTree.test.tsx @@ -0,0 +1,191 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import OrgRepoCheckboxTree from "../../../src/app/components/shared/OrgRepoCheckboxTree"; + +const availableOrgs = ["orgA", "orgB"]; +const availableRepos = [ + { owner: "orgA", name: "repoA1", fullName: "orgA/repoA1" }, + { owner: "orgA", name: "repoA2", fullName: "orgA/repoA2" }, + { owner: "orgB", name: "repoB1", fullName: "orgB/repoB1" }, +]; + +function findCheckboxByLabelText(text: string): HTMLInputElement { + const checkbox = screen + .getAllByRole("checkbox") + .find((cb) => cb.closest("label")?.textContent?.includes(text)); + if (!checkbox) throw new Error(`No checkbox found for label text "${text}"`); + return checkbox as HTMLInputElement; +} + +describe("OrgRepoCheckboxTree — rendering", () => { + it("renders one checkbox per org and one nested checkbox per repo", () => { + render(() => ( + + )); + + expect(screen.getAllByRole("checkbox")).toHaveLength(5); // 2 orgs + 3 repos + expect(screen.getByText("orgA")).toBeDefined(); + expect(screen.getByText("orgB")).toBeDefined(); + expect(screen.getByText("repoA1")).toBeDefined(); + expect(screen.getByText("repoA2")).toBeDefined(); + expect(screen.getByText("repoB1")).toBeDefined(); + }); + + it("pre-checks org checkboxes matching checkedOrgs prop", () => { + render(() => ( + + )); + + expect(findCheckboxByLabelText("orgA").checked).toBe(false); + expect(findCheckboxByLabelText("orgB").checked).toBe(true); + }); + + it("pre-checks repo checkboxes matching checkedRepos prop", () => { + render(() => ( + + )); + + expect(findCheckboxByLabelText("repoA1").checked).toBe(false); + expect(findCheckboxByLabelText("repoA2").checked).toBe(true); + expect(findCheckboxByLabelText("repoB1").checked).toBe(false); + }); + + it("checking an org checks and disables all of that org's nested repo checkboxes", () => { + render(() => ( + + )); + + const repoA1 = findCheckboxByLabelText("repoA1"); + const repoA2 = findCheckboxByLabelText("repoA2"); + const repoB1 = findCheckboxByLabelText("repoB1"); + + expect(repoA1.checked).toBe(true); + expect(repoA1.disabled).toBe(true); + expect(repoA2.checked).toBe(true); + expect(repoA2.disabled).toBe(true); + expect(repoB1.checked).toBe(false); + expect(repoB1.disabled).toBe(false); + }); + + it("renders emptyMessage when availableOrgs is empty", () => { + render(() => ( + + )); + + expect(screen.getByText("No repos tracked yet.")).toBeDefined(); + }); + + it("renders default 'No orgs available.' message when emptyMessage is omitted", () => { + render(() => ( + + )); + + expect(screen.getByText("No orgs available.")).toBeDefined(); + }); +}); + +describe("OrgRepoCheckboxTree — callbacks (pure function of props)", () => { + it("calls onToggleOrg with the org's name when its checkbox is clicked", () => { + const onToggleOrg = vi.fn(); + render(() => ( + + )); + + fireEvent.click(findCheckboxByLabelText("orgA")); + expect(onToggleOrg).toHaveBeenCalledTimes(1); + expect(onToggleOrg).toHaveBeenCalledWith("orgA"); + }); + + it("calls onToggleRepo with the repo's fullName when its checkbox is clicked", () => { + const onToggleRepo = vi.fn(); + render(() => ( + + )); + + fireEvent.click(findCheckboxByLabelText("repoB1")); + expect(onToggleRepo).toHaveBeenCalledTimes(1); + expect(onToggleRepo).toHaveBeenCalledWith("orgB/repoB1"); + }); + + it("reports every click identically regardless of click history — the component owns no toggle state", () => { + // If the component tracked its own checked/toggle state internally, repeated + // clicks might mutate that state and change what gets reported. Since it's a + // pure function of props + callbacks, every click on the same org must invoke + // onToggleOrg identically. + const onToggleOrg = vi.fn(); + render(() => ( + + )); + + const orgA = findCheckboxByLabelText("orgA"); + fireEvent.click(orgA); + fireEvent.click(orgA); + expect(onToggleOrg).toHaveBeenCalledTimes(2); + expect(onToggleOrg).toHaveBeenNthCalledWith(1, "orgA"); + expect(onToggleOrg).toHaveBeenNthCalledWith(2, "orgA"); + }); +}); From d1c99856b476ca36129730d0eed9f762503ec902 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 13 Aug 2026 16:52:07 -0400 Subject: [PATCH 05/10] feat(dependencies): adds exclusion management UI to Settings --- src/app/components/settings/SettingsPage.tsx | 46 +++++++++++- .../components/settings/SettingsPage.test.tsx | 70 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 0cad0b09..6070f37e 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -14,7 +14,7 @@ import { pushNotification } from "../../lib/errors"; import { buildOrgAccessUrl, buildJiraAuthorizeUrl } from "../../lib/oauth"; import { sealApiToken } from "../../lib/proxy"; import { isSafeGitHubUrl, openGitHubUrl } from "../../lib/url"; -import { relativeTime } from "../../lib/format"; +import { relativeTime, formatScopeSummary } from "../../lib/format"; import { fetchOrgs } from "../../services/api"; import { getClient } from "../../services/github"; import { getUsageSnapshot, getUsageResetAt, resetUsageData, checkAndResetIfExpired, SOURCE_LABELS } from "../../services/api-usage"; @@ -27,6 +27,7 @@ import ThemePicker from "./ThemePicker"; import DensityPicker from "./DensityPicker"; import TrackedUsersSection from "./TrackedUsersSection"; import CustomTabsSection from "./CustomTabsSection"; +import DependencyExclusionModal from "./DependencyExclusionModal"; import { InfoTooltip } from "../shared/Tooltip"; import { createJiraClient } from "../../lib/jira-utils"; import JiraFieldPicker from "./JiraFieldPicker"; @@ -142,6 +143,17 @@ export default function SettingsPage() { config.monitoredRepos.map(r => r.fullName).join(", ") ); + const dependencyExclusionPool = createMemo(() => { + const seen = new Map(); + for (const r of [...config.selectedRepos, ...config.upstreamRepos, ...config.monitoredRepos]) { + seen.set(r.fullName.toLowerCase(), r); + } + return [...seen.values()]; + }); + const dependencyExclusionOrgs = createMemo(() => + [...new Set(dependencyExclusionPool().map((r) => r.owner))] + ); + // ── Helpers ────────────────────────────────────────────────────────────── async function mergeNewOrgs() { @@ -352,6 +364,7 @@ export default function SettingsPage() { const [jiraApiMode, setJiraApiMode] = createSignal(false); const [showFieldPicker, setShowFieldPicker] = createSignal(false); const [showScopePicker, setShowScopePicker] = createSignal(false); + const [showDependencyExclusionModal, setShowDependencyExclusionModal] = createSignal(false); const jiraClient = createMemo(() => createJiraClient(config.jira?.authMethod)); @@ -1356,6 +1369,25 @@ export default function SettingsPage() { onInput={(e) => saveWithFeedback({ dependencies: { ...config.dependencies, rebaseLabel: e.currentTarget.value || "rebase" } })} /> + +
+ + {(config.dependencies?.excludedOrgs ?? []).length === 0 && (config.dependencies?.excludedRepos ?? []).length === 0 + ? "None excluded" + : formatScopeSummary((config.dependencies?.excludedOrgs ?? []).length, (config.dependencies?.excludedRepos ?? []).length, true)} + + +
+
{/* ── Account ─────────────────────────────────────────────────── */} @@ -1539,6 +1571,18 @@ export default function SettingsPage() { + setShowDependencyExclusionModal(false)} + availableOrgs={dependencyExclusionOrgs()} + availableRepos={dependencyExclusionPool()} + excludedOrgs={config.dependencies?.excludedOrgs ?? []} + excludedRepos={config.dependencies?.excludedRepos ?? []} + onSave={(orgs, repos) => + saveWithFeedback({ dependencies: { ...config.dependencies, excludedOrgs: orgs, excludedRepos: repos } }) + } + /> +