Skip to content
Merged
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
12 changes: 8 additions & 4 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import IssuesTab from "./IssuesTab";
import PullRequestsTab from "./PullRequestsTab";
import TrackedTab from "./TrackedTab";
import PersonalSummaryStrip from "./PersonalSummaryStrip";
import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, updateJiraConfig, type TrackedUser } from "../../stores/config";
import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, isTabUnscoped, updateJiraConfig, type TrackedUser } from "../../stores/config";
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";
Expand Down Expand Up @@ -54,14 +54,18 @@ const ISSUE_FILTER_DEFAULTS = IssueFiltersSchema.parse({});
const PR_FILTER_DEFAULTS = PullRequestFiltersSchema.parse({});
const ACTIONS_FILTER_DEFAULTS = ActionsFiltersSchema.parse({});

/** Build a scope matcher for a custom tab's org/repo scope. Shared between customTabData and tabCounts. */
/**
* Build a scope matcher for a custom tab's org/repo scope. Shared between
* customTabData and tabCounts. An empty scope (no orgs, no repos) matches
* nothing — see isTabUnscoped, which flags this state for the UI.
*/
function buildTabScopeMatcher(tab: CustomTab): (repoFullName: string) => boolean {
const orgSet = tab.orgScope.length > 0 ? new Set(tab.orgScope.map((o) => o.toLowerCase())) : null;
const repoSet = tab.repoScope.length > 0 ? new Set(tab.repoScope.map((r) => r.fullName.toLowerCase())) : null;
return (repoFullName: string) => {
if (repoSet && repoSet.has(repoFullName.toLowerCase())) return true;
if (orgSet && orgSet.has(repoFullName.split("/")[0].toLowerCase())) return true;
return !orgSet && !repoSet;
return false;
};
}

Expand Down Expand Up @@ -1270,7 +1274,7 @@ export default function DashboardPage() {
enableActions={config.enableActions}
enableJira={!!config.jira?.enabled}
enableDependencies={enableDependencies()}
customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name }))}
customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name, isUnscoped: isTabUnscoped(t) }))}
onAddTab={() => setShowCustomTabModal(true)}
onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }}
/>
Expand Down
9 changes: 8 additions & 1 deletion src/app/components/layout/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface TabBarProps {
enableActions?: boolean;
enableJira?: boolean;
enableDependencies?: boolean;
customTabs?: Array<{ id: string; name: string }>;
customTabs?: Array<{ id: string; name: string; isUnscoped?: boolean }>;
onAddTab?: () => void;
onEditTab?: (id: string) => void;
}
Expand Down Expand Up @@ -77,6 +77,13 @@ export default function TabBar(props: TabBarProps) {
{(tab) => (
<div class="relative group/tab flex items-center">
<Tabs.Trigger value={tab.id} class="tab compact:tab-sm data-[selected]:tab-active">
<Show when={tab.isUnscoped}>
<Tooltip content="Unscoped — this tab won't match any repos until you add scope">
<svg class="h-3.5 w-3.5 text-warning mr-1" fill="currentColor" viewBox="0 0 20 20" aria-label="Unscoped tab" role="img">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l6.28 11.163c.75 1.333-.213 2.987-1.742 2.987H3.72c-1.53 0-2.492-1.654-1.743-2.987L8.257 3.1zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</Tooltip>
</Show>
{tab.name}
<Show when={props.counts?.[tab.id] !== undefined}>
<span class="badge badge-sm badge-neutral ml-1">{props.counts?.[tab.id]}</span>
Expand Down
23 changes: 22 additions & 1 deletion src/app/components/onboarding/OrgSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSignal, createResource, For, Show } from "solid-js";
import { createSignal, createResource, createEffect, untrack, For, Show } from "solid-js";
import { fetchOrgs, OrgEntry } from "../../services/api";
import { getClient } from "../../services/github";
import { pushNotification } from "../../lib/errors";
import LoadingSpinner from "../shared/LoadingSpinner";
import FilterInput from "../shared/FilterInput";

Expand All @@ -25,6 +26,26 @@ export default function OrgSelector(props: OrgSelectorProps) {
return all.filter((o) => o.login.toLowerCase().includes(q));
};

// Prune selectedOrgs entries no longer present in a fresh, fully-paginated
// fetchOrgs() result — e.g. revoked/removed org access. Without this, a
// stale login would inflate the "N selected" counters below past the live
// total (selected.length could exceed orgs().length).
createEffect(() => {
const list = orgs();
if (orgs.loading || orgs.error || !list) return;
const liveLogins = new Set(list.map((o) => o.login.toLowerCase()));
const current = untrack(() => props.selected);
const stale = current.filter((login) => !liveLogins.has(login.toLowerCase()));
if (stale.length === 0) return;
const pruned = current.filter((login) => liveLogins.has(login.toLowerCase()));
untrack(() => props.onChange(pruned));
pushNotification(
"org-prune",
`Removed ${stale.length} organization${stale.length !== 1 ? "s" : ""} you no longer have access to (${stale.join(", ")})`,
"warning"
);
});

const isSelected = (login: string) => props.selected.includes(login);

function toggleOrg(login: string) {
Expand Down
15 changes: 13 additions & 2 deletions src/app/components/settings/CustomTabsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createSignal, createMemo, For, Show } from "solid-js";
import { config, removeCustomTab, reorderCustomTab } from "../../stores/config";
import { config, removeCustomTab, reorderCustomTab, isTabUnscoped } from "../../stores/config";
import type { CustomTab } from "../../stores/config";
import type { RepoRef } from "../../services/api";
import CustomTabModal from "../shared/CustomTabModal";
Expand Down Expand Up @@ -82,7 +82,18 @@ export default function CustomTabsSection(props: CustomTabsSectionProps) {
{baseTypeLabel(tab.baseType)}
</span>
</td>
<td class="text-xs text-base-content/70">{formatScopeSummary(tab.orgScope.length, tab.repoScope.length, true)}</td>
<td class="text-xs text-base-content/70">
<span class="flex items-center gap-1">
<Show when={isTabUnscoped(tab)}>
<Tooltip content="Unscoped — this tab won't match any repos until you add scope">
<svg class="h-3.5 w-3.5 text-warning shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-label="Unscoped tab" role="img">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l6.28 11.163c.75 1.333-.213 2.987-1.742 2.987H3.72c-1.53 0-2.492-1.654-1.743-2.987L8.257 3.1zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</Tooltip>
</Show>
{formatScopeSummary(tab.orgScope.length, tab.repoScope.length, true)}
</span>
</td>
<td class="text-center">
{tab.exclusive ? (
<svg class="h-4 w-4 text-success inline" fill="currentColor" viewBox="0 0 20 20" aria-label="Exclusive" role="img">
Expand Down
11 changes: 9 additions & 2 deletions src/app/components/shared/CustomTabModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export default function CustomTabModal(props: CustomTabModalProps) {

const nameValid = createMemo(() => name().trim().length > 0 && name().trim().length <= 30);

const scopeIsEmpty = createMemo(() => selectedOrgs().size === 0 && selectedRepos().size === 0);

// User field group — dynamic, includes tracked user logins
const userFieldGroup = createMemo((): FilterChipGroupDef => ({
label: "User",
Expand Down Expand Up @@ -258,15 +260,20 @@ export default function CustomTabModal(props: CustomTabModalProps) {
onClick={() => setScopeOpen((v) => !v)}
>
<span>Scope</span>
<span class="text-base-content/50 text-xs">
<span class="text-base-content/50 text-xs flex items-center gap-1">
<Show when={scopeIsEmpty()}>
<svg class="h-3.5 w-3.5 text-warning" fill="currentColor" viewBox="0 0 20 20" aria-label="Unscoped — matches no repos" role="img">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l6.28 11.163c.75 1.333-.213 2.987-1.742 2.987H3.72c-1.53 0-2.492-1.654-1.743-2.987L8.257 3.1zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</Show>
{formatScopeSummary(selectedOrgs().size, selectedRepos().size)}
<span class="ml-2">{scopeOpen() ? "▲" : "▼"}</span>
</span>
</button>
<Show when={scopeOpen()}>
<div id="custom-tab-scope-panel" class="p-3 space-y-3">
<p class="text-xs text-base-content/50">
Leave empty to include all repos. Org selection includes all repos in that org.
Leave empty to match no repos — the tab will show a warning icon until scoped. Org selection includes all repos in that org.
</p>
<Show
when={props.availableOrgs.length > 0}
Expand Down
2 changes: 1 addition & 1 deletion src/app/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function rateLimitCssClass(remaining: number, limit: number): string {

/** Format scope counts as "N org(s), M repo(s)". When elideZero is true, omit zero-count segments. */
export function formatScopeSummary(orgCount: number, repoCount: number, elideZero = false): string {
if (orgCount === 0 && repoCount === 0) return "All repos";
if (orgCount === 0 && repoCount === 0) return "No repos selected";
if (elideZero) {
const parts: string[] = [];
if (orgCount > 0) parts.push(`${orgCount} org${orgCount !== 1 ? "s" : ""}`);
Expand Down
26 changes: 15 additions & 11 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1553,32 +1553,36 @@ function mapReviewDecision(

/**
* Returns orgs and the personal user account. Personal account is first.
* Fully paginates GET /user/orgs — a single-page fetch would silently
* truncate at 100 orgs for heavily-affiliated accounts.
*/
export async function fetchOrgs(
octokit: ReturnType<typeof getClient>
): Promise<OrgEntry[]> {
if (!octokit) throw new Error("No GitHub client available");

const [userResult, orgsResult] = await Promise.all([
cachedRequest(octokit, "orgs:user", "GET /user"),
cachedRequest(octokit, "orgs:all", "GET /user/orgs", { per_page: 100 }),
]);
const userPromise = cachedRequest(octokit, "orgs:user", "GET /user");

const orgEntries: OrgEntry[] = [];
const ORG_CAP = 1000;
for await (const response of octokit.paginate.iterator("GET /user/orgs", {
per_page: 100,
})) {
for (const org of response.data as RawOrg[]) {
orgEntries.push({ login: org.login, avatarUrl: org.avatar_url, type: "org" });
}
if (orgEntries.length >= ORG_CAP) break;
}

const userResult = await userPromise;
const user = userResult.data as RawUser;
const orgs = orgsResult.data as RawOrg[];

const personal: OrgEntry = {
login: user.login,
avatarUrl: user.avatar_url,
type: "user",
};

const orgEntries: OrgEntry[] = orgs.map((o) => ({
login: o.login,
avatarUrl: o.avatar_url,
type: "org",
}));

return [personal, ...orgEntries];
}

Expand Down
15 changes: 14 additions & 1 deletion src/app/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { z } from "zod";
// ── Re-exports from shared/schemas (backward compat for existing importers) ───
export {
ConfigSchema, RepoRefSchema, TrackedUserSchema, THEME_OPTIONS,
CustomTabSchema, BUILTIN_TAB_IDS, isBuiltinTab, isActionsBasedTab,
CustomTabSchema, BUILTIN_TAB_IDS, isBuiltinTab, isActionsBasedTab, isTabUnscoped,
type Config, type TrackedUser, type ThemeId, type CustomTab, type BuiltinTabId,
type JiraConfig,
} from "../../shared/schemas";
Expand Down Expand Up @@ -82,6 +82,19 @@ export function updateConfig(partial: Partial<Config>): void {
if ("selectedRepos" in partial) {
const selectedSet = new Set(draft.selectedRepos.map((r) => r.fullName));
draft.monitoredRepos = draft.monitoredRepos.filter((r) => selectedSet.has(r.fullName));

// customTabs scope is derived from selectedRepos (see CustomTabModal's
// availableOrgs/availableRepos props) — prune scope entries that no
// longer correspond to a tracked repo, same as monitoredRepos above.
const ownerSet = new Set(draft.selectedRepos.map((r) => r.owner.toLowerCase()));
draft.customTabs = draft.customTabs.map((tab) => {
const filteredOrgScope = tab.orgScope.filter((o) => ownerSet.has(o.toLowerCase()));
const filteredRepoScope = tab.repoScope.filter((r) => selectedSet.has(r.fullName));
if (filteredOrgScope.length === tab.orgScope.length && filteredRepoScope.length === tab.repoScope.length) {
return tab;
}
return { ...tab, orgScope: filteredOrgScope, repoScope: filteredRepoScope };
});
}
})
);
Expand Down
5 changes: 5 additions & 0 deletions src/shared/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export function isActionsBasedTab(id: string, customTabs: readonly CustomTab[]):
return id === "actions" || (!isBuiltinTab(id) && customTabs.some((t) => t.id === id && t.baseType === "actions"));
}

/** A tab with no orgScope and no repoScope matches no repos — see buildTabScopeMatcher in DashboardPage.tsx. */
export function isTabUnscoped(tab: Pick<CustomTab, "orgScope" | "repoScope">): boolean {
return tab.orgScope.length === 0 && tab.repoScope.length === 0;
}

export const JiraAuthMethodSchema = z.enum(["oauth", "token"]).default("oauth");

export const JiraCustomFieldSchema = z.object({
Expand Down
47 changes: 39 additions & 8 deletions tests/components/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1050,12 +1050,12 @@ describe("DashboardPage — tracked tab", () => {

describe("DashboardPage — exclusive custom tabs", () => {
it("exclusive issues tab removes claimed items from the builtin Issues badge", async () => {
// Add an exclusive issues custom tab that claims all repos
// Add an exclusive issues custom tab scoped to the fixture's default owner
configStore.addCustomTab({
id: "excl01",
name: "My Issues",
baseType: "issues",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: {},
exclusive: true,
Expand Down Expand Up @@ -1083,7 +1083,7 @@ describe("DashboardPage — exclusive custom tabs", () => {
id: "excl02",
name: "Exclusive PRs",
baseType: "pullRequests",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: {},
exclusive: true,
Expand Down Expand Up @@ -1144,7 +1144,7 @@ describe("DashboardPage — exclusive custom tabs", () => {
id: "first01",
name: "First Exclusive",
baseType: "issues",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: {},
exclusive: true,
Expand All @@ -1153,7 +1153,7 @@ describe("DashboardPage — exclusive custom tabs", () => {
id: "second01",
name: "Second Exclusive",
baseType: "issues",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: {},
exclusive: true,
Expand Down Expand Up @@ -1183,7 +1183,7 @@ describe("DashboardPage — exclusive custom tabs", () => {
id: "exclact01",
name: "My Actions",
baseType: "actions",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: {},
exclusive: true,
Expand Down Expand Up @@ -1380,6 +1380,37 @@ describe("DashboardPage — custom tab scoping", () => {
expect(customTab.textContent?.replace(/\D+/g, "")).toBe("1");
});
});

it("a tab with empty orgScope and repoScope matches no items and shows the unscoped icon", async () => {
configStore.addCustomTab({
id: "unscoped01",
name: "Unscoped Tab",
baseType: "issues",
orgScope: [],
repoScope: [],
filterPreset: { scope: "all" },
exclusive: false,
});
vi.mocked(pollService.fetchAllData).mockResolvedValue({
issues: [
makeIssue({ id: 60, title: "Some issue", repoFullName: "owner/repo" }),
],
pullRequests: [],
workflowRuns: [],
errors: [],
});

render(() => <DashboardPage />);
await waitFor(() => {
// Empty scope now matches nothing — not "all repos" as it did previously.
const customTab = screen.getByRole("tab", { name: /Unscoped Tab/ });
expect(customTab.textContent?.replace(/\D+/g, "")).toBe("0");
// The builtin Issues tab is unaffected — it still shows the item.
const issuesTab = screen.getByRole("tab", { name: /^Issues/ });
expect(issuesTab.textContent?.replace(/\D+/g, "")).toBe("1");
expect(screen.getByLabelText("Unscoped tab")).toBeDefined();
});
});
});

// ── resolveInitialTab stale custom tab fallback ──────────────────────────────
Expand Down Expand Up @@ -1535,7 +1566,7 @@ describe("DashboardPage — tabCounts applies filterPreset", () => {
id: "selfuser",
name: "My Items",
baseType: "issues",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: { scope: "all", user: "_self" },
exclusive: false,
Expand Down Expand Up @@ -1567,7 +1598,7 @@ describe("DashboardPage — tabCounts applies filterPreset", () => {
id: "failures",
name: "Failed Runs",
baseType: "actions",
orgScope: [],
orgScope: ["owner"],
repoScope: [],
filterPreset: { conclusion: "failure" },
exclusive: false,
Expand Down
24 changes: 24 additions & 0 deletions tests/components/layout/TabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ describe("TabBar", () => {
screen.getByText("7");
});

it("shows a warning icon on an unscoped custom tab", () => {
const onTabChange = vi.fn();
render(() => (
<TabBar
activeTab="issues"
onTabChange={onTabChange}
customTabs={[{ id: "tab-alpha", name: "Alpha", isUnscoped: true }]}
/>
));
screen.getByLabelText("Unscoped tab");
});

it("does not show a warning icon on a scoped custom tab", () => {
const onTabChange = vi.fn();
render(() => (
<TabBar
activeTab="issues"
onTabChange={onTabChange}
customTabs={[{ id: "tab-alpha", name: "Alpha", isUnscoped: false }]}
/>
));
expect(screen.queryByLabelText("Unscoped tab")).toBeNull();
});

it("does not render a count badge when count is undefined for a custom tab", () => {
const onTabChange = vi.fn();
render(() => (
Expand Down
Loading