diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 5c68039e..43f2fc46 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -14,6 +14,7 @@ import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; interface ActionsTabProps { workflowRuns: WorkflowRun[]; loading?: boolean; + hasUpstreamRepos?: boolean; } interface WorkflowGroup { @@ -319,6 +320,13 @@ export default function ActionsTab(props: ActionsTabProps) { }} + + {/* Upstream repos exclusion note */} + +

+ Workflow runs are not tracked for upstream repositories. +

+
); } diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 2f01a730..935a7d70 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -6,7 +6,7 @@ import FilterBar from "../layout/FilterBar"; import ActionsTab from "./ActionsTab"; import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; -import { config, setConfig } from "../../stores/config"; +import { config, setConfig, type TrackedUser } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -158,6 +158,7 @@ async function pollFetch(): Promise { pr.totalReviewCount = e.totalReviewCount; pr.enriched = e.enriched; pr.nodeId = e.nodeId; + pr.surfacedBy = e.surfacedBy; } } else { state.pullRequests = data.pullRequests; @@ -307,6 +308,14 @@ export default function DashboardPage() { })); const userLogin = createMemo(() => user()?.login ?? ""); + const allUsers = createMemo(() => { + const login = userLogin().toLowerCase(); + if (!login) return []; + return [ + { login, label: "Me" }, + ...config.trackedUsers.map((u: TrackedUser) => ({ login: u.login, label: u.login })), + ]; + }); return (
@@ -335,6 +344,8 @@ export default function DashboardPage() { issues={dashboardData.issues} loading={dashboardData.loading} userLogin={userLogin()} + allUsers={allUsers()} + trackedUsers={config.trackedUsers} /> @@ -342,12 +353,15 @@ export default function DashboardPage() { pullRequests={dashboardData.pullRequests} loading={dashboardData.loading} userLogin={userLogin()} + allUsers={allUsers()} + trackedUsers={config.trackedUsers} /> 0} /> diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index d7fcde49..03b6edd9 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -1,8 +1,9 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; -import { config } from "../../stores/config"; +import { config, type TrackedUser } from "../../stores/config"; import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view"; import type { Issue } from "../../services/api"; import ItemRow from "./ItemRow"; +import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; @@ -20,6 +21,8 @@ export interface IssuesTabProps { issues: Issue[]; loading?: boolean; userLogin: string; + allUsers?: { login: string; label: string }[]; + trackedUsers?: TrackedUser[]; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments"; @@ -55,6 +58,27 @@ const sortOptions: SortOption[] = [ export default function IssuesTab(props: IssuesTabProps) { const [page, setPage] = createSignal(0); + const trackedUserMap = createMemo(() => + new Map(props.trackedUsers?.map(u => [u.login, u]) ?? []) + ); + + const upstreamRepoSet = createMemo(() => + new Set((config.upstreamRepos ?? []).map(r => r.fullName)) + ); + + const filterGroups = createMemo(() => { + const users = props.allUsers; + if (!users || users.length <= 1) return issueFilterGroups; + return [ + ...issueFilterGroups, + { + label: "User", + field: "user", + options: users.map((u) => ({ value: u.login, label: u.label })), + }, + ]; + }); + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["issues"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -76,7 +100,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (filter.repo && issue.repoFullName !== filter.repo) return false; if (filter.org && !issue.repoFullName.startsWith(filter.org + "/")) return false; - const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, []); + const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); if (tabFilter.role !== "all") { if (!roles.includes(tabFilter.role as "author" | "assignee")) return false; @@ -87,6 +111,14 @@ export default function IssuesTab(props: IssuesTabProps) { if (tabFilter.comments === "none" && issue.comments > 0) return false; } + if (tabFilter.user !== "all") { + const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user); + if (validUser) { + const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()]; + if (!surfacedBy.includes(tabFilter.user)) return false; + } + } + meta.set(issue.id, { roles }); return true; }); @@ -172,7 +204,7 @@ export default function IssuesTab(props: IssuesTabProps) { onChange={handleSort} /> { setTabFilter("issues", field as IssueFilterField, value); @@ -291,6 +323,14 @@ export default function IssuesTab(props: IssuesTabProps) { onIgnore={() => handleIgnore(issue)} density={config.viewDensity} commentCount={issue.comments} + surfacedByBadge={ + props.trackedUsers && props.trackedUsers.length > 0 + ? + : undefined + } > diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 9f9e9b94..a81fb7dc 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -1,6 +1,7 @@ import { For, JSX, Show } from "solid-js"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, labelTextColor, formatCount } from "../../lib/format"; +import { expandEmoji } from "../../lib/emoji"; export interface ItemRowProps { repo: string; @@ -15,6 +16,7 @@ export interface ItemRowProps { density: "compact" | "comfortable"; commentCount?: number; hideRepo?: boolean; + surfacedByBadge?: JSX.Element; } export default function ItemRow(props: ItemRowProps) { @@ -77,7 +79,7 @@ export default function ItemRow(props: ItemRowProps) { class="inline-flex items-center rounded-full text-xs px-2 py-0.5 font-medium bg-[var(--lb)] text-[var(--lf)]" style={{ "--lb": bg, "--lf": fg }} > - {label.name} + {expandEmoji(label.name)} ); }} @@ -94,6 +96,9 @@ export default function ItemRow(props: ItemRowProps) { {/* Author + time + comment count */}
{props.author} + +
{props.surfacedByBadge}
+
{relativeTime(props.createdAt)} 0}> diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 137a5a8f..83107cec 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -1,10 +1,11 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; -import { config } from "../../stores/config"; +import { config, type TrackedUser } from "../../stores/config"; import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import ItemRow from "./ItemRow"; +import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; import StatusDot from "../shared/StatusDot"; import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; @@ -23,6 +24,8 @@ export interface PullRequestsTabProps { pullRequests: PullRequest[]; loading?: boolean; userLogin: string; + allUsers?: { login: string; label: string }[]; + trackedUsers?: TrackedUser[]; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size"; @@ -120,6 +123,27 @@ const sortOptions: SortOption[] = [ export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); + const trackedUserMap = createMemo(() => + new Map(props.trackedUsers?.map(u => [u.login, u]) ?? []) + ); + + const upstreamRepoSet = createMemo(() => + new Set((config.upstreamRepos ?? []).map(r => r.fullName)) + ); + + const filterGroups = createMemo(() => { + const users = props.allUsers; + if (!users || users.length <= 1) return prFilterGroups; + return [ + ...prFilterGroups, + { + label: "User", + field: "user", + options: users.map((u) => ({ value: u.login, label: u.label })), + }, + ]; + }); + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -141,7 +165,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (filter.repo && pr.repoFullName !== filter.repo) return false; if (filter.org && !pr.repoFullName.startsWith(filter.org + "/")) return false; - const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins); + const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName)); const sizeCategory = prSizeCategory(pr.additions, pr.deletions); // Tab filters — light-field filters always apply; heavy-field filters @@ -170,6 +194,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (sizeCategory !== tabFilters.sizeCategory) return false; } + if (tabFilters.user !== "all") { + const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user); + if (validUser) { + const surfacedBy = pr.surfacedBy ?? [props.userLogin.toLowerCase()]; + if (!surfacedBy.includes(tabFilters.user)) return false; + } + } + meta.set(pr.id, { roles, sizeCategory }); return true; }); @@ -261,7 +293,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { onChange={handleSort} /> { setTabFilter("pullRequests", field as PullRequestFilterField, value); @@ -435,6 +467,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined} onIgnore={() => handleIgnore(pr)} density={config.viewDensity} + surfacedByBadge={ + props.trackedUsers && props.trackedUsers.length > 0 + ? + : undefined + } >
diff --git a/src/app/components/onboarding/OnboardingWizard.tsx b/src/app/components/onboarding/OnboardingWizard.tsx index faf158ab..68edc3df 100644 --- a/src/app/components/onboarding/OnboardingWizard.tsx +++ b/src/app/components/onboarding/OnboardingWizard.tsx @@ -16,6 +16,9 @@ export default function OnboardingWizard() { const [selectedRepos, setSelectedRepos] = createSignal( config.selectedRepos.length > 0 ? [...config.selectedRepos] : [] ); + const [upstreamRepos, setUpstreamRepos] = createSignal( + config.upstreamRepos.length > 0 ? [...config.upstreamRepos] : [] + ); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); @@ -53,6 +56,7 @@ export default function OnboardingWizard() { updateConfig({ selectedOrgs: uniqueOrgs, selectedRepos: selectedRepos(), + upstreamRepos: upstreamRepos(), onboardingComplete: true, }); // Flush synchronously — the debounced persistence effect won't fire before page unload @@ -108,6 +112,10 @@ export default function OnboardingWizard() { orgEntries={orgEntries()} selected={selectedRepos()} onChange={setSelectedRepos} + showUpstreamDiscovery={true} + upstreamRepos={upstreamRepos()} + onUpstreamChange={setUpstreamRepos} + trackedUsers={config.trackedUsers} /> @@ -120,12 +128,12 @@ export default function OnboardingWizard() {
diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index 409167cd..d5227c61 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -2,20 +2,30 @@ import { createSignal, createEffect, createMemo, + untrack, Show, Index, + For, } from "solid-js"; -import { fetchOrgs, fetchRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api"; +import { fetchOrgs, fetchRepos, discoverUpstreamRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api"; import { getClient } from "../../services/github"; +import { user } from "../../stores/auth"; import { relativeTime } from "../../lib/format"; import LoadingSpinner from "../shared/LoadingSpinner"; import FilterInput from "../shared/FilterInput"; +// Validates owner/repo format (both segments must be non-empty, no spaces) +const VALID_REPO_NAME = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; + interface RepoSelectorProps { selectedOrgs: string[]; orgEntries?: OrgEntry[]; // Pre-fetched org entries — skip internal fetchOrgs when provided selected: RepoRef[]; onChange: (selected: RepoRef[]) => void; + showUpstreamDiscovery?: boolean; + upstreamRepos?: RepoRef[]; + onUpstreamChange?: (repos: RepoRef[]) => void; + trackedUsers?: { login: string; avatarUrl: string; name: string | null }[]; } interface OrgRepoState { @@ -32,6 +42,14 @@ export default function RepoSelector(props: RepoSelectorProps) { const [loadedCount, setLoadedCount] = createSignal(0); let effectVersion = 0; + // ── Upstream discovery state ─────────────────────────────────────────────── + const [discoveredRepos, setDiscoveredRepos] = createSignal([]); + const [discoveringUpstream, setDiscoveringUpstream] = createSignal(false); + const [discoveryCapped, setDiscoveryCapped] = createSignal(false); + const [manualEntry, setManualEntry] = createSignal(""); + const [validatingManual, setValidatingManual] = createSignal(false); + const [manualEntryError, setManualEntryError] = createSignal(null); + // Initialize org states and fetch repos on mount / when selectedOrgs change createEffect(() => { const orgs = props.selectedOrgs; @@ -43,13 +61,6 @@ export default function RepoSelector(props: RepoSelectorProps) { // Version counter: if selectedOrgs changes while fetches are in-flight, // stale callbacks check this and bail out instead of writing to state. const version = ++effectVersion; - if (orgs.length === 0) { - setOrgStates([]); - setLoadedCount(0); - return; - } - - // Initialize all orgs as loading setOrgStates( orgs.map((org) => ({ org, @@ -61,6 +72,10 @@ export default function RepoSelector(props: RepoSelectorProps) { ); setLoadedCount(0); + if (orgs.length === 0 && !props.showUpstreamDiscovery) { + return; + } + const client = getClient(); if (!client) { setOrgStates( @@ -73,58 +88,98 @@ export default function RepoSelector(props: RepoSelectorProps) { })) ); setLoadedCount(orgs.length); - return; + if (!props.showUpstreamDiscovery) return; } // Fetch org type info first, then repos incrementally void (async () => { - let entries: OrgEntry[]; - if (preloadedEntries != null) { - entries = preloadedEntries; - } else { - try { - entries = await fetchOrgs(client); - } catch { - entries = []; + if (orgs.length > 0 && client) { + let entries: OrgEntry[]; + if (preloadedEntries != null) { + entries = preloadedEntries; + } else { + try { + entries = await fetchOrgs(client); + } catch { + entries = []; + } } - } - if (version !== effectVersion) return; + if (version !== effectVersion) return; - const typeMap = new Map( - entries.map((e) => [e.login, e.type]) - ); + const typeMap = new Map( + entries.map((e) => [e.login, e.type]) + ); - // Fetch repos for each org independently so results trickle in - const promises = orgs.map(async (org) => { - const type = typeMap.get(org) ?? "org"; - try { - const repos = await fetchRepos(client, org, type); - if (version !== effectVersion) return; - setOrgStates((prev) => - prev.map((s) => - s.org === org ? { ...s, type, repos, loading: false } : s - ) - ); - } catch (err) { - if (version !== effectVersion) return; - const message = - err instanceof Error ? err.message : "Failed to load repositories"; - setOrgStates((prev) => - prev.map((s) => - s.org === org - ? { ...s, type, repos: [], loading: false, error: message } - : s - ) - ); - } finally { - if (version === effectVersion) { - setLoadedCount((c) => c + 1); + // Fetch repos for each org independently so results trickle in + const promises = orgs.map(async (org) => { + const type = typeMap.get(org) ?? "org"; + try { + const repos = await fetchRepos(client, org, type); + if (version !== effectVersion) return; + setOrgStates((prev) => + prev.map((s) => + s.org === org ? { ...s, type, repos, loading: false } : s + ) + ); + } catch (err) { + if (version !== effectVersion) return; + const message = + err instanceof Error ? err.message : "Failed to load repositories"; + setOrgStates((prev) => + prev.map((s) => + s.org === org + ? { ...s, type, repos: [], loading: false, error: message } + : s + ) + ); + } finally { + if (version === effectVersion) { + setLoadedCount((c) => c + 1); + } } - } - }); + }); + + await Promise.allSettled(promises); + } - await Promise.allSettled(promises); + // After all org repos have loaded, trigger upstream discovery if enabled. + // Use untrack to prevent reactive prop reads from re-triggering the effect. + if (props.showUpstreamDiscovery && version === effectVersion) { + const currentUser = untrack(() => user()); + const discoveryClient = getClient(); + if (currentUser && discoveryClient) { + setDiscoveringUpstream(true); + setDiscoveredRepos([]); + setDiscoveryCapped(false); + const allOrgFullNames = new Set(); + for (const state of orgStates()) { + for (const repo of state.repos) { + allOrgFullNames.add(repo.fullName); + } + } + untrack(() => { + for (const repo of props.selected) { + allOrgFullNames.add(repo.fullName); + } + for (const repo of props.upstreamRepos ?? []) { + allOrgFullNames.add(repo.fullName); + } + }); + void discoverUpstreamRepos(discoveryClient, currentUser.login, allOrgFullNames, props.trackedUsers) + .then((repos) => { + if (version !== effectVersion) return; + setDiscoveredRepos(repos); + setDiscoveryCapped(repos.length >= 100); + }) + .catch(() => { + // Non-fatal — partial results may already be in state + }) + .finally(() => { + if (version === effectVersion) setDiscoveringUpstream(false); + }); + } + } })(); }); @@ -172,20 +227,18 @@ export default function RepoSelector(props: RepoSelectorProps) { const sortedOrgStates = createMemo(() => { const states = orgStates(); - // Defer sorting during initial load to prevent layout shift as orgs trickle in. - // After initial load (all orgs resolved), sorting stays active during retries - // because loadedCount is not reset by retryOrg. + // Defer sorting until all orgs have loaded: prevents layout shift during + // trickle-in, and ensures each org's type ("user" vs "org") is resolved + // from fetchOrgs before we sort on it. loadedCount is not reset by retryOrg, + // so sorting stays active during retries. if (loadedCount() < props.selectedOrgs.length) return states; - const maxPushedAt = new Map( - states.map((s) => [ - s.org, - s.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, ""), - ]) - ); + // Order: personal org first, then remaining orgs alphabetically. + // Repos within each org retain their existing recency order from fetchRepos. return [...states].sort((a, b) => { - const aMax = maxPushedAt.get(a.org) ?? ""; - const bMax = maxPushedAt.get(b.org) ?? ""; - return aMax > bMax ? -1 : aMax < bMax ? 1 : 0; + const aIsUser = a.type === "user" ? 0 : 1; + const bIsUser = b.type === "user" ? 0 : 1; + if (aIsUser !== bIsUser) return aIsUser - bIsUser; + return a.org.localeCompare(b.org, "en"); }); }); @@ -252,6 +305,100 @@ export default function RepoSelector(props: RepoSelectorProps) { props.onChange(props.selected.filter((r) => !allVisible.has(r.fullName))); } + // ── Upstream selection helpers ──────────────────────────────────────────── + + const upstreamSelectedSet = createMemo(() => + new Set((props.upstreamRepos ?? []).map((r) => r.fullName)) + ); + + function isUpstreamSelected(fullName: string) { + return upstreamSelectedSet().has(fullName); + } + + function toggleUpstreamRepo(repo: RepoRef) { + const current = props.upstreamRepos ?? []; + if (isUpstreamSelected(repo.fullName)) { + props.onUpstreamChange?.(current.filter((r) => r.fullName !== repo.fullName)); + } else { + props.onUpstreamChange?.([...current, repo]); + } + } + + async function handleManualAdd() { + const raw = manualEntry().trim(); + if (!raw) return; + if (!VALID_REPO_NAME.test(raw)) { + setManualEntryError("Format must be owner/repo"); + return; + } + const [owner, name] = raw.split("/"); + const fullName = `${owner}/${name}`; + + // Check duplicates against org repos, upstream selected, and discovered + if (selectedSet().has(fullName)) { + setManualEntryError("Already in your selected repositories"); + return; + } + if (upstreamSelectedSet().has(fullName)) { + setManualEntryError("Already in upstream repositories"); + return; + } + if (discoveredRepos().some((r) => r.fullName === fullName)) { + setManualEntryError("Already discovered — select it from the list below"); + return; + } + + const client = getClient(); + if (!client) { + setManualEntryError("Not connected — try again"); + return; + } + + setValidatingManual(true); + setManualEntryError(null); + try { + await client.request("GET /repos/{owner}/{repo}", { owner, repo: name }); + } catch (err) { + const status = typeof err === "object" && err !== null && "status" in err + ? (err as { status: number }).status + : null; + if (status === 404) { + setManualEntryError("Repository not found"); + } else { + setManualEntryError("Could not verify repository — try again"); + } + return; + } finally { + setValidatingManual(false); + } + + const newRepo: RepoRef = { owner, name, fullName }; + props.onUpstreamChange?.([...(props.upstreamRepos ?? []), newRepo]); + setManualEntry(""); + setManualEntryError(null); + } + + function handleManualKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") void handleManualAdd(); + } + + // Manually-added upstream repos not in the discovered list + const manualUpstreamRepos = createMemo(() => { + const discoveredSet = new Set(discoveredRepos().map(r => r.fullName)); + return (props.upstreamRepos ?? []).filter(r => !discoveredSet.has(r.fullName)); + }); + + // Upstream repos visible in the discovery list (discovered + manually added that aren't org repos) + const filteredDiscovered = createMemo(() => { + const query = q(); + if (!query) return discoveredRepos(); + return discoveredRepos().filter( + (r) => + r.name.toLowerCase().includes(query) || + r.owner.toLowerCase().includes(query) + ); + }); + // ── Status ──────────────────────────────────────────────────────────────── const totalOrgs = () => props.selectedOrgs.length; @@ -413,6 +560,112 @@ export default function RepoSelector(props: RepoSelectorProps) { }} + {/* Upstream Repositories section */} + +
+ {/* Section heading */} +
+

Upstream Repositories

+

+ Repos you contribute to but don't own. Issues and PRs are tracked; workflow runs are not. +

+
+ + {/* Manual entry */} +
+ { + setManualEntry(e.currentTarget.value); + setManualEntryError(null); + }} + onKeyDown={handleManualKeyDown} + disabled={validatingManual()} + class="input input-sm flex-1" + aria-label="Add upstream repo manually" + /> + +
+ + + + + {/* Discovery loading */} + +
+ + Discovering upstream repos... +
+
+ + {/* Discovered repos list */} + 0}> +
+
+
    + + {(repo) => ( +
  • + +
  • + )} +
    +
+
+
+ +

+ Showing first 100 discovered repos. Use manual entry above to add specific repos. +

+
+
+ + {/* Manually-added upstream repos not in discovered list */} + 0}> +
+ + {(repo) => ( +
+ {repo.fullName} + +
+ )} +
+
+
+
+
+ {/* Total count */} 0}>

diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index c02fcb0b..1eba20d9 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import RepoSelector from "../onboarding/RepoSelector"; import Section from "./Section"; import SettingRow from "./SettingRow"; import ThemePicker from "./ThemePicker"; +import TrackedUsersSection from "./TrackedUsersSection"; import type { RepoRef } from "../../services/api"; export default function SettingsPage() { @@ -51,6 +52,7 @@ export default function SettingsPage() { // Local copies for org/repo editing (committed on blur/change) const [localOrgs, setLocalOrgs] = createSignal(config.selectedOrgs); const [localRepos, setLocalRepos] = createSignal(config.selectedRepos); + const [localUpstream, setLocalUpstream] = createSignal(config.upstreamRepos); // ── Helpers ────────────────────────────────────────────────────────────── @@ -106,6 +108,11 @@ export default function SettingsPage() { saveWithFeedback({ selectedRepos: repos }); } + function handleUpstreamChange(repos: RepoRef[]) { + setLocalUpstream(repos); + saveWithFeedback({ upstreamRepos: repos }); + } + async function handleRequestNotificationPermission() { if (typeof Notification === "undefined") return; const perm = await Notification.requestPermission(); @@ -134,6 +141,8 @@ export default function SettingsPage() { { selectedOrgs: config.selectedOrgs, selectedRepos: config.selectedRepos, + upstreamRepos: config.upstreamRepos, + trackedUsers: config.trackedUsers, refreshInterval: config.refreshInterval, hotPollInterval: config.hotPollInterval, maxWorkflowsPerRepo: config.maxWorkflowsPerRepo, @@ -288,7 +297,7 @@ export default function SettingsPage() {

Repositories

- {localRepos().length} selected + {localRepos().length + localUpstream().length} selected{localUpstream().length > 0 ? ` (${localUpstream().length} upstream)` : ""}

50}>

@@ -311,13 +320,30 @@ export default function SettingsPage() { selectedOrgs={localOrgs()} selected={localRepos()} onChange={handleReposChange} + showUpstreamDiscovery={true} + upstreamRepos={localUpstream()} + onUpstreamChange={handleUpstreamChange} + trackedUsers={config.trackedUsers} />

- {/* Section 2: Refresh */} + {/* Section 2: Tracked Users */} +
+
+

+ Track another GitHub user's issues and pull requests alongside yours. +

+ saveWithFeedback({ trackedUsers: users })} + /> +
+
+ + {/* Section 3: Refresh */}
- {/* Section 3: GitHub Actions */} + {/* Section 4: GitHub Actions */}
- {/* Section 4: Notifications */} + {/* Section 5: Notifications */}
- {/* Section 5: Appearance */} + {/* Section 6: Appearance */}

Theme

@@ -527,7 +553,7 @@ export default function SettingsPage() {
- {/* Section 6: Tabs */} + {/* Section 7: Tabs */}
- {/* Section 7: Data */} + {/* Section 8: Data */}
{/* Authentication method */} void; +} + +export default function TrackedUsersSection(props: TrackedUsersSectionProps) { + const [inputLogin, setInputLogin] = createSignal(""); + const [validating, setValidating] = createSignal(false); + const [validationError, setValidationError] = createSignal(null); + + async function handleAdd() { + const raw = inputLogin().trim().toLowerCase(); + if (!raw) return; + + // Check duplicate (case-insensitive — already lowercased) + const isDuplicate = props.users.some((u) => u.login.toLowerCase() === raw); + if (isDuplicate) { + setValidationError("Already tracking this user"); + return; + } + + // Check self-tracking + const currentLogin = user()?.login?.toLowerCase(); + if (currentLogin && raw === currentLogin) { + setValidationError("Your activity is already included in your dashboard"); + return; + } + + // Soft cap + if (props.users.length >= 10) { + setValidationError("Maximum of 10 tracked users"); + return; + } + + const client = getClient(); + if (!client) { + setValidationError("Not connected — try again"); + return; + } + + setValidating(true); + setValidationError(null); + try { + const validated = await validateGitHubUser(client, raw); + if (!validated) { + setValidationError("User not found"); + return; + } + props.onSave([...props.users, validated]); + setInputLogin(""); + } catch { + setValidationError("Validation failed — try again"); + } finally { + setValidating(false); + } + } + + function handleRemove(login: string) { + props.onSave(props.users.filter((u) => u.login !== login)); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + void handleAdd(); + } + } + + return ( +
+ {/* Add input row */} +
+ { + setInputLogin(e.currentTarget.value); + setValidationError(null); + }} + onKeyDown={handleKeyDown} + disabled={validating()} + class="input input-sm flex-1" + aria-label="GitHub username" + /> + +
+ + {/* Validation error */} + + + + + {/* User list */} + + {(trackedUser) => ( +
+
+
+ {trackedUser.login} +
+
+
+ + {trackedUser.name ?? trackedUser.login} + + + + {trackedUser.login} + + +
+ +
+ )} +
+ + {/* API usage warning at 3+ users */} + = 3}> + + + + {/* Cap reached message */} + = 10}> + + +
+ ); +} diff --git a/src/app/components/shared/RoleBadge.tsx b/src/app/components/shared/RoleBadge.tsx index 0df156ec..1b39c208 100644 --- a/src/app/components/shared/RoleBadge.tsx +++ b/src/app/components/shared/RoleBadge.tsx @@ -1,7 +1,7 @@ import { For, Show } from "solid-js"; interface RoleBadgeProps { - roles: ("author" | "reviewer" | "assignee")[]; + roles: ("author" | "reviewer" | "assignee" | "involved")[]; } const ROLE_CONFIG = { @@ -17,6 +17,10 @@ const ROLE_CONFIG = { label: "Assignee", class: "badge badge-accent badge-sm", }, + involved: { + label: "Involved", + class: "badge badge-ghost badge-sm", + }, } as const; export default function RoleBadge(props: RoleBadgeProps) { diff --git a/src/app/components/shared/UserAvatarBadge.tsx b/src/app/components/shared/UserAvatarBadge.tsx new file mode 100644 index 00000000..8aa0e3f0 --- /dev/null +++ b/src/app/components/shared/UserAvatarBadge.tsx @@ -0,0 +1,50 @@ +import { createMemo, For, Show } from "solid-js"; + +export function buildSurfacedByUsers( + surfacedBy: string[] | undefined, + trackedUserMap: Map, +): { login: string; avatarUrl: string }[] { + return (surfacedBy ?? []).flatMap((login) => { + const u = trackedUserMap.get(login); + return u ? [{ login: u.login, avatarUrl: u.avatarUrl }] : []; + }); +} + +interface UserAvatarBadgeProps { + users: { login: string; avatarUrl: string }[]; + currentUserLogin: string; +} + +export default function UserAvatarBadge(props: UserAvatarBadgeProps) { + const trackedUsers = createMemo(() => + props.users.filter( + (u) => u.login.toLowerCase() !== props.currentUserLogin.toLowerCase() + ) + ); + + return ( + 0}> +
u.login).join(", ")}`} + > + + {(u, i) => ( +
0 ? { "margin-left": "-6px" } : {}} + > +
+ {u.login} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/lib/emoji.ts b/src/app/lib/emoji.ts new file mode 100644 index 00000000..4305d8fa --- /dev/null +++ b/src/app/lib/emoji.ts @@ -0,0 +1,13 @@ +// Vendored from gemoji 8.1.0 — static shortcode→Unicode map (42KB, ~15KB gzipped). +// Regenerate: node -e "const {gemoji}=require('gemoji');const m={};for(const e of gemoji)for(const n of e.names)m[n]=e.emoji;process.stdout.write(JSON.stringify(m))" > src/app/lib/github-emoji-map.json +import emojiMap from "./github-emoji-map.json"; + +const SHORTCODE_RE = /:([a-z0-9_+-]+):/g; + +const map = emojiMap as Record; + +/** Replace GitHub `:shortcode:` patterns with Unicode emoji. Unknown codes are left as-is. */ +export function expandEmoji(text: string): string { + if (!text.includes(":")) return text; + return text.replace(SHORTCODE_RE, (match, name: string) => map[name] ?? match); +} diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index 6e6243da..df769619 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -75,13 +75,15 @@ export function deriveInvolvementRoles( authorLogin: string, assigneeLogins: string[], reviewerLogins: string[], -): ("author" | "reviewer" | "assignee")[] { + isUpstream?: boolean, +): ("author" | "reviewer" | "assignee" | "involved")[] { if (!userLogin) return []; const login = userLogin.toLowerCase(); - const roles: ("author" | "reviewer" | "assignee")[] = []; + const roles: ("author" | "reviewer" | "assignee" | "involved")[] = []; if (authorLogin.toLowerCase() === login) roles.push("author"); if (reviewerLogins.some((r) => r.toLowerCase() === login)) roles.push("reviewer"); if (assigneeLogins.some((a) => a.toLowerCase() === login)) roles.push("assignee"); + if (roles.length === 0 && isUpstream) roles.push("involved"); return roles; } diff --git a/src/app/lib/github-emoji-map.json b/src/app/lib/github-emoji-map.json new file mode 100644 index 00000000..472b410d --- /dev/null +++ b/src/app/lib/github-emoji-map.json @@ -0,0 +1 @@ +{"100":"💯","1234":"🔢","grinning":"😀","smiley":"😃","smile":"😄","grin":"😁","laughing":"😆","satisfied":"😆","sweat_smile":"😅","rofl":"🤣","joy":"😂","slightly_smiling_face":"🙂","upside_down_face":"🙃","melting_face":"🫠","wink":"😉","blush":"😊","innocent":"😇","smiling_face_with_three_hearts":"🥰","heart_eyes":"😍","star_struck":"🤩","kissing_heart":"😘","kissing":"😗","relaxed":"☺️","kissing_closed_eyes":"😚","kissing_smiling_eyes":"😙","smiling_face_with_tear":"🥲","yum":"😋","stuck_out_tongue":"😛","stuck_out_tongue_winking_eye":"😜","zany_face":"🤪","stuck_out_tongue_closed_eyes":"😝","money_mouth_face":"🤑","hugs":"🤗","hand_over_mouth":"🤭","face_with_open_eyes_and_hand_over_mouth":"🫢","face_with_peeking_eye":"🫣","shushing_face":"🤫","thinking":"🤔","saluting_face":"🫡","zipper_mouth_face":"🤐","raised_eyebrow":"🤨","neutral_face":"😐","expressionless":"😑","no_mouth":"😶","dotted_line_face":"🫥","face_in_clouds":"😶‍🌫️","smirk":"😏","unamused":"😒","roll_eyes":"🙄","grimacing":"😬","face_exhaling":"😮‍💨","lying_face":"🤥","shaking_face":"🫨","relieved":"😌","pensive":"😔","sleepy":"😪","drooling_face":"🤤","sleeping":"😴","mask":"😷","face_with_thermometer":"🤒","face_with_head_bandage":"🤕","nauseated_face":"🤢","vomiting_face":"🤮","sneezing_face":"🤧","hot_face":"🥵","cold_face":"🥶","woozy_face":"🥴","dizzy_face":"😵","face_with_spiral_eyes":"😵‍💫","exploding_head":"🤯","cowboy_hat_face":"🤠","partying_face":"🥳","disguised_face":"🥸","sunglasses":"😎","nerd_face":"🤓","monocle_face":"🧐","confused":"😕","face_with_diagonal_mouth":"🫤","worried":"😟","slightly_frowning_face":"🙁","frowning_face":"☹️","open_mouth":"😮","hushed":"😯","astonished":"😲","flushed":"😳","pleading_face":"🥺","face_holding_back_tears":"🥹","frowning":"😦","anguished":"😧","fearful":"😨","cold_sweat":"😰","disappointed_relieved":"😥","cry":"😢","sob":"😭","scream":"😱","confounded":"😖","persevere":"😣","disappointed":"😞","sweat":"😓","weary":"😩","tired_face":"😫","yawning_face":"🥱","triumph":"😤","rage":"😡","pout":"😡","angry":"😠","cursing_face":"🤬","smiling_imp":"😈","imp":"👿","skull":"💀","skull_and_crossbones":"☠️","hankey":"💩","poop":"💩","shit":"💩","clown_face":"🤡","japanese_ogre":"👹","japanese_goblin":"👺","ghost":"👻","alien":"👽","space_invader":"👾","robot":"🤖","smiley_cat":"😺","smile_cat":"😸","joy_cat":"😹","heart_eyes_cat":"😻","smirk_cat":"😼","kissing_cat":"😽","scream_cat":"🙀","crying_cat_face":"😿","pouting_cat":"😾","see_no_evil":"🙈","hear_no_evil":"🙉","speak_no_evil":"🙊","love_letter":"💌","cupid":"💘","gift_heart":"💝","sparkling_heart":"💖","heartpulse":"💗","heartbeat":"💓","revolving_hearts":"💞","two_hearts":"💕","heart_decoration":"💟","heavy_heart_exclamation":"❣️","broken_heart":"💔","heart_on_fire":"❤️‍🔥","mending_heart":"❤️‍🩹","heart":"❤️","pink_heart":"🩷","orange_heart":"🧡","yellow_heart":"💛","green_heart":"💚","blue_heart":"💙","light_blue_heart":"🩵","purple_heart":"💜","brown_heart":"🤎","black_heart":"🖤","grey_heart":"🩶","white_heart":"🤍","kiss":"💋","anger":"💢","boom":"💥","collision":"💥","dizzy":"💫","sweat_drops":"💦","dash":"💨","hole":"🕳️","speech_balloon":"💬","eye_speech_bubble":"👁️‍🗨️","left_speech_bubble":"🗨️","right_anger_bubble":"🗯️","thought_balloon":"💭","zzz":"💤","wave":"👋","raised_back_of_hand":"🤚","raised_hand_with_fingers_splayed":"🖐️","hand":"✋","raised_hand":"✋","vulcan_salute":"🖖","rightwards_hand":"🫱","leftwards_hand":"🫲","palm_down_hand":"🫳","palm_up_hand":"🫴","leftwards_pushing_hand":"🫷","rightwards_pushing_hand":"🫸","ok_hand":"👌","pinched_fingers":"🤌","pinching_hand":"🤏","v":"✌️","crossed_fingers":"🤞","hand_with_index_finger_and_thumb_crossed":"🫰","love_you_gesture":"🤟","metal":"🤘","call_me_hand":"🤙","point_left":"👈","point_right":"👉","point_up_2":"👆","middle_finger":"🖕","fu":"🖕","point_down":"👇","point_up":"☝️","index_pointing_at_the_viewer":"🫵","+1":"👍","thumbsup":"👍","-1":"👎","thumbsdown":"👎","fist_raised":"✊","fist":"✊","fist_oncoming":"👊","facepunch":"👊","punch":"👊","fist_left":"🤛","fist_right":"🤜","clap":"👏","raised_hands":"🙌","heart_hands":"🫶","open_hands":"👐","palms_up_together":"🤲","handshake":"🤝","pray":"🙏","writing_hand":"✍️","nail_care":"💅","selfie":"🤳","muscle":"💪","mechanical_arm":"🦾","mechanical_leg":"🦿","leg":"🦵","foot":"🦶","ear":"👂","ear_with_hearing_aid":"🦻","nose":"👃","brain":"🧠","anatomical_heart":"🫀","lungs":"🫁","tooth":"🦷","bone":"🦴","eyes":"👀","eye":"👁️","tongue":"👅","lips":"👄","biting_lip":"🫦","baby":"👶","child":"🧒","boy":"👦","girl":"👧","adult":"🧑","blond_haired_person":"👱","man":"👨","bearded_person":"🧔","man_beard":"🧔‍♂️","woman_beard":"🧔‍♀️","red_haired_man":"👨‍🦰","curly_haired_man":"👨‍🦱","white_haired_man":"👨‍🦳","bald_man":"👨‍🦲","woman":"👩","red_haired_woman":"👩‍🦰","person_red_hair":"🧑‍🦰","curly_haired_woman":"👩‍🦱","person_curly_hair":"🧑‍🦱","white_haired_woman":"👩‍🦳","person_white_hair":"🧑‍🦳","bald_woman":"👩‍🦲","person_bald":"🧑‍🦲","blond_haired_woman":"👱‍♀️","blonde_woman":"👱‍♀️","blond_haired_man":"👱‍♂️","older_adult":"🧓","older_man":"👴","older_woman":"👵","frowning_person":"🙍","frowning_man":"🙍‍♂️","frowning_woman":"🙍‍♀️","pouting_face":"🙎","pouting_man":"🙎‍♂️","pouting_woman":"🙎‍♀️","no_good":"🙅","no_good_man":"🙅‍♂️","ng_man":"🙅‍♂️","no_good_woman":"🙅‍♀️","ng_woman":"🙅‍♀️","ok_person":"🙆","ok_man":"🙆‍♂️","ok_woman":"🙆‍♀️","tipping_hand_person":"💁","information_desk_person":"💁","tipping_hand_man":"💁‍♂️","sassy_man":"💁‍♂️","tipping_hand_woman":"💁‍♀️","sassy_woman":"💁‍♀️","raising_hand":"🙋","raising_hand_man":"🙋‍♂️","raising_hand_woman":"🙋‍♀️","deaf_person":"🧏","deaf_man":"🧏‍♂️","deaf_woman":"🧏‍♀️","bow":"🙇","bowing_man":"🙇‍♂️","bowing_woman":"🙇‍♀️","facepalm":"🤦","man_facepalming":"🤦‍♂️","woman_facepalming":"🤦‍♀️","shrug":"🤷","man_shrugging":"🤷‍♂️","woman_shrugging":"🤷‍♀️","health_worker":"🧑‍⚕️","man_health_worker":"👨‍⚕️","woman_health_worker":"👩‍⚕️","student":"🧑‍🎓","man_student":"👨‍🎓","woman_student":"👩‍🎓","teacher":"🧑‍🏫","man_teacher":"👨‍🏫","woman_teacher":"👩‍🏫","judge":"🧑‍⚖️","man_judge":"👨‍⚖️","woman_judge":"👩‍⚖️","farmer":"🧑‍🌾","man_farmer":"👨‍🌾","woman_farmer":"👩‍🌾","cook":"🧑‍🍳","man_cook":"👨‍🍳","woman_cook":"👩‍🍳","mechanic":"🧑‍🔧","man_mechanic":"👨‍🔧","woman_mechanic":"👩‍🔧","factory_worker":"🧑‍🏭","man_factory_worker":"👨‍🏭","woman_factory_worker":"👩‍🏭","office_worker":"🧑‍💼","man_office_worker":"👨‍💼","woman_office_worker":"👩‍💼","scientist":"🧑‍🔬","man_scientist":"👨‍🔬","woman_scientist":"👩‍🔬","technologist":"🧑‍💻","man_technologist":"👨‍💻","woman_technologist":"👩‍💻","singer":"🧑‍🎤","man_singer":"👨‍🎤","woman_singer":"👩‍🎤","artist":"🧑‍🎨","man_artist":"👨‍🎨","woman_artist":"👩‍🎨","pilot":"🧑‍✈️","man_pilot":"👨‍✈️","woman_pilot":"👩‍✈️","astronaut":"🧑‍🚀","man_astronaut":"👨‍🚀","woman_astronaut":"👩‍🚀","firefighter":"🧑‍🚒","man_firefighter":"👨‍🚒","woman_firefighter":"👩‍🚒","police_officer":"👮","cop":"👮","policeman":"👮‍♂️","policewoman":"👮‍♀️","detective":"🕵️","male_detective":"🕵️‍♂️","female_detective":"🕵️‍♀️","guard":"💂","guardsman":"💂‍♂️","guardswoman":"💂‍♀️","ninja":"🥷","construction_worker":"👷","construction_worker_man":"👷‍♂️","construction_worker_woman":"👷‍♀️","person_with_crown":"🫅","prince":"🤴","princess":"👸","person_with_turban":"👳","man_with_turban":"👳‍♂️","woman_with_turban":"👳‍♀️","man_with_gua_pi_mao":"👲","woman_with_headscarf":"🧕","person_in_tuxedo":"🤵","man_in_tuxedo":"🤵‍♂️","woman_in_tuxedo":"🤵‍♀️","person_with_veil":"👰","man_with_veil":"👰‍♂️","woman_with_veil":"👰‍♀️","bride_with_veil":"👰‍♀️","pregnant_woman":"🤰","pregnant_man":"🫃","pregnant_person":"🫄","breast_feeding":"🤱","woman_feeding_baby":"👩‍🍼","man_feeding_baby":"👨‍🍼","person_feeding_baby":"🧑‍🍼","angel":"👼","santa":"🎅","mrs_claus":"🤶","mx_claus":"🧑‍🎄","superhero":"🦸","superhero_man":"🦸‍♂️","superhero_woman":"🦸‍♀️","supervillain":"🦹","supervillain_man":"🦹‍♂️","supervillain_woman":"🦹‍♀️","mage":"🧙","mage_man":"🧙‍♂️","mage_woman":"🧙‍♀️","fairy":"🧚","fairy_man":"🧚‍♂️","fairy_woman":"🧚‍♀️","vampire":"🧛","vampire_man":"🧛‍♂️","vampire_woman":"🧛‍♀️","merperson":"🧜","merman":"🧜‍♂️","mermaid":"🧜‍♀️","elf":"🧝","elf_man":"🧝‍♂️","elf_woman":"🧝‍♀️","genie":"🧞","genie_man":"🧞‍♂️","genie_woman":"🧞‍♀️","zombie":"🧟","zombie_man":"🧟‍♂️","zombie_woman":"🧟‍♀️","troll":"🧌","massage":"💆","massage_man":"💆‍♂️","massage_woman":"💆‍♀️","haircut":"💇","haircut_man":"💇‍♂️","haircut_woman":"💇‍♀️","walking":"🚶","walking_man":"🚶‍♂️","walking_woman":"🚶‍♀️","standing_person":"🧍","standing_man":"🧍‍♂️","standing_woman":"🧍‍♀️","kneeling_person":"🧎","kneeling_man":"🧎‍♂️","kneeling_woman":"🧎‍♀️","person_with_probing_cane":"🧑‍🦯","man_with_probing_cane":"👨‍🦯","woman_with_probing_cane":"👩‍🦯","person_in_motorized_wheelchair":"🧑‍🦼","man_in_motorized_wheelchair":"👨‍🦼","woman_in_motorized_wheelchair":"👩‍🦼","person_in_manual_wheelchair":"🧑‍🦽","man_in_manual_wheelchair":"👨‍🦽","woman_in_manual_wheelchair":"👩‍🦽","runner":"🏃","running":"🏃","running_man":"🏃‍♂️","running_woman":"🏃‍♀️","woman_dancing":"💃","dancer":"💃","man_dancing":"🕺","business_suit_levitating":"🕴️","dancers":"👯","dancing_men":"👯‍♂️","dancing_women":"👯‍♀️","sauna_person":"🧖","sauna_man":"🧖‍♂️","sauna_woman":"🧖‍♀️","climbing":"🧗","climbing_man":"🧗‍♂️","climbing_woman":"🧗‍♀️","person_fencing":"🤺","horse_racing":"🏇","skier":"⛷️","snowboarder":"🏂","golfing":"🏌️","golfing_man":"🏌️‍♂️","golfing_woman":"🏌️‍♀️","surfer":"🏄","surfing_man":"🏄‍♂️","surfing_woman":"🏄‍♀️","rowboat":"🚣","rowing_man":"🚣‍♂️","rowing_woman":"🚣‍♀️","swimmer":"🏊","swimming_man":"🏊‍♂️","swimming_woman":"🏊‍♀️","bouncing_ball_person":"⛹️","bouncing_ball_man":"⛹️‍♂️","basketball_man":"⛹️‍♂️","bouncing_ball_woman":"⛹️‍♀️","basketball_woman":"⛹️‍♀️","weight_lifting":"🏋️","weight_lifting_man":"🏋️‍♂️","weight_lifting_woman":"🏋️‍♀️","bicyclist":"🚴","biking_man":"🚴‍♂️","biking_woman":"🚴‍♀️","mountain_bicyclist":"🚵","mountain_biking_man":"🚵‍♂️","mountain_biking_woman":"🚵‍♀️","cartwheeling":"🤸","man_cartwheeling":"🤸‍♂️","woman_cartwheeling":"🤸‍♀️","wrestling":"🤼","men_wrestling":"🤼‍♂️","women_wrestling":"🤼‍♀️","water_polo":"🤽","man_playing_water_polo":"🤽‍♂️","woman_playing_water_polo":"🤽‍♀️","handball_person":"🤾","man_playing_handball":"🤾‍♂️","woman_playing_handball":"🤾‍♀️","juggling_person":"🤹","man_juggling":"🤹‍♂️","woman_juggling":"🤹‍♀️","lotus_position":"🧘","lotus_position_man":"🧘‍♂️","lotus_position_woman":"🧘‍♀️","bath":"🛀","sleeping_bed":"🛌","people_holding_hands":"🧑‍🤝‍🧑","two_women_holding_hands":"👭","couple":"👫","two_men_holding_hands":"👬","couplekiss":"💏","couplekiss_man_woman":"👩‍❤️‍💋‍👨","couplekiss_man_man":"👨‍❤️‍💋‍👨","couplekiss_woman_woman":"👩‍❤️‍💋‍👩","couple_with_heart":"💑","couple_with_heart_woman_man":"👩‍❤️‍👨","couple_with_heart_man_man":"👨‍❤️‍👨","couple_with_heart_woman_woman":"👩‍❤️‍👩","family":"👪","family_man_woman_boy":"👨‍👩‍👦","family_man_woman_girl":"👨‍👩‍👧","family_man_woman_girl_boy":"👨‍👩‍👧‍👦","family_man_woman_boy_boy":"👨‍👩‍👦‍👦","family_man_woman_girl_girl":"👨‍👩‍👧‍👧","family_man_man_boy":"👨‍👨‍👦","family_man_man_girl":"👨‍👨‍👧","family_man_man_girl_boy":"👨‍👨‍👧‍👦","family_man_man_boy_boy":"👨‍👨‍👦‍👦","family_man_man_girl_girl":"👨‍👨‍👧‍👧","family_woman_woman_boy":"👩‍👩‍👦","family_woman_woman_girl":"👩‍👩‍👧","family_woman_woman_girl_boy":"👩‍👩‍👧‍👦","family_woman_woman_boy_boy":"👩‍👩‍👦‍👦","family_woman_woman_girl_girl":"👩‍👩‍👧‍👧","family_man_boy":"👨‍👦","family_man_boy_boy":"👨‍👦‍👦","family_man_girl":"👨‍👧","family_man_girl_boy":"👨‍👧‍👦","family_man_girl_girl":"👨‍👧‍👧","family_woman_boy":"👩‍👦","family_woman_boy_boy":"👩‍👦‍👦","family_woman_girl":"👩‍👧","family_woman_girl_boy":"👩‍👧‍👦","family_woman_girl_girl":"👩‍👧‍👧","speaking_head":"🗣️","bust_in_silhouette":"👤","busts_in_silhouette":"👥","people_hugging":"🫂","footprints":"👣","monkey_face":"🐵","monkey":"🐒","gorilla":"🦍","orangutan":"🦧","dog":"🐶","dog2":"🐕","guide_dog":"🦮","service_dog":"🐕‍🦺","poodle":"🐩","wolf":"🐺","fox_face":"🦊","raccoon":"🦝","cat":"🐱","cat2":"🐈","black_cat":"🐈‍⬛","lion":"🦁","tiger":"🐯","tiger2":"🐅","leopard":"🐆","horse":"🐴","moose":"🫎","donkey":"🫏","racehorse":"🐎","unicorn":"🦄","zebra":"🦓","deer":"🦌","bison":"🦬","cow":"🐮","ox":"🐂","water_buffalo":"🐃","cow2":"🐄","pig":"🐷","pig2":"🐖","boar":"🐗","pig_nose":"🐽","ram":"🐏","sheep":"🐑","goat":"🐐","dromedary_camel":"🐪","camel":"🐫","llama":"🦙","giraffe":"🦒","elephant":"🐘","mammoth":"🦣","rhinoceros":"🦏","hippopotamus":"🦛","mouse":"🐭","mouse2":"🐁","rat":"🐀","hamster":"🐹","rabbit":"🐰","rabbit2":"🐇","chipmunk":"🐿️","beaver":"🦫","hedgehog":"🦔","bat":"🦇","bear":"🐻","polar_bear":"🐻‍❄️","koala":"🐨","panda_face":"🐼","sloth":"🦥","otter":"🦦","skunk":"🦨","kangaroo":"🦘","badger":"🦡","feet":"🐾","paw_prints":"🐾","turkey":"🦃","chicken":"🐔","rooster":"🐓","hatching_chick":"🐣","baby_chick":"🐤","hatched_chick":"🐥","bird":"🐦","penguin":"🐧","dove":"🕊️","eagle":"🦅","duck":"🦆","swan":"🦢","owl":"🦉","dodo":"🦤","feather":"🪶","flamingo":"🦩","peacock":"🦚","parrot":"🦜","wing":"🪽","black_bird":"🐦‍⬛","goose":"🪿","frog":"🐸","crocodile":"🐊","turtle":"🐢","lizard":"🦎","snake":"🐍","dragon_face":"🐲","dragon":"🐉","sauropod":"🦕","t-rex":"🦖","whale":"🐳","whale2":"🐋","dolphin":"🐬","flipper":"🐬","seal":"🦭","fish":"🐟","tropical_fish":"🐠","blowfish":"🐡","shark":"🦈","octopus":"🐙","shell":"🐚","coral":"🪸","jellyfish":"🪼","snail":"🐌","butterfly":"🦋","bug":"🐛","ant":"🐜","bee":"🐝","honeybee":"🐝","beetle":"🪲","lady_beetle":"🐞","cricket":"🦗","cockroach":"🪳","spider":"🕷️","spider_web":"🕸️","scorpion":"🦂","mosquito":"🦟","fly":"🪰","worm":"🪱","microbe":"🦠","bouquet":"💐","cherry_blossom":"🌸","white_flower":"💮","lotus":"🪷","rosette":"🏵️","rose":"🌹","wilted_flower":"🥀","hibiscus":"🌺","sunflower":"🌻","blossom":"🌼","tulip":"🌷","hyacinth":"🪻","seedling":"🌱","potted_plant":"🪴","evergreen_tree":"🌲","deciduous_tree":"🌳","palm_tree":"🌴","cactus":"🌵","ear_of_rice":"🌾","herb":"🌿","shamrock":"☘️","four_leaf_clover":"🍀","maple_leaf":"🍁","fallen_leaf":"🍂","leaves":"🍃","empty_nest":"🪹","nest_with_eggs":"🪺","mushroom":"🍄","grapes":"🍇","melon":"🍈","watermelon":"🍉","tangerine":"🍊","orange":"🍊","mandarin":"🍊","lemon":"🍋","banana":"🍌","pineapple":"🍍","mango":"🥭","apple":"🍎","green_apple":"🍏","pear":"🍐","peach":"🍑","cherries":"🍒","strawberry":"🍓","blueberries":"🫐","kiwi_fruit":"🥝","tomato":"🍅","olive":"🫒","coconut":"🥥","avocado":"🥑","eggplant":"🍆","potato":"🥔","carrot":"🥕","corn":"🌽","hot_pepper":"🌶️","bell_pepper":"🫑","cucumber":"🥒","leafy_green":"🥬","broccoli":"🥦","garlic":"🧄","onion":"🧅","peanuts":"🥜","beans":"🫘","chestnut":"🌰","ginger_root":"🫚","pea_pod":"🫛","bread":"🍞","croissant":"🥐","baguette_bread":"🥖","flatbread":"🫓","pretzel":"🥨","bagel":"🥯","pancakes":"🥞","waffle":"🧇","cheese":"🧀","meat_on_bone":"🍖","poultry_leg":"🍗","cut_of_meat":"🥩","bacon":"🥓","hamburger":"🍔","fries":"🍟","pizza":"🍕","hotdog":"🌭","sandwich":"🥪","taco":"🌮","burrito":"🌯","tamale":"🫔","stuffed_flatbread":"🥙","falafel":"🧆","egg":"🥚","fried_egg":"🍳","shallow_pan_of_food":"🥘","stew":"🍲","fondue":"🫕","bowl_with_spoon":"🥣","green_salad":"🥗","popcorn":"🍿","butter":"🧈","salt":"🧂","canned_food":"🥫","bento":"🍱","rice_cracker":"🍘","rice_ball":"🍙","rice":"🍚","curry":"🍛","ramen":"🍜","spaghetti":"🍝","sweet_potato":"🍠","oden":"🍢","sushi":"🍣","fried_shrimp":"🍤","fish_cake":"🍥","moon_cake":"🥮","dango":"🍡","dumpling":"🥟","fortune_cookie":"🥠","takeout_box":"🥡","crab":"🦀","lobster":"🦞","shrimp":"🦐","squid":"🦑","oyster":"🦪","icecream":"🍦","shaved_ice":"🍧","ice_cream":"🍨","doughnut":"🍩","cookie":"🍪","birthday":"🎂","cake":"🍰","cupcake":"🧁","pie":"🥧","chocolate_bar":"🍫","candy":"🍬","lollipop":"🍭","custard":"🍮","honey_pot":"🍯","baby_bottle":"🍼","milk_glass":"🥛","coffee":"☕","teapot":"🫖","tea":"🍵","sake":"🍶","champagne":"🍾","wine_glass":"🍷","cocktail":"🍸","tropical_drink":"🍹","beer":"🍺","beers":"🍻","clinking_glasses":"🥂","tumbler_glass":"🥃","pouring_liquid":"🫗","cup_with_straw":"🥤","bubble_tea":"🧋","beverage_box":"🧃","mate":"🧉","ice_cube":"🧊","chopsticks":"🥢","plate_with_cutlery":"🍽️","fork_and_knife":"🍴","spoon":"🥄","hocho":"🔪","knife":"🔪","jar":"🫙","amphora":"🏺","earth_africa":"🌍","earth_americas":"🌎","earth_asia":"🌏","globe_with_meridians":"🌐","world_map":"🗺️","japan":"🗾","compass":"🧭","mountain_snow":"🏔️","mountain":"⛰️","volcano":"🌋","mount_fuji":"🗻","camping":"🏕️","beach_umbrella":"🏖️","desert":"🏜️","desert_island":"🏝️","national_park":"🏞️","stadium":"🏟️","classical_building":"🏛️","building_construction":"🏗️","bricks":"🧱","rock":"🪨","wood":"🪵","hut":"🛖","houses":"🏘️","derelict_house":"🏚️","house":"🏠","house_with_garden":"🏡","office":"🏢","post_office":"🏣","european_post_office":"🏤","hospital":"🏥","bank":"🏦","hotel":"🏨","love_hotel":"🏩","convenience_store":"🏪","school":"🏫","department_store":"🏬","factory":"🏭","japanese_castle":"🏯","european_castle":"🏰","wedding":"💒","tokyo_tower":"🗼","statue_of_liberty":"🗽","church":"⛪","mosque":"🕌","hindu_temple":"🛕","synagogue":"🕍","shinto_shrine":"⛩️","kaaba":"🕋","fountain":"⛲","tent":"⛺","foggy":"🌁","night_with_stars":"🌃","cityscape":"🏙️","sunrise_over_mountains":"🌄","sunrise":"🌅","city_sunset":"🌆","city_sunrise":"🌇","bridge_at_night":"🌉","hotsprings":"♨️","carousel_horse":"🎠","playground_slide":"🛝","ferris_wheel":"🎡","roller_coaster":"🎢","barber":"💈","circus_tent":"🎪","steam_locomotive":"🚂","railway_car":"🚃","bullettrain_side":"🚄","bullettrain_front":"🚅","train2":"🚆","metro":"🚇","light_rail":"🚈","station":"🚉","tram":"🚊","monorail":"🚝","mountain_railway":"🚞","train":"🚋","bus":"🚌","oncoming_bus":"🚍","trolleybus":"🚎","minibus":"🚐","ambulance":"🚑","fire_engine":"🚒","police_car":"🚓","oncoming_police_car":"🚔","taxi":"🚕","oncoming_taxi":"🚖","car":"🚗","red_car":"🚗","oncoming_automobile":"🚘","blue_car":"🚙","pickup_truck":"🛻","truck":"🚚","articulated_lorry":"🚛","tractor":"🚜","racing_car":"🏎️","motorcycle":"🏍️","motor_scooter":"🛵","manual_wheelchair":"🦽","motorized_wheelchair":"🦼","auto_rickshaw":"🛺","bike":"🚲","kick_scooter":"🛴","skateboard":"🛹","roller_skate":"🛼","busstop":"🚏","motorway":"🛣️","railway_track":"🛤️","oil_drum":"🛢️","fuelpump":"⛽","wheel":"🛞","rotating_light":"🚨","traffic_light":"🚥","vertical_traffic_light":"🚦","stop_sign":"🛑","construction":"🚧","anchor":"⚓","ring_buoy":"🛟","boat":"⛵","sailboat":"⛵","canoe":"🛶","speedboat":"🚤","passenger_ship":"🛳️","ferry":"⛴️","motor_boat":"🛥️","ship":"🚢","airplane":"✈️","small_airplane":"🛩️","flight_departure":"🛫","flight_arrival":"🛬","parachute":"🪂","seat":"💺","helicopter":"🚁","suspension_railway":"🚟","mountain_cableway":"🚠","aerial_tramway":"🚡","artificial_satellite":"🛰️","rocket":"🚀","flying_saucer":"🛸","bellhop_bell":"🛎️","luggage":"🧳","hourglass":"⌛","hourglass_flowing_sand":"⏳","watch":"⌚","alarm_clock":"⏰","stopwatch":"⏱️","timer_clock":"⏲️","mantelpiece_clock":"🕰️","clock12":"🕛","clock1230":"🕧","clock1":"🕐","clock130":"🕜","clock2":"🕑","clock230":"🕝","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"🕢","clock8":"🕗","clock830":"🕣","clock9":"🕘","clock930":"🕤","clock10":"🕙","clock1030":"🕥","clock11":"🕚","clock1130":"🕦","new_moon":"🌑","waxing_crescent_moon":"🌒","first_quarter_moon":"🌓","moon":"🌔","waxing_gibbous_moon":"🌔","full_moon":"🌕","waning_gibbous_moon":"🌖","last_quarter_moon":"🌗","waning_crescent_moon":"🌘","crescent_moon":"🌙","new_moon_with_face":"🌚","first_quarter_moon_with_face":"🌛","last_quarter_moon_with_face":"🌜","thermometer":"🌡️","sunny":"☀️","full_moon_with_face":"🌝","sun_with_face":"🌞","ringed_planet":"🪐","star":"⭐","star2":"🌟","stars":"🌠","milky_way":"🌌","cloud":"☁️","partly_sunny":"⛅","cloud_with_lightning_and_rain":"⛈️","sun_behind_small_cloud":"🌤️","sun_behind_large_cloud":"🌥️","sun_behind_rain_cloud":"🌦️","cloud_with_rain":"🌧️","cloud_with_snow":"🌨️","cloud_with_lightning":"🌩️","tornado":"🌪️","fog":"🌫️","wind_face":"🌬️","cyclone":"🌀","rainbow":"🌈","closed_umbrella":"🌂","open_umbrella":"☂️","umbrella":"☔","parasol_on_ground":"⛱️","zap":"⚡","snowflake":"❄️","snowman_with_snow":"☃️","snowman":"⛄","comet":"☄️","fire":"🔥","droplet":"💧","ocean":"🌊","jack_o_lantern":"🎃","christmas_tree":"🎄","fireworks":"🎆","sparkler":"🎇","firecracker":"🧨","sparkles":"✨","balloon":"🎈","tada":"🎉","confetti_ball":"🎊","tanabata_tree":"🎋","bamboo":"🎍","dolls":"🎎","flags":"🎏","wind_chime":"🎐","rice_scene":"🎑","red_envelope":"🧧","ribbon":"🎀","gift":"🎁","reminder_ribbon":"🎗️","tickets":"🎟️","ticket":"🎫","medal_military":"🎖️","trophy":"🏆","medal_sports":"🏅","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","soccer":"⚽","baseball":"⚾","softball":"🥎","basketball":"🏀","volleyball":"🏐","football":"🏈","rugby_football":"🏉","tennis":"🎾","flying_disc":"🥏","bowling":"🎳","cricket_game":"🏏","field_hockey":"🏑","ice_hockey":"🏒","lacrosse":"🥍","ping_pong":"🏓","badminton":"🏸","boxing_glove":"🥊","martial_arts_uniform":"🥋","goal_net":"🥅","golf":"⛳","ice_skate":"⛸️","fishing_pole_and_fish":"🎣","diving_mask":"🤿","running_shirt_with_sash":"🎽","ski":"🎿","sled":"🛷","curling_stone":"🥌","dart":"🎯","yo_yo":"🪀","kite":"🪁","gun":"🔫","8ball":"🎱","crystal_ball":"🔮","magic_wand":"🪄","video_game":"🎮","joystick":"🕹️","slot_machine":"🎰","game_die":"🎲","jigsaw":"🧩","teddy_bear":"🧸","pinata":"🪅","mirror_ball":"🪩","nesting_dolls":"🪆","spades":"♠️","hearts":"♥️","diamonds":"♦️","clubs":"♣️","chess_pawn":"♟️","black_joker":"🃏","mahjong":"🀄","flower_playing_cards":"🎴","performing_arts":"🎭","framed_picture":"🖼️","art":"🎨","thread":"🧵","sewing_needle":"🪡","yarn":"🧶","knot":"🪢","eyeglasses":"👓","dark_sunglasses":"🕶️","goggles":"🥽","lab_coat":"🥼","safety_vest":"🦺","necktie":"👔","shirt":"👕","tshirt":"👕","jeans":"👖","scarf":"🧣","gloves":"🧤","coat":"🧥","socks":"🧦","dress":"👗","kimono":"👘","sari":"🥻","one_piece_swimsuit":"🩱","swim_brief":"🩲","shorts":"🩳","bikini":"👙","womans_clothes":"👚","folding_hand_fan":"🪭","purse":"👛","handbag":"👜","pouch":"👝","shopping":"🛍️","school_satchel":"🎒","thong_sandal":"🩴","mans_shoe":"👞","shoe":"👞","athletic_shoe":"👟","hiking_boot":"🥾","flat_shoe":"🥿","high_heel":"👠","sandal":"👡","ballet_shoes":"🩰","boot":"👢","hair_pick":"🪮","crown":"👑","womans_hat":"👒","tophat":"🎩","mortar_board":"🎓","billed_cap":"🧢","military_helmet":"🪖","rescue_worker_helmet":"⛑️","prayer_beads":"📿","lipstick":"💄","ring":"💍","gem":"💎","mute":"🔇","speaker":"🔈","sound":"🔉","loud_sound":"🔊","loudspeaker":"📢","mega":"📣","postal_horn":"📯","bell":"🔔","no_bell":"🔕","musical_score":"🎼","musical_note":"🎵","notes":"🎶","studio_microphone":"🎙️","level_slider":"🎚️","control_knobs":"🎛️","microphone":"🎤","headphones":"🎧","radio":"📻","saxophone":"🎷","accordion":"🪗","guitar":"🎸","musical_keyboard":"🎹","trumpet":"🎺","violin":"🎻","banjo":"🪕","drum":"🥁","long_drum":"🪘","maracas":"🪇","flute":"🪈","iphone":"📱","calling":"📲","phone":"☎️","telephone":"☎️","telephone_receiver":"📞","pager":"📟","fax":"📠","battery":"🔋","low_battery":"🪫","electric_plug":"🔌","computer":"💻","desktop_computer":"🖥️","printer":"🖨️","keyboard":"⌨️","computer_mouse":"🖱️","trackball":"🖲️","minidisc":"💽","floppy_disk":"💾","cd":"💿","dvd":"📀","abacus":"🧮","movie_camera":"🎥","film_strip":"🎞️","film_projector":"📽️","clapper":"🎬","tv":"📺","camera":"📷","camera_flash":"📸","video_camera":"📹","vhs":"📼","mag":"🔍","mag_right":"🔎","candle":"🕯️","bulb":"💡","flashlight":"🔦","izakaya_lantern":"🏮","lantern":"🏮","diya_lamp":"🪔","notebook_with_decorative_cover":"📔","closed_book":"📕","book":"📖","open_book":"📖","green_book":"📗","blue_book":"📘","orange_book":"📙","books":"📚","notebook":"📓","ledger":"📒","page_with_curl":"📃","scroll":"📜","page_facing_up":"📄","newspaper":"📰","newspaper_roll":"🗞️","bookmark_tabs":"📑","bookmark":"🔖","label":"🏷️","moneybag":"💰","coin":"🪙","yen":"💴","dollar":"💵","euro":"💶","pound":"💷","money_with_wings":"💸","credit_card":"💳","receipt":"🧾","chart":"💹","envelope":"✉️","email":"📧","e-mail":"📧","incoming_envelope":"📨","envelope_with_arrow":"📩","outbox_tray":"📤","inbox_tray":"📥","package":"📦","mailbox":"📫","mailbox_closed":"📪","mailbox_with_mail":"📬","mailbox_with_no_mail":"📭","postbox":"📮","ballot_box":"🗳️","pencil2":"✏️","black_nib":"✒️","fountain_pen":"🖋️","pen":"🖊️","paintbrush":"🖌️","crayon":"🖍️","memo":"📝","pencil":"📝","briefcase":"💼","file_folder":"📁","open_file_folder":"📂","card_index_dividers":"🗂️","date":"📅","calendar":"📆","spiral_notepad":"🗒️","spiral_calendar":"🗓️","card_index":"📇","chart_with_upwards_trend":"📈","chart_with_downwards_trend":"📉","bar_chart":"📊","clipboard":"📋","pushpin":"📌","round_pushpin":"📍","paperclip":"📎","paperclips":"🖇️","straight_ruler":"📏","triangular_ruler":"📐","scissors":"✂️","card_file_box":"🗃️","file_cabinet":"🗄️","wastebasket":"🗑️","lock":"🔒","unlock":"🔓","lock_with_ink_pen":"🔏","closed_lock_with_key":"🔐","key":"🔑","old_key":"🗝️","hammer":"🔨","axe":"🪓","pick":"⛏️","hammer_and_pick":"⚒️","hammer_and_wrench":"🛠️","dagger":"🗡️","crossed_swords":"⚔️","bomb":"💣","boomerang":"🪃","bow_and_arrow":"🏹","shield":"🛡️","carpentry_saw":"🪚","wrench":"🔧","screwdriver":"🪛","nut_and_bolt":"🔩","gear":"⚙️","clamp":"🗜️","balance_scale":"⚖️","probing_cane":"🦯","link":"🔗","chains":"⛓️","hook":"🪝","toolbox":"🧰","magnet":"🧲","ladder":"🪜","alembic":"⚗️","test_tube":"🧪","petri_dish":"🧫","dna":"🧬","microscope":"🔬","telescope":"🔭","satellite":"📡","syringe":"💉","drop_of_blood":"🩸","pill":"💊","adhesive_bandage":"🩹","crutch":"🩼","stethoscope":"🩺","x_ray":"🩻","door":"🚪","elevator":"🛗","mirror":"🪞","window":"🪟","bed":"🛏️","couch_and_lamp":"🛋️","chair":"🪑","toilet":"🚽","plunger":"🪠","shower":"🚿","bathtub":"🛁","mouse_trap":"🪤","razor":"🪒","lotion_bottle":"🧴","safety_pin":"🧷","broom":"🧹","basket":"🧺","roll_of_paper":"🧻","bucket":"🪣","soap":"🧼","bubbles":"🫧","toothbrush":"🪥","sponge":"🧽","fire_extinguisher":"🧯","shopping_cart":"🛒","smoking":"🚬","coffin":"⚰️","headstone":"🪦","funeral_urn":"⚱️","nazar_amulet":"🧿","hamsa":"🪬","moyai":"🗿","placard":"🪧","identification_card":"🪪","atm":"🏧","put_litter_in_its_place":"🚮","potable_water":"🚰","wheelchair":"♿","mens":"🚹","womens":"🚺","restroom":"🚻","baby_symbol":"🚼","wc":"🚾","passport_control":"🛂","customs":"🛃","baggage_claim":"🛄","left_luggage":"🛅","warning":"⚠️","children_crossing":"🚸","no_entry":"⛔","no_entry_sign":"🚫","no_bicycles":"🚳","no_smoking":"🚭","do_not_litter":"🚯","non-potable_water":"🚱","no_pedestrians":"🚷","no_mobile_phones":"📵","underage":"🔞","radioactive":"☢️","biohazard":"☣️","arrow_up":"⬆️","arrow_upper_right":"↗️","arrow_right":"➡️","arrow_lower_right":"↘️","arrow_down":"⬇️","arrow_lower_left":"↙️","arrow_left":"⬅️","arrow_upper_left":"↖️","arrow_up_down":"↕️","left_right_arrow":"↔️","leftwards_arrow_with_hook":"↩️","arrow_right_hook":"↪️","arrow_heading_up":"⤴️","arrow_heading_down":"⤵️","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","back":"🔙","end":"🔚","on":"🔛","soon":"🔜","top":"🔝","place_of_worship":"🛐","atom_symbol":"⚛️","om":"🕉️","star_of_david":"✡️","wheel_of_dharma":"☸️","yin_yang":"☯️","latin_cross":"✝️","orthodox_cross":"☦️","star_and_crescent":"☪️","peace_symbol":"☮️","menorah":"🕎","six_pointed_star":"🔯","khanda":"🪯","aries":"♈","taurus":"♉","gemini":"♊","cancer":"♋","leo":"♌","virgo":"♍","libra":"♎","scorpius":"♏","sagittarius":"♐","capricorn":"♑","aquarius":"♒","pisces":"♓","ophiuchus":"⛎","twisted_rightwards_arrows":"🔀","repeat":"🔁","repeat_one":"🔂","arrow_forward":"▶️","fast_forward":"⏩","next_track_button":"⏭️","play_or_pause_button":"⏯️","arrow_backward":"◀️","rewind":"⏪","previous_track_button":"⏮️","arrow_up_small":"🔼","arrow_double_up":"⏫","arrow_down_small":"🔽","arrow_double_down":"⏬","pause_button":"⏸️","stop_button":"⏹️","record_button":"⏺️","eject_button":"⏏️","cinema":"🎦","low_brightness":"🔅","high_brightness":"🔆","signal_strength":"📶","wireless":"🛜","vibration_mode":"📳","mobile_phone_off":"📴","female_sign":"♀️","male_sign":"♂️","transgender_symbol":"⚧️","heavy_multiplication_x":"✖️","heavy_plus_sign":"➕","heavy_minus_sign":"➖","heavy_division_sign":"➗","heavy_equals_sign":"🟰","infinity":"♾️","bangbang":"‼️","interrobang":"⁉️","question":"❓","grey_question":"❔","grey_exclamation":"❕","exclamation":"❗","heavy_exclamation_mark":"❗","wavy_dash":"〰️","currency_exchange":"💱","heavy_dollar_sign":"💲","medical_symbol":"⚕️","recycle":"♻️","fleur_de_lis":"⚜️","trident":"🔱","name_badge":"📛","beginner":"🔰","o":"⭕","white_check_mark":"✅","ballot_box_with_check":"☑️","heavy_check_mark":"✔️","x":"❌","negative_squared_cross_mark":"❎","curly_loop":"➰","loop":"➿","part_alternation_mark":"〽️","eight_spoked_asterisk":"✳️","eight_pointed_black_star":"✴️","sparkle":"❇️","copyright":"©️","registered":"®️","tm":"™️","hash":"#️⃣","asterisk":"*️⃣","zero":"0️⃣","one":"1️⃣","two":"2️⃣","three":"3️⃣","four":"4️⃣","five":"5️⃣","six":"6️⃣","seven":"7️⃣","eight":"8️⃣","nine":"9️⃣","keycap_ten":"🔟","capital_abcd":"🔠","abcd":"🔡","symbols":"🔣","abc":"🔤","a":"🅰️","ab":"🆎","b":"🅱️","cl":"🆑","cool":"🆒","free":"🆓","information_source":"ℹ️","id":"🆔","m":"Ⓜ️","new":"🆕","ng":"🆖","o2":"🅾️","ok":"🆗","parking":"🅿️","sos":"🆘","up":"🆙","vs":"🆚","koko":"🈁","sa":"🈂️","u6708":"🈷️","u6709":"🈶","u6307":"🈯","ideograph_advantage":"🉐","u5272":"🈹","u7121":"🈚","u7981":"🈲","accept":"🉑","u7533":"🈸","u5408":"🈴","u7a7a":"🈳","congratulations":"㊗️","secret":"㊙️","u55b6":"🈺","u6e80":"🈵","red_circle":"🔴","orange_circle":"🟠","yellow_circle":"🟡","green_circle":"🟢","large_blue_circle":"🔵","purple_circle":"🟣","brown_circle":"🟤","black_circle":"⚫","white_circle":"⚪","red_square":"🟥","orange_square":"🟧","yellow_square":"🟨","green_square":"🟩","blue_square":"🟦","purple_square":"🟪","brown_square":"🟫","black_large_square":"⬛","white_large_square":"⬜","black_medium_square":"◼️","white_medium_square":"◻️","black_medium_small_square":"◾","white_medium_small_square":"◽","black_small_square":"▪️","white_small_square":"▫️","large_orange_diamond":"🔶","large_blue_diamond":"🔷","small_orange_diamond":"🔸","small_blue_diamond":"🔹","small_red_triangle":"🔺","small_red_triangle_down":"🔻","diamond_shape_with_a_dot_inside":"💠","radio_button":"🔘","white_square_button":"🔳","black_square_button":"🔲","checkered_flag":"🏁","triangular_flag_on_post":"🚩","crossed_flags":"🎌","black_flag":"🏴","white_flag":"🏳️","rainbow_flag":"🏳️‍🌈","transgender_flag":"🏳️‍⚧️","pirate_flag":"🏴‍☠️","ascension_island":"🇦🇨","andorra":"🇦🇩","united_arab_emirates":"🇦🇪","afghanistan":"🇦🇫","antigua_barbuda":"🇦🇬","anguilla":"🇦🇮","albania":"🇦🇱","armenia":"🇦🇲","angola":"🇦🇴","antarctica":"🇦🇶","argentina":"🇦🇷","american_samoa":"🇦🇸","austria":"🇦🇹","australia":"🇦🇺","aruba":"🇦🇼","aland_islands":"🇦🇽","azerbaijan":"🇦🇿","bosnia_herzegovina":"🇧🇦","barbados":"🇧🇧","bangladesh":"🇧🇩","belgium":"🇧🇪","burkina_faso":"🇧🇫","bulgaria":"🇧🇬","bahrain":"🇧🇭","burundi":"🇧🇮","benin":"🇧🇯","st_barthelemy":"🇧🇱","bermuda":"🇧🇲","brunei":"🇧🇳","bolivia":"🇧🇴","caribbean_netherlands":"🇧🇶","brazil":"🇧🇷","bahamas":"🇧🇸","bhutan":"🇧🇹","bouvet_island":"🇧🇻","botswana":"🇧🇼","belarus":"🇧🇾","belize":"🇧🇿","canada":"🇨🇦","cocos_islands":"🇨🇨","congo_kinshasa":"🇨🇩","central_african_republic":"🇨🇫","congo_brazzaville":"🇨🇬","switzerland":"🇨🇭","cote_divoire":"🇨🇮","cook_islands":"🇨🇰","chile":"🇨🇱","cameroon":"🇨🇲","cn":"🇨🇳","colombia":"🇨🇴","clipperton_island":"🇨🇵","costa_rica":"🇨🇷","cuba":"🇨🇺","cape_verde":"🇨🇻","curacao":"🇨🇼","christmas_island":"🇨🇽","cyprus":"🇨🇾","czech_republic":"🇨🇿","de":"🇩🇪","diego_garcia":"🇩🇬","djibouti":"🇩🇯","denmark":"🇩🇰","dominica":"🇩🇲","dominican_republic":"🇩🇴","algeria":"🇩🇿","ceuta_melilla":"🇪🇦","ecuador":"🇪🇨","estonia":"🇪🇪","egypt":"🇪🇬","western_sahara":"🇪🇭","eritrea":"🇪🇷","es":"🇪🇸","ethiopia":"🇪🇹","eu":"🇪🇺","european_union":"🇪🇺","finland":"🇫🇮","fiji":"🇫🇯","falkland_islands":"🇫🇰","micronesia":"🇫🇲","faroe_islands":"🇫🇴","fr":"🇫🇷","gabon":"🇬🇦","gb":"🇬🇧","uk":"🇬🇧","grenada":"🇬🇩","georgia":"🇬🇪","french_guiana":"🇬🇫","guernsey":"🇬🇬","ghana":"🇬🇭","gibraltar":"🇬🇮","greenland":"🇬🇱","gambia":"🇬🇲","guinea":"🇬🇳","guadeloupe":"🇬🇵","equatorial_guinea":"🇬🇶","greece":"🇬🇷","south_georgia_south_sandwich_islands":"🇬🇸","guatemala":"🇬🇹","guam":"🇬🇺","guinea_bissau":"🇬🇼","guyana":"🇬🇾","hong_kong":"🇭🇰","heard_mcdonald_islands":"🇭🇲","honduras":"🇭🇳","croatia":"🇭🇷","haiti":"🇭🇹","hungary":"🇭🇺","canary_islands":"🇮🇨","indonesia":"🇮🇩","ireland":"🇮🇪","israel":"🇮🇱","isle_of_man":"🇮🇲","india":"🇮🇳","british_indian_ocean_territory":"🇮🇴","iraq":"🇮🇶","iran":"🇮🇷","iceland":"🇮🇸","it":"🇮🇹","jersey":"🇯🇪","jamaica":"🇯🇲","jordan":"🇯🇴","jp":"🇯🇵","kenya":"🇰🇪","kyrgyzstan":"🇰🇬","cambodia":"🇰🇭","kiribati":"🇰🇮","comoros":"🇰🇲","st_kitts_nevis":"🇰🇳","north_korea":"🇰🇵","kr":"🇰🇷","kuwait":"🇰🇼","cayman_islands":"🇰🇾","kazakhstan":"🇰🇿","laos":"🇱🇦","lebanon":"🇱🇧","st_lucia":"🇱🇨","liechtenstein":"🇱🇮","sri_lanka":"🇱🇰","liberia":"🇱🇷","lesotho":"🇱🇸","lithuania":"🇱🇹","luxembourg":"🇱🇺","latvia":"🇱🇻","libya":"🇱🇾","morocco":"🇲🇦","monaco":"🇲🇨","moldova":"🇲🇩","montenegro":"🇲🇪","st_martin":"🇲🇫","madagascar":"🇲🇬","marshall_islands":"🇲🇭","macedonia":"🇲🇰","mali":"🇲🇱","myanmar":"🇲🇲","mongolia":"🇲🇳","macau":"🇲🇴","northern_mariana_islands":"🇲🇵","martinique":"🇲🇶","mauritania":"🇲🇷","montserrat":"🇲🇸","malta":"🇲🇹","mauritius":"🇲🇺","maldives":"🇲🇻","malawi":"🇲🇼","mexico":"🇲🇽","malaysia":"🇲🇾","mozambique":"🇲🇿","namibia":"🇳🇦","new_caledonia":"🇳🇨","niger":"🇳🇪","norfolk_island":"🇳🇫","nigeria":"🇳🇬","nicaragua":"🇳🇮","netherlands":"🇳🇱","norway":"🇳🇴","nepal":"🇳🇵","nauru":"🇳🇷","niue":"🇳🇺","new_zealand":"🇳🇿","oman":"🇴🇲","panama":"🇵🇦","peru":"🇵🇪","french_polynesia":"🇵🇫","papua_new_guinea":"🇵🇬","philippines":"🇵🇭","pakistan":"🇵🇰","poland":"🇵🇱","st_pierre_miquelon":"🇵🇲","pitcairn_islands":"🇵🇳","puerto_rico":"🇵🇷","palestinian_territories":"🇵🇸","portugal":"🇵🇹","palau":"🇵🇼","paraguay":"🇵🇾","qatar":"🇶🇦","reunion":"🇷🇪","romania":"🇷🇴","serbia":"🇷🇸","ru":"🇷🇺","rwanda":"🇷🇼","saudi_arabia":"🇸🇦","solomon_islands":"🇸🇧","seychelles":"🇸🇨","sudan":"🇸🇩","sweden":"🇸🇪","singapore":"🇸🇬","st_helena":"🇸🇭","slovenia":"🇸🇮","svalbard_jan_mayen":"🇸🇯","slovakia":"🇸🇰","sierra_leone":"🇸🇱","san_marino":"🇸🇲","senegal":"🇸🇳","somalia":"🇸🇴","suriname":"🇸🇷","south_sudan":"🇸🇸","sao_tome_principe":"🇸🇹","el_salvador":"🇸🇻","sint_maarten":"🇸🇽","syria":"🇸🇾","swaziland":"🇸🇿","tristan_da_cunha":"🇹🇦","turks_caicos_islands":"🇹🇨","chad":"🇹🇩","french_southern_territories":"🇹🇫","togo":"🇹🇬","thailand":"🇹🇭","tajikistan":"🇹🇯","tokelau":"🇹🇰","timor_leste":"🇹🇱","turkmenistan":"🇹🇲","tunisia":"🇹🇳","tonga":"🇹🇴","tr":"🇹🇷","trinidad_tobago":"🇹🇹","tuvalu":"🇹🇻","taiwan":"🇹🇼","tanzania":"🇹🇿","ukraine":"🇺🇦","uganda":"🇺🇬","us_outlying_islands":"🇺🇲","united_nations":"🇺🇳","us":"🇺🇸","uruguay":"🇺🇾","uzbekistan":"🇺🇿","vatican_city":"🇻🇦","st_vincent_grenadines":"🇻🇨","venezuela":"🇻🇪","british_virgin_islands":"🇻🇬","us_virgin_islands":"🇻🇮","vietnam":"🇻🇳","vanuatu":"🇻🇺","wallis_futuna":"🇼🇫","samoa":"🇼🇸","kosovo":"🇽🇰","yemen":"🇾🇪","mayotte":"🇾🇹","south_africa":"🇿🇦","zambia":"🇿🇲","zimbabwe":"🇿🇼","england":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","scotland":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","wales":"🏴󠁧󠁢󠁷󠁬󠁳󠁿"} diff --git a/src/app/services/api.ts b/src/app/services/api.ts index c9f677f6..46ed6281 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1,5 +1,6 @@ import { getClient, cachedRequest, updateGraphqlRateLimit, updateRateLimitFromHeaders } from "./github"; import { pushNotification } from "../lib/errors"; +import type { TrackedUser } from "../stores/config"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -33,6 +34,7 @@ export interface Issue { assigneeLogins: string[]; repoFullName: string; comments: number; + surfacedBy?: string[]; } export interface CheckStatus { @@ -69,6 +71,7 @@ export interface PullRequest { enriched?: boolean; /** GraphQL global node ID — used for hot-poll status updates */ nodeId?: string; + surfacedBy?: string[]; } export interface WorkflowRun { @@ -197,7 +200,8 @@ function extractSearchPartialData(err: unknown): T | null { } const VALID_REPO_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; -const VALID_LOGIN = /^[A-Za-z0-9\[\]-]+$/; +const VALID_LOGIN = /^[A-Za-z0-9-]{1,39}$/; +const VALID_TRACKED_LOGIN = VALID_LOGIN; function chunkArray(arr: T[], size: number): T[][] { const chunks: T[][] = []; @@ -912,6 +916,99 @@ function processLightPRNode( return true; } +/** + * Executes a single LIGHT_COMBINED_SEARCH_QUERY with partial-error handling, + * node processing, and pagination follow-ups. Shared by both the chunked + * repo-scoped search and the unscoped global user search. + */ +async function executeLightCombinedQuery( + octokit: GitHubOctokit, + issueQ: string, + prInvQ: string, + prRevQ: string, + errorLabel: string, + issueSeen: Set, + issues: Issue[], + prMap: Map, + nodeIdMap: Map, + errors: ApiError[], + issueCap: number, + prCap: number, +): Promise { + let response: LightCombinedSearchResponse; + let isPartial = false; + try { + response = await octokit.graphql(LIGHT_COMBINED_SEARCH_QUERY, { + issueQ, prInvQ, prRevQ, + issueCursor: null, prInvCursor: null, prRevCursor: null, + }); + } catch (err) { + const partial = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") + ? err.data as Partial + : null; + if (partial && (partial.issues || partial.prInvolves || partial.prReviewReq)) { + response = { + issues: partial.issues ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: partial.prInvolves ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prReviewReq: partial.prReviewReq ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: partial.rateLimit, + }; + isPartial = true; + const { message } = extractRejectionError(err); + errors.push({ repo: errorLabel, statusCode: null, message, retryable: true }); + } else { + throw err; + } + } + + if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); + + for (const node of response.issues.nodes) { + if (issues.length >= issueCap) break; + if (!node) continue; + processIssueNode(node, issueSeen, issues); + } + for (const node of response.prInvolves.nodes) { + if (prMap.size >= prCap) break; + if (!node) continue; + processLightPRNode(node, prMap, nodeIdMap); + } + for (const node of response.prReviewReq.nodes) { + if (prMap.size >= prCap) break; + if (!node) continue; + processLightPRNode(node, prMap, nodeIdMap); + } + + if (isPartial) return; + + if (response.issues.pageInfo.hasNextPage && response.issues.pageInfo.endCursor && issues.length < issueCap) { + await paginateGraphQLSearch( + octokit, ISSUES_SEARCH_QUERY, issueQ, errorLabel, errors, + (node) => processIssueNode(node, issueSeen, issues), + () => issues.length, issueCap, response.issues.pageInfo.endCursor, + ); + } + + const prPaginationTasks: Promise[] = []; + if (response.prInvolves.pageInfo.hasNextPage && response.prInvolves.pageInfo.endCursor && prMap.size < prCap) { + prPaginationTasks.push(paginateGraphQLSearch( + octokit, LIGHT_PR_SEARCH_QUERY, prInvQ, errorLabel, errors, + (node) => processLightPRNode(node, prMap, nodeIdMap), + () => prMap.size, prCap, response.prInvolves.pageInfo.endCursor, + )); + } + if (response.prReviewReq.pageInfo.hasNextPage && response.prReviewReq.pageInfo.endCursor && prMap.size < prCap) { + prPaginationTasks.push(paginateGraphQLSearch( + octokit, LIGHT_PR_SEARCH_QUERY, prRevQ, errorLabel, errors, + (node) => processLightPRNode(node, prMap, nodeIdMap), + () => prMap.size, prCap, response.prReviewReq.pageInfo.endCursor, + )); + } + if (prPaginationTasks.length > 0) { + await Promise.allSettled(prPaginationTasks); + } +} + /** * Phase 1: light combined search. Fetches issues fully and PRs with minimal fields. * Returns light PRs (enriched: false) and their GraphQL node IDs for phase 2 backfill. @@ -939,103 +1036,24 @@ async function graphqlLightCombinedSearch( const ISSUE_CAP = 1000; const PR_CAP = 1000; - const chunkResults = await Promise.allSettled(chunks.map(async (chunk, chunkIdx) => { + await Promise.allSettled(chunks.map(async (chunk, chunkIdx) => { const repoQualifiers = buildRepoQualifiers(chunk); const issueQ = `is:issue is:open involves:${userLogin} ${repoQualifiers}`; const prInvQ = `is:pr is:open involves:${userLogin} ${repoQualifiers}`; const prRevQ = `is:pr is:open review-requested:${userLogin} ${repoQualifiers}`; const batchLabel = `light-batch-${chunkIdx + 1}/${chunks.length}`; - let response: LightCombinedSearchResponse; - let isPartial = false; try { - try { - response = await octokit.graphql(LIGHT_COMBINED_SEARCH_QUERY, { - issueQ, prInvQ, prRevQ, - issueCursor: null, prInvCursor: null, prRevCursor: null, - }); - } catch (err) { - const partial = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") - ? err.data as Partial - : null; - if (partial && (partial.issues || partial.prInvolves || partial.prReviewReq)) { - response = { - issues: partial.issues ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - prInvolves: partial.prInvolves ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - prReviewReq: partial.prReviewReq ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - rateLimit: partial.rateLimit, - }; - isPartial = true; - const { message } = extractRejectionError(err); - errors.push({ repo: batchLabel, statusCode: null, message, retryable: true }); - } else { - throw err; - } - } - - if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); - - for (const node of response.issues.nodes) { - if (issues.length >= ISSUE_CAP) break; - if (!node) continue; - processIssueNode(node, issueSeen, issues); - } - - for (const node of response.prInvolves.nodes) { - if (prMap.size >= PR_CAP) break; - if (!node) continue; - processLightPRNode(node, prMap, nodeIdMap); - } - for (const node of response.prReviewReq.nodes) { - if (prMap.size >= PR_CAP) break; - if (!node) continue; - processLightPRNode(node, prMap, nodeIdMap); - } - - if (isPartial) return; - - // Pagination follow-ups for issues (PRs don't paginate in light mode — - // backfill handles the full set via node IDs) - if (response.issues.pageInfo.hasNextPage && response.issues.pageInfo.endCursor && issues.length < ISSUE_CAP) { - await paginateGraphQLSearch( - octokit, ISSUES_SEARCH_QUERY, issueQ, batchLabel, errors, - (node) => processIssueNode(node, issueSeen, issues), - () => issues.length, ISSUE_CAP, response.issues.pageInfo.endCursor, - ); - } - - // Light PR pagination: re-fetch with light query for additional pages - const prPaginationTasks: Promise[] = []; - if (response.prInvolves.pageInfo.hasNextPage && response.prInvolves.pageInfo.endCursor && prMap.size < PR_CAP) { - prPaginationTasks.push(paginateGraphQLSearch( - octokit, LIGHT_PR_SEARCH_QUERY, prInvQ, batchLabel, errors, - (node) => processLightPRNode(node, prMap, nodeIdMap), - () => prMap.size, PR_CAP, response.prInvolves.pageInfo.endCursor, - )); - } - if (response.prReviewReq.pageInfo.hasNextPage && response.prReviewReq.pageInfo.endCursor && prMap.size < PR_CAP) { - prPaginationTasks.push(paginateGraphQLSearch( - octokit, LIGHT_PR_SEARCH_QUERY, prRevQ, batchLabel, errors, - (node) => processLightPRNode(node, prMap, nodeIdMap), - () => prMap.size, PR_CAP, response.prReviewReq.pageInfo.endCursor, - )); - } - if (prPaginationTasks.length > 0) { - await Promise.allSettled(prPaginationTasks); - } + await executeLightCombinedQuery( + octokit, issueQ, prInvQ, prRevQ, batchLabel, + issueSeen, issues, prMap, nodeIdMap, errors, ISSUE_CAP, PR_CAP, + ); } catch (err) { const { statusCode, message } = extractRejectionError(err); errors.push({ repo: batchLabel, statusCode, message, retryable: statusCode === null || statusCode >= 500 }); } })); - for (const result of chunkResults) { - if (result.status === "rejected") { - const { statusCode, message } = extractRejectionError(result.reason); - errors.push({ repo: "light-batch", statusCode, message, retryable: statusCode === null || statusCode >= 500 }); - } - } - if (issues.length >= ISSUE_CAP) { console.warn(`[api] Issue search results capped at ${ISSUE_CAP}`); pushNotification("search/issues", `Issue search results capped at 1,000 — some items are hidden`, "warning"); @@ -1182,57 +1200,172 @@ function mergeEnrichment( }); } +/** + * Merges tracked user search results into the main issue/PR maps. + * Items already present get the tracked user's login appended to surfacedBy. + * New items are added with surfacedBy: [trackedLogin]. + * Returns a union of all prNodeIds for backfill. + */ +function mergeTrackedUserResults( + issueMap: Map, + prMap: Map, + nodeIdMap: Map, + trackedResult: LightSearchResult, + trackedLogin: string +): void { + const login = trackedLogin.toLowerCase(); + + for (const issue of trackedResult.issues) { + const existing = issueMap.get(issue.id); + if (existing) { + if (!existing.surfacedBy?.includes(login)) { + existing.surfacedBy = [...(existing.surfacedBy ?? []), login]; + } + } else { + issueMap.set(issue.id, { ...issue, surfacedBy: [login] }); + } + } + + for (const pr of trackedResult.pullRequests) { + const existing = prMap.get(pr.id); + if (existing) { + if (!existing.surfacedBy?.includes(login)) { + existing.surfacedBy = [...(existing.surfacedBy ?? []), login]; + } + } else { + prMap.set(pr.id, { ...pr, surfacedBy: [login] }); + // Register node ID for backfill if this PR is new + if (pr.nodeId) nodeIdMap.set(pr.id, pr.nodeId); + } + } +} + /** * Fetches open issues and PRs using a two-phase approach: * - Phase 1 (light): minimal fields for immediate rendering * - Phase 2 (heavy): enrichment via nodes(ids:[]) backfill * * If onLightData is provided, it fires after phase 1 with light data - * so the UI can render immediately. The returned promise resolves - * with fully enriched data after phase 2 completes. + * (including surfacedBy annotations) so the UI can render immediately. + * The returned promise resolves with fully enriched data after phase 2. + * + * If trackedUsers is provided, their global searches run in parallel after + * phase 1 completes. Results are merged by databaseId with surfacedBy tracking. */ export async function fetchIssuesAndPullRequests( octokit: ReturnType, repos: RepoRef[], userLogin: string, onLightData?: (data: FetchIssuesAndPRsResult) => void, + trackedUsers?: TrackedUser[], ): Promise { if (!octokit) throw new Error("No GitHub client available"); - if (repos.length === 0 || !userLogin) return { issues: [], pullRequests: [], errors: [] }; - // Phase 1: light combined search - const lightResult = await graphqlLightCombinedSearch(octokit, repos, userLogin); + const hasTrackedUsers = (trackedUsers?.length ?? 0) > 0; + + // Early exit — tracked user searches are scoped to repos, so no repos = no results + if (repos.length === 0) { + return { issues: [], pullRequests: [], errors: [] }; + } + + const normalizedLogin = userLogin.toLowerCase(); + const allErrors: ApiError[] = []; + + // Working maps for merging results + const issueMap = new Map(); + const prMap = new Map(); + const nodeIdMap = new Map(); + + // Phase 1: main user light search (skipped when repos empty) + if (repos.length > 0 && userLogin) { + const lightResult = await graphqlLightCombinedSearch(octokit, repos, userLogin); + allErrors.push(...lightResult.errors); - // Notify caller with light data for immediate rendering - if (onLightData) { + // Annotate main user's items with surfacedBy BEFORE firing onLightData + for (const issue of lightResult.issues) { + issueMap.set(issue.id, { ...issue, surfacedBy: [normalizedLogin] }); + } + for (const pr of lightResult.pullRequests) { + prMap.set(pr.id, { ...pr, surfacedBy: [normalizedLogin] }); + if (pr.nodeId) nodeIdMap.set(pr.id, pr.nodeId); + } + } + + // Fire onLightData with annotated main user data (tracked user results come later) + if (onLightData && (issueMap.size > 0 || prMap.size > 0)) { onLightData({ - issues: lightResult.issues, - pullRequests: lightResult.pullRequests, - errors: lightResult.errors, + issues: [...issueMap.values()], + pullRequests: [...prMap.values()], + errors: allErrors, }); } - // Phase 2: heavy backfill - if (lightResult.prNodeIds.length === 0) { - return { - issues: lightResult.issues, - pullRequests: lightResult.pullRequests.map(pr => ({ ...pr, enriched: true })), - errors: lightResult.errors, - }; - } + // Main user node IDs known — start backfill in parallel with tracked user searches. + // This delivers enriched main-user PRs without waiting for tracked user pagination. + const mainNodeIds = [...nodeIdMap.values()]; + const mainBackfillPromise = mainNodeIds.length > 0 + ? fetchPREnrichment(octokit, mainNodeIds) + : Promise.resolve({ enrichments: new Map(), errors: [] as ApiError[] }); + + // Tracked user searches — scoped to the same repos as the main user + const trackedSearchPromise = hasTrackedUsers && repos.length > 0 + ? Promise.allSettled(trackedUsers!.map((u) => graphqlLightCombinedSearch(octokit, repos, u.login))) + : Promise.resolve([] as PromiseSettledResult[]); + + const [mainBackfill, trackedResults] = await Promise.all([mainBackfillPromise, trackedSearchPromise]); - const { enrichments, errors: backfillErrors } = await fetchPREnrichment(octokit, lightResult.prNodeIds); + // Merge main backfill results + const backfillErrors = [...mainBackfill.errors]; const forkInfoMap = new Map(); - const enrichedPRs = mergeEnrichment(lightResult.pullRequests, enrichments, forkInfoMap); + + // Merge tracked user results and collect new (delta) node IDs + const preTrackedPrIds = new Set(prMap.keys()); + if (hasTrackedUsers) { + const settled = trackedResults as PromiseSettledResult[]; + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + const trackedLogin = trackedUsers![i].login; + if (result.status === "fulfilled") { + allErrors.push(...result.value.errors); + mergeTrackedUserResults(issueMap, prMap, nodeIdMap, result.value, trackedLogin); + } else { + const { statusCode, message } = extractRejectionError(result.reason); + allErrors.push({ repo: `tracked-user:${trackedLogin}`, statusCode, message, retryable: statusCode === null || statusCode >= 500 }); + } + } + } + + const mergedIssues = [...issueMap.values()]; + const mergedPRs = [...prMap.values()]; + + // Apply main backfill enrichments to all PRs (main user PRs get enriched, tracked-only PRs get nothing yet) + let enrichedPRs = mainBackfill.enrichments.size > 0 + ? mergeEnrichment(mergedPRs, mainBackfill.enrichments, forkInfoMap) + : mergedPRs; + + // Delta backfill: enrich only NEW PRs from tracked users (not already in main user's set) + const deltaNodeIds: string[] = []; + for (const pr of mergedPRs) { + if (!preTrackedPrIds.has(pr.id)) { + const nodeId = nodeIdMap.get(pr.id); + if (nodeId) deltaNodeIds.push(nodeId); + } + } + + if (deltaNodeIds.length > 0) { + const delta = await fetchPREnrichment(octokit, deltaNodeIds); + backfillErrors.push(...delta.errors); + enrichedPRs = mergeEnrichment(enrichedPRs, delta.enrichments, forkInfoMap); + } // Fork PR fallback for enriched PRs - const prMap = new Map(enrichedPRs.map(pr => [pr.id, pr])); - await runForkPRFallback(octokit, prMap, forkInfoMap); + const enrichedPRMap = new Map(enrichedPRs.map(pr => [pr.id, pr])); + await runForkPRFallback(octokit, enrichedPRMap, forkInfoMap); return { - issues: lightResult.issues, - pullRequests: [...prMap.values()], - errors: [...lightResult.errors, ...backfillErrors], + issues: mergedIssues, + pullRequests: [...enrichedPRMap.values()], + errors: [...allErrors, ...backfillErrors], }; } @@ -1844,3 +1977,125 @@ export async function fetchWorkflowRunById( completedAt: run.completed_at ?? null, }; } + +// ── User validation + upstream repo discovery ───────────────────────────────── + +const AVATAR_CDN_PREFIX = "https://avatars.githubusercontent.com/"; +const AVATAR_FALLBACK = `${AVATAR_CDN_PREFIX}u/0`; + +interface RawGitHubUser { + login: string; + avatar_url: string; + name: string | null; +} + +/** + * Validates a GitHub user login and returns their profile data. + * Uses a strict login regex to prevent injection into GraphQL query strings. + * Returns null if the login is invalid or the user does not exist (404). + * Throws on network or server errors. + */ +export async function validateGitHubUser( + octokit: GitHubOctokit, + login: string +): Promise { + if (!VALID_TRACKED_LOGIN.test(login)) return null; + + let response: { data: RawGitHubUser }; + try { + response = await octokit.request("GET /users/{username}", { username: login }) as { data: RawGitHubUser }; + } catch (err) { + const status = + typeof err === "object" && err !== null && "status" in err + ? (err as { status: number }).status + : null; + if (status === 404) return null; + throw err; + } + + const raw = response.data; + const avatarUrl = raw.avatar_url.startsWith(AVATAR_CDN_PREFIX) + ? raw.avatar_url + : AVATAR_FALLBACK; + + return { + login: raw.login.toLowerCase(), + avatarUrl, + name: raw.name ?? null, + }; +} + +/** + * Discovers repos the user participates in but doesn't own, via unscoped + * GraphQL search (no repo: qualifiers). Returns up to 100 repos sorted + * alphabetically, excluding any in the provided excludeRepos set. + * When trackedUsers is provided, also discovers repos those users participate in. + */ +export async function discoverUpstreamRepos( + octokit: GitHubOctokit, + userLogin: string, + excludeRepos: Set, + trackedUsers?: TrackedUser[] +): Promise { + const repoNames = new Set(); + const errors: ApiError[] = []; + const CAP = 100; + + function extractRepoName(node: { repository?: { nameWithOwner: string } | null }): boolean { + const name = node.repository?.nameWithOwner; + if (!name) return false; + if (excludeRepos.has(name)) return false; + repoNames.add(name); + return true; + } + + function discoverForUser(login: string) { + const issueQ = `is:issue is:open involves:${login}`; + const prQ = `is:pr is:open involves:${login}`; + return Promise.allSettled([ + paginateGraphQLSearch( + octokit, ISSUES_SEARCH_QUERY, issueQ, `upstream-issues:${login}`, errors, + (node) => extractRepoName(node), + () => repoNames.size, CAP, + ), + paginateGraphQLSearch( + octokit, LIGHT_PR_SEARCH_QUERY, prQ, `upstream-prs:${login}`, errors, + (node) => extractRepoName(node), + () => repoNames.size, CAP, + ), + ]); + } + + // Collect all valid logins to discover for + const logins: string[] = []; + if (VALID_TRACKED_LOGIN.test(userLogin)) logins.push(userLogin); + for (const u of trackedUsers ?? []) { + if (VALID_TRACKED_LOGIN.test(u.login)) logins.push(u.login); + } + if (logins.length === 0) return []; + + await Promise.allSettled(logins.map((login) => discoverForUser(login))); + + if (errors.length > 0) { + pushNotification( + "upstream-discovery", + `Upstream repo discovery partial failure — some repositories may be missing`, + "warning" + ); + } + + const repos: RepoRef[] = []; + for (const fullName of repoNames) { + const slash = fullName.indexOf("/"); + if (slash === -1) continue; + repos.push({ + owner: fullName.slice(0, slash), + name: fullName.slice(slash + 1), + fullName, + }); + } + + repos.sort((a, b) => a.fullName.localeCompare(b.fullName)); + if (repos.length > CAP) repos.splice(CAP); + return repos; +} diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 11535306..42a1355b 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onCleanup } from "solid-js"; +import { createSignal, createEffect, createRoot, untrack, onCleanup } from "solid-js"; import { getClient } from "./github"; import { config } from "../stores/config"; import { user, onAuthCleared } from "../stores/auth"; @@ -87,6 +87,24 @@ export function resetPollState(): void { // Auto-reset poll state on logout (avoids circular dep with auth.ts) onAuthCleared(resetPollState); +// When tracked users change, reset notification state so the next poll cycle +// silently seeds all items (including the new tracked user's) without flooding +// the user with "new item" notifications for pre-existing content. +// Use a flag to skip the initial run (module-level mount). +// Wrapped in createRoot to provide a reactive owner at module scope (per SolidJS gotcha). +// Subscribes to array length only — fires on add/remove, not property mutations. +let _trackedUsersMounted = false; +createRoot(() => { + createEffect(() => { + void (config.trackedUsers?.length ?? 0); + if (!_trackedUsersMounted) { + _trackedUsersMounted = true; + return; + } + untrack(() => _resetNotificationState()); + }); +}); + /** * Checks if anything changed since last poll using the Notifications API. * Returns true if there are new notifications (or first check), false if unchanged. @@ -182,14 +200,27 @@ export async function fetchAllData( // If staleness >= MAX_GATE_STALENESS_MS, skip the gate and force a full fetch } - const repos = config.selectedRepos; const userLogin = user()?.login ?? ""; + // Combine selectedRepos + upstreamRepos for issues/PRs, dedup by fullName. + // Upstream repos are excluded from workflow runs (Actions not supported there). + const selectedRepos = config.selectedRepos; + const upstreamRepos = config.upstreamRepos ?? []; + const seenFullNames = new Set(selectedRepos.map((r) => r.fullName)); + const combinedRepos = [...selectedRepos]; + for (const repo of upstreamRepos) { + if (!seenFullNames.has(repo.fullName)) { + combinedRepos.push(repo); + } + } + + const trackedUsers = config.trackedUsers ?? []; + // Issues + PRs use a two-phase approach: light query first (phase 1), // then heavy backfill (phase 2). Workflow runs use REST core. // All streams run in parallel (GraphQL 5000 pts/hr + REST core 5000/hr). const [issuesAndPrsResult, runResult] = await Promise.allSettled([ - fetchIssuesAndPullRequests(octokit, repos, userLogin, onLightData ? (lightData) => { + fetchIssuesAndPullRequests(octokit, combinedRepos, userLogin, onLightData ? (lightData) => { // Phase 1: fire callback with light issues + PRs (no workflow runs yet) onLightData({ issues: lightData.issues, @@ -197,8 +228,8 @@ export async function fetchAllData( workflowRuns: [], errors: lightData.errors, }); - } : undefined), - fetchWorkflowRuns(octokit, repos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow), + } : undefined, trackedUsers), + fetchWorkflowRuns(octokit, selectedRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow), ]); // Collect top-level errors (total function failures) diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index 21c5b36d..c43b4432 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -17,17 +17,28 @@ export function resolveTheme(theme: ThemeId): string { return prefersDark ? AUTO_DARK_THEME : AUTO_LIGHT_THEME; } +export const RepoRefSchema = z.object({ + owner: z.string(), + name: z.string(), + fullName: z.string(), +}); + +export const TrackedUserSchema = z.object({ + login: z.string(), + avatarUrl: z.string().url().refine( + (u) => u.startsWith("https://avatars.githubusercontent.com/"), + "Avatar URL must be from GitHub CDN" + ), + name: z.string().nullable(), +}); + +export type TrackedUser = z.infer; + export const ConfigSchema = z.object({ selectedOrgs: z.array(z.string()).default([]), - selectedRepos: z - .array( - z.object({ - owner: z.string(), - name: z.string(), - fullName: z.string(), - }) - ) - .default([]), + selectedRepos: z.array(RepoRefSchema).default([]), + upstreamRepos: z.array(RepoRefSchema).default([]), + trackedUsers: z.array(TrackedUserSchema).max(10).default([]), refreshInterval: z.number().min(0).max(3600).default(300), hotPollInterval: z.number().min(10).max(120).default(30), maxWorkflowsPerRepo: z.number().min(1).max(20).default(5), diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index d12ea490..048b6af1 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -7,6 +7,7 @@ export const VIEW_STORAGE_KEY = "github-tracker:view"; const IssueFiltersSchema = z.object({ role: z.enum(["all", "author", "assignee"]).default("all"), comments: z.enum(["all", "has", "none"]).default("all"), + user: z.enum(["all"]).or(z.string()).default("all"), }); const PullRequestFiltersSchema = z.object({ @@ -15,6 +16,7 @@ const PullRequestFiltersSchema = z.object({ draft: z.enum(["all", "draft", "ready"]).default("all"), checkStatus: z.enum(["all", "success", "failure", "pending", "conflict", "none"]).default("all"), sizeCategory: z.enum(["all", "XS", "S", "M", "L", "XL"]).default("all"), + user: z.enum(["all"]).or(z.string()).default("all"), }); const ActionsFiltersSchema = z.object({ @@ -60,12 +62,12 @@ export const ViewStateSchema = z.object({ }) .default({ org: null, repo: null }), tabFilters: z.object({ - issues: IssueFiltersSchema.default({ role: "all", comments: "all" }), - pullRequests: PullRequestFiltersSchema.default({ role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all" }), + issues: IssueFiltersSchema.default({ role: "all", comments: "all", user: "all" }), + pullRequests: PullRequestFiltersSchema.default({ role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }), actions: ActionsFiltersSchema.default({ conclusion: "all", event: "all" }), }).default({ - issues: { role: "all", comments: "all" }, - pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all" }, + issues: { role: "all", comments: "all", user: "all" }, + pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, }), showPrRuns: z.boolean().default(false), @@ -108,8 +110,8 @@ export function resetViewState(): void { ignoredItems: [], globalFilter: { org: null, repo: null }, tabFilters: { - issues: { role: "all", comments: "all" }, - pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all" }, + issues: { role: "all", comments: "all", user: "all" }, + pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, }, showPrRuns: false, diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx new file mode 100644 index 00000000..d88a3f94 --- /dev/null +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { makeWorkflowRun } from "../../helpers/index"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: () => true, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import ActionsTab from "../../../src/app/components/dashboard/ActionsTab"; +import { resetViewState } from "../../../src/app/stores/view"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + resetViewState(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ActionsTab — upstream exclusion note", () => { + it("does not show upstream note when hasUpstreamRepos is false", () => { + render(() => ( + + )); + expect(screen.queryByText(/workflow runs are not tracked for upstream/i)).toBeNull(); + }); + + it("does not show upstream note when hasUpstreamRepos is undefined", () => { + render(() => ( + + )); + expect(screen.queryByText(/workflow runs are not tracked for upstream/i)).toBeNull(); + }); + + it("shows upstream note when hasUpstreamRepos is true", () => { + render(() => ( + + )); + screen.getByText(/workflow runs are not tracked for upstream/i); + }); + + it("shows upstream note alongside workflow run data when hasUpstreamRepos is true", () => { + const runs = [makeWorkflowRun({ repoFullName: "owner/repo" })]; + render(() => ( + + )); + screen.getByText(/workflow runs are not tracked for upstream/i); + }); +}); diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx new file mode 100644 index 00000000..97564cd1 --- /dev/null +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { makeIssue } from "../../helpers/index"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: () => true, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; +import { setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import type { TrackedUser } from "../../../src/app/stores/config"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + resetViewState(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("IssuesTab — user filter chip", () => { + it("does not show User filter chip when allUsers has only 1 entry (no tracked users)", () => { + render(() => ( + + )); + // FilterChips renders "User:" label — absent when only 1 user + expect(screen.queryByText("User:")).toBeNull(); + }); + + it("shows User filter chip when allUsers has > 1 entry", () => { + render(() => ( + + )); + screen.getByText("User:"); + }); + + it("does not show User filter chip when allUsers is undefined", () => { + render(() => ( + + )); + expect(screen.queryByText("User:")).toBeNull(); + }); +}); + +describe("IssuesTab — user filter logic", () => { + it("shows all issues when user filter is 'all'", () => { + // Use distinct repos so expand state can be set per repo + const issues = [ + makeIssue({ id: 1, title: "Main issue", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), + makeIssue({ id: 2, title: "Tracked issue", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), + ]; + setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true); + + render(() => ( + + )); + + screen.getByText("Main issue"); + screen.getByText("Tracked issue"); + }); + + it("filters issues to only the tracked user's items when user filter is set", () => { + const issues = [ + makeIssue({ id: 1, title: "Main issue", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), + makeIssue({ id: 2, title: "Tracked issue", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), + ]; + + setTabFilter("issues", "user", "tracked1"); + setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true); + + render(() => ( + + )); + + expect(screen.queryByText("Main issue")).toBeNull(); + screen.getByText("Tracked issue"); + }); + + it("uses userLogin as fallback surfacedBy for items with undefined surfacedBy", () => { + const issues = [ + makeIssue({ id: 1, title: "Legacy issue", repoFullName: "owner/repo", surfacedBy: undefined }), + ]; + + // Filter to "me" — legacy items without surfacedBy should show as belonging to the main user + setTabFilter("issues", "user", "me"); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + screen.getByText("Legacy issue"); + }); + + it("hides legacy issues (no surfacedBy) when filtered to tracked user", () => { + const issues = [ + makeIssue({ id: 1, title: "Legacy issue", repoFullName: "owner/repo", surfacedBy: undefined }), + ]; + + setTabFilter("issues", "user", "tracked1"); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.queryByText("Legacy issue")).toBeNull(); + }); + + it("shows all items when user filter references a removed tracked user (stale filter)", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "owner/repo", surfacedBy: ["me"] }), + ]; + + // Set filter to a user that no longer exists in allUsers + setTabFilter("issues", "user", "removed-user"); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + // Stale filter value should be ignored — items still visible + screen.getByText("My issue"); + }); +}); + +describe("IssuesTab — avatar badge", () => { + it("renders avatar img for items surfaced by tracked users", () => { + const trackedUsers: TrackedUser[] = [ + { login: "tracked1", avatarUrl: "https://avatars.githubusercontent.com/u/1", name: "Tracked One" }, + ]; + const issues = [ + makeIssue({ id: 1, title: "Tracked issue", repoFullName: "owner/repo", surfacedBy: ["tracked1"] }), + ]; + + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + // Avatar img should be present (tracked1 is not the current user "me") + const img = screen.getByAltText("tracked1"); + expect(img.getAttribute("src")).toBe("https://avatars.githubusercontent.com/u/1"); + }); + + it("does not render avatar badge when trackedUsers is empty", () => { + const issues = [ + makeIssue({ id: 1, title: "My issue", repoFullName: "owner/repo", surfacedBy: ["me"] }), + ]; + + setAllExpanded("issues", ["owner/repo"], true); + + const { container } = render(() => ( + + )); + + expect(container.querySelector(".avatar")).toBeNull(); + }); +}); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx new file mode 100644 index 00000000..674c26dd --- /dev/null +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { makePullRequest } from "../../helpers/index"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: () => true, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; +import { setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import type { TrackedUser } from "../../../src/app/stores/config"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + resetViewState(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("PullRequestsTab — user filter chip", () => { + it("does not show User filter chip when allUsers has only 1 entry", () => { + render(() => ( + + )); + expect(screen.queryByText("User:")).toBeNull(); + }); + + it("shows User filter chip when allUsers has > 1 entry", () => { + render(() => ( + + )); + screen.getByText("User:"); + }); +}); + +describe("PullRequestsTab — user filter logic", () => { + it("shows all PRs when user filter is 'all'", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), + makePullRequest({ id: 2, title: "Tracked PR", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), + ]; + setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true); + + render(() => ( + + )); + + screen.getByText("My PR"); + screen.getByText("Tracked PR"); + }); + + it("filters PRs to only the tracked user's items when user filter is set", () => { + const prs = [ + makePullRequest({ id: 1, title: "My PR", repoFullName: "owner/repo-a", surfacedBy: ["me"] }), + makePullRequest({ id: 2, title: "Tracked PR", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }), + ]; + + setTabFilter("pullRequests", "user", "tracked1"); + setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true); + + render(() => ( + + )); + + expect(screen.queryByText("My PR")).toBeNull(); + screen.getByText("Tracked PR"); + }); + + it("uses userLogin as fallback surfacedBy for items with undefined surfacedBy", () => { + const prs = [ + makePullRequest({ id: 1, title: "Legacy PR", repoFullName: "owner/repo", surfacedBy: undefined }), + ]; + + setTabFilter("pullRequests", "user", "me"); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + screen.getByText("Legacy PR"); + }); + + it("hides legacy PRs (no surfacedBy) when filtered to tracked user", () => { + const prs = [ + makePullRequest({ id: 1, title: "Legacy PR", repoFullName: "owner/repo", surfacedBy: undefined }), + ]; + + setTabFilter("pullRequests", "user", "tracked1"); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.queryByText("Legacy PR")).toBeNull(); + }); +}); + +describe("PullRequestsTab — avatar badge", () => { + it("renders avatar img for PRs surfaced by tracked users", () => { + const trackedUsers: TrackedUser[] = [ + { login: "tracked1", avatarUrl: "https://avatars.githubusercontent.com/u/1", name: "Tracked One" }, + ]; + const prs = [ + makePullRequest({ id: 1, title: "Tracked PR", repoFullName: "owner/repo", surfacedBy: ["tracked1"] }), + ]; + + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + const img = screen.getByAltText("tracked1"); + expect(img.getAttribute("src")).toBe("https://avatars.githubusercontent.com/u/1"); + }); +}); diff --git a/tests/components/onboarding/OnboardingWizard.test.tsx b/tests/components/onboarding/OnboardingWizard.test.tsx index 3264a6ba..639541e7 100644 --- a/tests/components/onboarding/OnboardingWizard.test.tsx +++ b/tests/components/onboarding/OnboardingWizard.test.tsx @@ -47,7 +47,7 @@ vi.mock("../../../src/app/components/shared/LoadingSpinner", () => ({ // Mock config store vi.mock("../../../src/app/stores/config", () => ({ CONFIG_STORAGE_KEY: "github-tracker:config", - config: { selectedOrgs: [], selectedRepos: [] }, + config: { selectedOrgs: [], selectedRepos: [], upstreamRepos: [] }, updateConfig: vi.fn(), })); diff --git a/tests/components/onboarding/RepoSelector.test.tsx b/tests/components/onboarding/RepoSelector.test.tsx index 2bc6e723..9544e1fb 100644 --- a/tests/components/onboarding/RepoSelector.test.tsx +++ b/tests/components/onboarding/RepoSelector.test.tsx @@ -4,8 +4,14 @@ import userEvent from "@testing-library/user-event"; import type { RepoRef, RepoEntry } from "../../../src/app/services/api"; // Mock getClient before importing component +const mockRequest = vi.fn().mockResolvedValue({ data: {} }); vi.mock("../../../src/app/services/github", () => ({ - getClient: () => ({}), + getClient: () => ({ request: mockRequest }), +})); + +vi.mock("../../../src/app/stores/auth", () => ({ + user: () => ({ login: "testuser", name: "Test User", avatar_url: "" }), + token: () => "fake-token", })); // Mock api module functions @@ -20,6 +26,7 @@ vi.mock("../../../src/app/services/api", async (importOriginal) => { { login: "active-org", avatarUrl: "", type: "org" }, ]), fetchRepos: vi.fn(), + discoverUpstreamRepos: vi.fn().mockResolvedValue([]), }; }); @@ -227,27 +234,27 @@ describe("RepoSelector", () => { }); }); - it("sorts org groups by most recent activity", async () => { - const staleRepos: RepoEntry[] = [ - { owner: "stale-org", name: "old-repo", fullName: "stale-org/old-repo", pushedAt: "2025-01-01T00:00:00Z" }, - ]; - const activeRepos: RepoEntry[] = [ - { owner: "active-org", name: "new-repo", fullName: "active-org/new-repo", pushedAt: "2026-03-23T00:00:00Z" }, - ]; + it("shows personal org first, then remaining orgs alphabetically", async () => { + vi.mocked(api.fetchOrgs).mockResolvedValue([ + { login: "zebra-org", avatarUrl: "", type: "org" }, + { login: "testuser", avatarUrl: "", type: "user" }, + { login: "alpha-org", avatarUrl: "", type: "org" }, + ]); vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { - if (org === "stale-org") return Promise.resolve(staleRepos); - return Promise.resolve(activeRepos); + return Promise.resolve([ + { owner: org as string, name: "repo", fullName: `${org}/repo`, pushedAt: "2026-03-20T00:00:00Z" }, + ]); }); render(() => ( - + )); await waitFor(() => { - screen.getByText("old-repo"); - screen.getByText("new-repo"); + expect(screen.getAllByText("repo").length).toBe(3); }); - const orgHeaders = screen.getAllByText(/^(active-org|stale-org)$/); - expect(orgHeaders[0].textContent).toBe("active-org"); - expect(orgHeaders[1].textContent).toBe("stale-org"); + const orgHeaders = screen.getAllByText(/^(testuser|alpha-org|zebra-org)$/); + expect(orgHeaders[0].textContent).toBe("testuser"); + expect(orgHeaders[1].textContent).toBe("alpha-org"); + expect(orgHeaders[2].textContent).toBe("zebra-org"); }); it("does not show timestamp for repos with null pushedAt", async () => { @@ -289,28 +296,32 @@ describe("RepoSelector", () => { } }); - it("preserves org order when all repos have null pushedAt", async () => { - const nullOrg1: RepoEntry[] = [ - { owner: "stale-org", name: "null-repo-1", fullName: "stale-org/null-repo-1", pushedAt: null }, + it("sorts orgs alphabetically regardless of repo activity", async () => { + vi.mocked(api.fetchOrgs).mockResolvedValue([ + { login: "stale-org", avatarUrl: "", type: "org" }, + { login: "active-org", avatarUrl: "", type: "org" }, + ]); + const staleRepos: RepoEntry[] = [ + { owner: "stale-org", name: "old-repo", fullName: "stale-org/old-repo", pushedAt: "2025-01-01T00:00:00Z" }, ]; - const nullOrg2: RepoEntry[] = [ - { owner: "active-org", name: "null-repo-2", fullName: "active-org/null-repo-2", pushedAt: null }, + const activeRepos: RepoEntry[] = [ + { owner: "active-org", name: "new-repo", fullName: "active-org/new-repo", pushedAt: "2026-03-23T00:00:00Z" }, ]; vi.mocked(api.fetchRepos).mockImplementation((_client, org) => { - if (org === "stale-org") return Promise.resolve(nullOrg1); - return Promise.resolve(nullOrg2); + if (org === "stale-org") return Promise.resolve(staleRepos); + return Promise.resolve(activeRepos); }); render(() => ( )); await waitFor(() => { - screen.getByText("null-repo-1"); - screen.getByText("null-repo-2"); + screen.getByText("old-repo"); + screen.getByText("new-repo"); }); const orgHeaders = screen.getAllByText(/^(active-org|stale-org)$/); - // Both have null pushedAt → comparator returns 0 → original order preserved - expect(orgHeaders[0].textContent).toBe("stale-org"); - expect(orgHeaders[1].textContent).toBe("active-org"); + // Alphabetical: active-org before stale-org, regardless of pushedAt + expect(orgHeaders[0].textContent).toBe("active-org"); + expect(orgHeaders[1].textContent).toBe("stale-org"); }); it("each org group has a scrollable region with aria-label", async () => { @@ -362,3 +373,321 @@ describe("RepoSelector", () => { expect(api.fetchOrgs).not.toHaveBeenCalled(); }); }); + +describe("RepoSelector — upstream discovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.mocked(api.fetchRepos).mockResolvedValue(myorgRepos); + vi.mocked(api.discoverUpstreamRepos).mockResolvedValue([]); + mockRequest.mockReset().mockResolvedValue({ data: {} }); + }); + + it("does not call discoverUpstreamRepos when showUpstreamDiscovery is false (default)", async () => { + render(() => ( + + )); + await waitFor(() => { + screen.getByText("repo-a"); + }); + // Give time for any async effects to settle + await new Promise((r) => setTimeout(r, 50)); + expect(api.discoverUpstreamRepos).not.toHaveBeenCalled(); + }); + + it("does not render Upstream Repositories heading when showUpstreamDiscovery is false", async () => { + render(() => ( + + )); + await waitFor(() => screen.getByText("repo-a")); + expect(screen.queryByText("Upstream Repositories")).toBeNull(); + }); + + it("renders Upstream Repositories heading when showUpstreamDiscovery is true", async () => { + render(() => ( + + )); + await waitFor(() => { + screen.getByText("Upstream Repositories"); + }); + }); + + it("calls discoverUpstreamRepos after org repos load when showUpstreamDiscovery is true", async () => { + render(() => ( + + )); + await waitFor(() => { + expect(api.discoverUpstreamRepos).toHaveBeenCalledOnce(); + }); + expect(api.discoverUpstreamRepos).toHaveBeenCalledWith( + expect.anything(), + "testuser", + expect.any(Set), + undefined + ); + }); + + it("shows discovered repos as checkboxes", async () => { + const discovered: RepoRef[] = [ + { owner: "upstream-owner", name: "upstream-repo", fullName: "upstream-owner/upstream-repo" }, + ]; + vi.mocked(api.discoverUpstreamRepos).mockResolvedValue(discovered); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("upstream-owner/upstream-repo"); + }); + }); + + it("selecting a discovered repo calls onUpstreamChange", async () => { + const user = userEvent.setup(); + const discovered: RepoRef[] = [ + { owner: "upstream-owner", name: "upstream-repo", fullName: "upstream-owner/upstream-repo" }, + ]; + vi.mocked(api.discoverUpstreamRepos).mockResolvedValue(discovered); + const onUpstreamChange = vi.fn(); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("upstream-owner/upstream-repo"); + }); + + const checkboxes = screen.getAllByRole("checkbox"); + const upstreamCheckbox = checkboxes.find((cb) => { + const label = cb.closest("label"); + return label?.textContent?.includes("upstream-owner/upstream-repo"); + }); + await user.click(upstreamCheckbox!); + + expect(onUpstreamChange).toHaveBeenCalledWith([discovered[0]]); + }); + + it("discovered repos already in selectedRepos are excluded from the excludeSet passed to discoverUpstreamRepos", async () => { + const selected: RepoRef[] = [ + { owner: "myorg", name: "repo-a", fullName: "myorg/repo-a" }, + ]; + + render(() => ( + + )); + + await waitFor(() => { + expect(api.discoverUpstreamRepos).toHaveBeenCalled(); + }); + + const excludeSet = vi.mocked(api.discoverUpstreamRepos).mock.calls[0][2] as Set; + expect(excludeSet.has("myorg/repo-a")).toBe(true); + }); + + it("shows workflow runs note text", async () => { + render(() => ( + + )); + await waitFor(() => { + screen.getByText(/workflow runs are not/i); + }); + }); + + it("manual entry: typing owner/repo and clicking Add calls onUpstreamChange", async () => { + const user = userEvent.setup(); + const onUpstreamChange = vi.fn(); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("Upstream Repositories"); + }); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "some-owner/some-repo"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + expect(onUpstreamChange).toHaveBeenCalledWith([ + { owner: "some-owner", name: "some-repo", fullName: "some-owner/some-repo" }, + ]); + }); + + it("manual entry: invalid format (no slash) shows validation error", async () => { + const user = userEvent.setup(); + + render(() => ( + + )); + + await waitFor(() => screen.getByText("Upstream Repositories")); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "noslash"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + screen.getByText(/format must be owner\/repo/i); + }); + + it("manual entry: duplicate from selectedRepos shows duplicate error", async () => { + const user = userEvent.setup(); + const selected: RepoRef[] = [ + { owner: "myorg", name: "repo-a", fullName: "myorg/repo-a" }, + ]; + + render(() => ( + + )); + + await waitFor(() => screen.getByText("Upstream Repositories")); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "myorg/repo-a"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + screen.getByText(/already in your selected/i); + }); + + it("manual entry: duplicate from upstream repos shows duplicate error", async () => { + const user = userEvent.setup(); + + render(() => ( + + )); + + await waitFor(() => screen.getByText("Upstream Repositories")); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "upstream-org/upstream-repo"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + screen.getByText(/already in upstream/i); + }); + + it("manual entry: duplicate from discovered repos shows discovered error", async () => { + const user = userEvent.setup(); + const discovered: RepoRef[] = [ + { owner: "disc-org", name: "disc-repo", fullName: "disc-org/disc-repo" }, + ]; + vi.mocked(api.discoverUpstreamRepos).mockResolvedValue(discovered); + + render(() => ( + + )); + + await waitFor(() => { + screen.getByText("disc-org/disc-repo"); + }); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "disc-org/disc-repo"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + screen.getByText(/already discovered/i); + }); + + it("manual entry: shows error when repo does not exist (404)", async () => { + const user = userEvent.setup(); + mockRequest.mockRejectedValue(Object.assign(new Error("Not Found"), { status: 404 })); + + render(() => ( + + )); + + await waitFor(() => screen.getByText("Upstream Repositories")); + + const input = screen.getByRole("textbox", { name: /add upstream repo/i }); + await user.type(input, "nonexistent-org/no-repo"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + await waitFor(() => { + screen.getByText(/repository not found/i); + }); + }); +}); diff --git a/tests/components/settings/TrackedUsersSection.test.tsx b/tests/components/settings/TrackedUsersSection.test.tsx new file mode 100644 index 00000000..1b7549ca --- /dev/null +++ b/tests/components/settings/TrackedUsersSection.test.tsx @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library"; +import userEvent from "@testing-library/user-event"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/stores/auth", () => ({ + user: () => ({ login: "currentuser", name: "Current User", avatar_url: "" }), + token: () => "fake-token", + clearAuth: vi.fn(), +})); + +vi.mock("../../../src/app/services/github", () => ({ + getClient: vi.fn(() => ({})), +})); + +vi.mock("../../../src/app/services/api", () => ({ + validateGitHubUser: vi.fn(), +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── + +import TrackedUsersSection from "../../../src/app/components/settings/TrackedUsersSection"; +import * as apiModule from "../../../src/app/services/api"; +import * as githubModule from "../../../src/app/services/github"; +import type { TrackedUser } from "../../../src/app/stores/config"; + +// ── Test fixtures ───────────────────────────────────────────────────────────── + +function makeUser(overrides: Partial = {}): TrackedUser { + return { + login: "octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/583231", + name: "The Octocat", + ...overrides, + }; +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + vi.mocked(githubModule.getClient).mockReturnValue({} as ReturnType); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("TrackedUsersSection — rendering", () => { + it("renders the add input and button", () => { + render(() => ( + + )); + screen.getByRole("textbox", { name: /github username/i }); + screen.getByRole("button", { name: /add/i }); + }); + + it("renders existing tracked users with avatars and names", () => { + const users = [ + makeUser({ login: "octocat", name: "The Octocat" }), + makeUser({ login: "torvalds", name: "Linus Torvalds", avatarUrl: "https://avatars.githubusercontent.com/u/1024025" }), + ]; + render(() => ); + + screen.getByText("The Octocat"); + screen.getByText("octocat"); + screen.getByText("Linus Torvalds"); + screen.getByText("torvalds"); + + const avatars = screen.getAllByRole("img"); + expect(avatars.length).toBeGreaterThanOrEqual(2); + }); + + it("shows login as display name when name is null", () => { + const users = [makeUser({ login: "nameless", name: null })]; + render(() => ); + // Login should be rendered as the display name (only one occurrence, no muted login below) + const elements = screen.getAllByText("nameless"); + expect(elements.length).toBe(1); + }); + + it("does not show API warning with fewer than 3 users", () => { + const users = [makeUser(), makeUser({ login: "other" })]; + render(() => ); + expect(screen.queryByText(/rate limiting/i)).toBeNull(); + }); + + it("shows API warning when 3 or more users are tracked", () => { + const users = [ + makeUser({ login: "user1" }), + makeUser({ login: "user2" }), + makeUser({ login: "user3" }), + ]; + render(() => ); + screen.getByText(/rate limiting/i); + }); + + it("shows API warning at exactly 3 users", () => { + const users = [ + makeUser({ login: "a" }), + makeUser({ login: "b" }), + makeUser({ login: "c" }), + ]; + render(() => ); + screen.getByText(/rate limiting/i); + }); +}); + +describe("TrackedUsersSection — adding a user", () => { + it("calls onSave with updated array when adding a valid user", async () => { + const onSave = vi.fn(); + const newUser = makeUser({ login: "newuser", name: "New User" }); + vi.mocked(apiModule.validateGitHubUser).mockResolvedValue(newUser); + + const user = userEvent.setup(); + render(() => ); + + const input = screen.getByRole("textbox", { name: /github username/i }); + await user.type(input, "newuser"); + await user.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + expect(onSave).toHaveBeenCalledWith([newUser]); + }); + }); + + it("normalizes login to lowercase before calling validateGitHubUser", async () => { + const onSave = vi.fn(); + const newUser = makeUser({ login: "mixedcase" }); + vi.mocked(apiModule.validateGitHubUser).mockResolvedValue(newUser); + + const user = userEvent.setup(); + render(() => ); + + const input = screen.getByRole("textbox", { name: /github username/i }); + await user.type(input, "MixedCase"); + await user.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + expect(apiModule.validateGitHubUser).toHaveBeenCalledWith(expect.anything(), "mixedcase"); + }); + }); + + it("clears input after successfully adding a user", async () => { + const onSave = vi.fn(); + vi.mocked(apiModule.validateGitHubUser).mockResolvedValue(makeUser()); + + const user = userEvent.setup(); + render(() => ); + + const input = screen.getByRole("textbox", { name: /github username/i }); + await user.type(input, "octocat"); + await user.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + expect((input as HTMLInputElement).value).toBe(""); + }); + }); + + it("shows error when user is not found (validateGitHubUser returns null)", async () => { + vi.mocked(apiModule.validateGitHubUser).mockResolvedValue(null); + + const user = userEvent.setup(); + render(() => ); + + await user.type(screen.getByRole("textbox", { name: /github username/i }), "ghost"); + await user.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + screen.getByText("User not found"); + }); + }); + + it("shows error when adding a duplicate user (case-insensitive)", async () => { + const existing = makeUser({ login: "octocat" }); + const onSave = vi.fn(); + + const user = userEvent.setup(); + render(() => ); + + await user.type(screen.getByRole("textbox", { name: /github username/i }), "OCTOCAT"); + await user.click(screen.getByRole("button", { name: /add/i })); + + screen.getByText("Already tracking this user"); + expect(onSave).not.toHaveBeenCalled(); + expect(apiModule.validateGitHubUser).not.toHaveBeenCalled(); + }); + + it("shows error when adding the current user's own login", async () => { + const onSave = vi.fn(); + + const user = userEvent.setup(); + render(() => ); + + // auth mock returns login "currentuser" + await user.type(screen.getByRole("textbox", { name: /github username/i }), "CurrentUser"); + await user.click(screen.getByRole("button", { name: /add/i })); + + screen.getByText("Your activity is already included in your dashboard"); + expect(onSave).not.toHaveBeenCalled(); + expect(apiModule.validateGitHubUser).not.toHaveBeenCalled(); + }); + + it("disables input and button while validation is in-flight", async () => { + let resolveValidation!: (v: TrackedUser | null) => void; + vi.mocked(apiModule.validateGitHubUser).mockReturnValue( + new Promise((resolve) => { resolveValidation = resolve; }) + ); + + const user = userEvent.setup(); + render(() => ); + + const input = screen.getByRole("textbox", { name: /github username/i }); + const addBtn = screen.getByRole("button", { name: /add/i }); + + await user.type(input, "slowuser"); + fireEvent.click(addBtn); + + await waitFor(() => { + expect(input.hasAttribute("disabled")).toBe(true); + expect(addBtn.hasAttribute("disabled")).toBe(true); + }); + + // Clean up + resolveValidation(null); + }); + + it("shows error when validateGitHubUser throws (network error)", async () => { + vi.mocked(apiModule.validateGitHubUser).mockRejectedValue(new Error("Network timeout")); + + const user = userEvent.setup(); + render(() => ); + + await user.type(screen.getByRole("textbox", { name: /github username/i }), "someuser"); + await user.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + screen.getByText("Validation failed — try again"); + }); + }); + + it("submits on Enter key press", async () => { + const onSave = vi.fn(); + vi.mocked(apiModule.validateGitHubUser).mockResolvedValue(makeUser()); + + const user = userEvent.setup(); + render(() => ); + + const input = screen.getByRole("textbox", { name: /github username/i }); + await user.type(input, "octocat"); + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + }); +}); + +describe("TrackedUsersSection — removing a user", () => { + it("calls onSave without the removed user when Remove is clicked", async () => { + const users = [ + makeUser({ login: "user1", name: "User One" }), + makeUser({ login: "user2", name: "User Two" }), + ]; + const onSave = vi.fn(); + + const user = userEvent.setup(); + render(() => ); + + const removeBtn = screen.getByRole("button", { name: /remove user1/i }); + await user.click(removeBtn); + + expect(onSave).toHaveBeenCalledOnce(); + expect(onSave).toHaveBeenCalledWith([users[1]]); + }); + + it("renders remove button for each tracked user", () => { + const users = [ + makeUser({ login: "user1" }), + makeUser({ login: "user2" }), + makeUser({ login: "user3" }), + ]; + render(() => ); + + screen.getByRole("button", { name: /remove user1/i }); + screen.getByRole("button", { name: /remove user2/i }); + screen.getByRole("button", { name: /remove user3/i }); + }); +}); diff --git a/tests/components/shared/UserAvatarBadge.test.tsx b/tests/components/shared/UserAvatarBadge.test.tsx new file mode 100644 index 00000000..49f408ff --- /dev/null +++ b/tests/components/shared/UserAvatarBadge.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import UserAvatarBadge from "../../../src/app/components/shared/UserAvatarBadge"; + +describe("UserAvatarBadge", () => { + it("renders nothing when users array is empty", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("img")).toBeNull(); + }); + + it("renders nothing when all users are the current user", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("img")).toBeNull(); + }); + + it("is case-insensitive when comparing logins to currentUserLogin", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("img")).toBeNull(); + }); + + it("renders avatars for tracked users (not the current user)", () => { + render(() => ( + + )); + const imgs = screen.getAllByRole("img"); + expect(imgs).toHaveLength(2); + }); + + it("filters out the current user and renders only tracked users", () => { + render(() => ( + + )); + const imgs = screen.getAllByRole("img"); + expect(imgs).toHaveLength(1); + expect(imgs[0].getAttribute("alt")).toBe("tracked1"); + }); + + it("shows tooltip with login via title attribute", () => { + render(() => ( + + )); + const img = screen.getByRole("img"); + expect(img.getAttribute("title")).toBe("octocat"); + }); + + it("uses avatarUrl as img src", () => { + const avatarUrl = "https://avatars.githubusercontent.com/u/583231"; + render(() => ( + + )); + const img = screen.getByRole("img"); + expect(img.getAttribute("src")).toBe(avatarUrl); + }); + + it("applies negative margin for stacked avatars when multiple users", () => { + const { container } = render(() => ( + + )); + // Second avatar wrapper should have a negative margin-left style + const avatarWrappers = container.querySelectorAll(".avatar"); + expect(avatarWrappers.length).toBe(2); + // First has no negative margin; second does + expect((avatarWrappers[1] as HTMLElement).style.marginLeft).toBe("-6px"); + }); +}); diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 4155266f..66430c3c 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -21,6 +21,7 @@ export function makeIssue(overrides: Partial = {}): Issue { assigneeLogins: [], repoFullName: "owner/repo", comments: 0, + surfacedBy: ["testuser"], ...overrides, }; } @@ -52,6 +53,7 @@ export function makePullRequest(overrides: Partial = {}): PullReque labels: [], reviewDecision: null, totalReviewCount: 0, + surfacedBy: ["testuser"], ...overrides, }; } diff --git a/tests/lib/emoji.test.ts b/tests/lib/emoji.test.ts new file mode 100644 index 00000000..13fcf42f --- /dev/null +++ b/tests/lib/emoji.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { expandEmoji } from "../../src/app/lib/emoji"; + +describe("expandEmoji", () => { + it("expands known shortcodes to Unicode", () => { + expect(expandEmoji(":microbe: bug")).toBe("🦠 bug"); + expect(expandEmoji(":sponge: formatter")).toBe("🧽 formatter"); + expect(expandEmoji(":rocket:")).toBe("🚀"); + }); + + it("handles multiple shortcodes in one string", () => { + expect(expandEmoji(":bug: :fire: critical")).toBe("🐛 🔥 critical"); + }); + + it("leaves unknown shortcodes as-is", () => { + expect(expandEmoji(":not_a_real_emoji:")).toBe(":not_a_real_emoji:"); + }); + + it("returns plain text unchanged", () => { + expect(expandEmoji("no emoji here")).toBe("no emoji here"); + expect(expandEmoji("")).toBe(""); + }); + + it("handles mixed known and unknown shortcodes", () => { + expect(expandEmoji(":rocket: :fakecode: go")).toBe("🚀 :fakecode: go"); + }); + + it("does not expand partial colon patterns", () => { + expect(expandEmoji("time: 3:00")).toBe("time: 3:00"); + expect(expandEmoji("key:value")).toBe("key:value"); + }); +}); diff --git a/tests/services/api-users.test.ts b/tests/services/api-users.test.ts new file mode 100644 index 00000000..70e0408c --- /dev/null +++ b/tests/services/api-users.test.ts @@ -0,0 +1,785 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { validateGitHubUser, discoverUpstreamRepos, fetchIssuesAndPullRequests } from "../../src/app/services/api"; +import type { TrackedUser } from "../../src/app/stores/config"; + +vi.mock("../../src/app/lib/errors", () => ({ + pushNotification: vi.fn(), + pushError: vi.fn(), + getErrors: vi.fn().mockReturnValue([]), + dismissError: vi.fn(), + getNotifications: vi.fn().mockReturnValue([]), + getUnreadCount: vi.fn().mockReturnValue(0), + markAllAsRead: vi.fn(), +})); + +vi.mock("../../src/app/services/github", () => ({ + getClient: vi.fn(), + cachedRequest: vi.fn(), + updateGraphqlRateLimit: vi.fn(), + updateRateLimitFromHeaders: vi.fn(), + clearCache: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeOctokit( + requestImpl: (route: string, params?: unknown) => Promise = async () => ({}), + graphqlImpl: (query: string, variables?: unknown) => Promise = async () => ({}) +) { + return { + request: vi.fn(requestImpl), + graphql: vi.fn(graphqlImpl), + paginate: { iterator: vi.fn() }, + }; +} + +function makeUserResponse(overrides: { + login?: string; + avatar_url?: string; + name?: string | null; +} = {}) { + return { + data: { + login: overrides.login ?? "octocat", + avatar_url: overrides.avatar_url ?? "https://avatars.githubusercontent.com/u/583231?v=4", + name: overrides.name !== undefined ? overrides.name : "The Octocat", + }, + }; +} + +let searchPageIdCounter = 100000; + +/** Build a minimal GraphQL search response page with the given repo names. */ +function makeSearchPage(repoNames: string[], hasNextPage = false) { + return { + search: { + issueCount: repoNames.length, + pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor-1" : null }, + nodes: repoNames.map((nameWithOwner) => ({ + databaseId: searchPageIdCounter++, + number: 1, + title: "Test", + state: "OPEN", + url: `https://github.com/${nameWithOwner}/issues/1`, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/583231" }, + labels: { nodes: [] }, + assignees: { nodes: [] }, + repository: { nameWithOwner }, + comments: { totalCount: 0 }, + // PR fields (ignored by issue processNode, but needed for shape) + isDraft: false, + headRefName: "main", + baseRefName: "main", + reviewDecision: null, + id: "PR_xxx", + headRefOid: "", + headRepository: null, + mergeStateStatus: "UNKNOWN", + reviewRequests: { nodes: [] }, + deletions: 0, + additions: 0, + changedFiles: 0, + reviewThreads: { totalCount: 0 }, + latestReviews: { totalCount: 0, nodes: [] }, + commits: { nodes: [] }, + })), + }, + rateLimit: { remaining: 4990, resetAt: "2024-01-01T01:00:00Z" }, + }; +} + +// ── validateGitHubUser ──────────────────────────────────────────────────────── + +describe("validateGitHubUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns user data on 200 response", async () => { + const octokit = makeOctokit(async () => makeUserResponse()); + const result = await validateGitHubUser(octokit as never, "octocat"); + expect(result).toEqual({ + login: "octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/583231?v=4", + name: "The Octocat", + }); + expect(octokit.request).toHaveBeenCalledWith("GET /users/{username}", { username: "octocat" }); + }); + + it("returns null for 404", async () => { + const octokit = makeOctokit(async () => { + const err = Object.assign(new Error("Not Found"), { status: 404 }); + throw err; + }); + const result = await validateGitHubUser(octokit as never, "nonexistent-user"); + expect(result).toBeNull(); + expect(octokit.request).toHaveBeenCalledOnce(); + }); + + it("returns null for invalid login without making API call", async () => { + const octokit = makeOctokit(async () => { + throw new Error("should not be called"); + }); + // Bracket chars are not in VALID_TRACKED_LOGIN + const result = await validateGitHubUser(octokit as never, "bad[user]"); + expect(result).toBeNull(); + expect(octokit.request).not.toHaveBeenCalled(); + }); + + it("returns null for login exceeding 39 chars without making API call", async () => { + const octokit = makeOctokit(async () => { + throw new Error("should not be called"); + }); + const longLogin = "a".repeat(40); + const result = await validateGitHubUser(octokit as never, longLogin); + expect(result).toBeNull(); + expect(octokit.request).not.toHaveBeenCalled(); + }); + + it("returns null for empty login without making API call", async () => { + const octokit = makeOctokit(async () => { + throw new Error("should not be called"); + }); + const result = await validateGitHubUser(octokit as never, ""); + expect(result).toBeNull(); + expect(octokit.request).not.toHaveBeenCalled(); + }); + + it("propagates network errors (non-404)", async () => { + const octokit = makeOctokit(async () => { + const err = Object.assign(new Error("Network Error"), { status: 500 }); + throw err; + }); + await expect(validateGitHubUser(octokit as never, "octocat")).rejects.toThrow("Network Error"); + }); + + it("uses avatar fallback for invalid avatar URL", async () => { + const octokit = makeOctokit(async () => + makeUserResponse({ avatar_url: "https://evil.com/avatar.png" }) + ); + const result = await validateGitHubUser(octokit as never, "octocat"); + expect(result?.avatarUrl).toBe("https://avatars.githubusercontent.com/u/0"); + }); + + it("accepts valid avatar URL from GitHub CDN", async () => { + const cdnUrl = "https://avatars.githubusercontent.com/u/12345?v=4"; + const octokit = makeOctokit(async () => makeUserResponse({ avatar_url: cdnUrl })); + const result = await validateGitHubUser(octokit as never, "octocat"); + expect(result?.avatarUrl).toBe(cdnUrl); + }); + + it("returns null name when API returns null", async () => { + const octokit = makeOctokit(async () => makeUserResponse({ name: null })); + const result = await validateGitHubUser(octokit as never, "octocat"); + expect(result?.name).toBeNull(); + }); + + it("normalizes login to lowercase", async () => { + const octokit = makeOctokit(async () => makeUserResponse({ login: "OctoCat" })); + const result = await validateGitHubUser(octokit as never, "octocat"); + expect(result?.login).toBe("octocat"); + }); +}); + +// ── discoverUpstreamRepos ───────────────────────────────────────────────────── + +describe("discoverUpstreamRepos", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchPageIdCounter = 100000; + }); + + it("returns repos found in issue and PR search results", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(["org/repo-a", "org/repo-b"]); + if (v.q.includes("is:pr")) return makeSearchPage(["org/repo-c"]); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + const names = result.map((r) => r.fullName); + expect(names).toContain("org/repo-a"); + expect(names).toContain("org/repo-b"); + expect(names).toContain("org/repo-c"); + expect(result.length).toBe(3); + }); + + it("deduplicates repos appearing in both issue and PR results", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + // Both searches return the same repo + if (v.q.includes("is:issue")) return makeSearchPage(["org/shared-repo", "org/issue-only"]); + if (v.q.includes("is:pr")) return makeSearchPage(["org/shared-repo", "org/pr-only"]); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + const names = result.map((r) => r.fullName); + // shared-repo should appear only once + expect(names.filter((n) => n === "org/shared-repo").length).toBe(1); + expect(names).toContain("org/issue-only"); + expect(names).toContain("org/pr-only"); + expect(result.length).toBe(3); + }); + + it("excludes repos in the excludeRepos set", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(["org/included", "org/excluded"]); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos( + octokit as never, + "octocat", + new Set(["org/excluded"]) + ); + const names = result.map((r) => r.fullName); + expect(names).toContain("org/included"); + expect(names).not.toContain("org/excluded"); + }); + + it("returns empty array for empty search results", async () => { + const octokit = makeOctokit( + async () => ({}), + async () => makeSearchPage([]) + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + expect(result).toEqual([]); + }); + + it("returns empty array for invalid userLogin without making API calls", async () => { + const octokit = makeOctokit( + async () => ({}), + async () => { + throw new Error("should not be called"); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "bad[login]", new Set()); + expect(result).toEqual([]); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); + + it("returns empty array for login exceeding 39 chars", async () => { + const octokit = makeOctokit( + async () => ({}), + async () => { + throw new Error("should not be called"); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "a".repeat(40), new Set()); + expect(result).toEqual([]); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); + + it("returns partial results when one search fails", async () => { + const { pushNotification } = await import("../../src/app/lib/errors"); + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(["org/from-issues"]); + // PR search throws a non-partial error + throw new Error("GraphQL timeout"); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + // Should still get the issue results + const names = result.map((r) => r.fullName); + expect(names).toContain("org/from-issues"); + // And should have pushed a warning notification + expect(pushNotification).toHaveBeenCalled(); + }); + + it("respects the 100-repo cap", async () => { + // Generate 120 unique repo names in the issue search + const manyRepos = Array.from({ length: 120 }, (_, i) => `org/repo-${i.toString().padStart(3, "0")}`); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(manyRepos); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + expect(result.length).toBeLessThanOrEqual(100); + }); + + it("returns results sorted alphabetically by fullName", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(["zzz/repo", "aaa/repo", "mmm/repo"]); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + const names = result.map((r) => r.fullName); + expect(names).toEqual(["aaa/repo", "mmm/repo", "zzz/repo"]); + }); + + it("correctly parses owner and name from fullName", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) return makeSearchPage(["my-org/my-repo"]); + return makeSearchPage([]); + } + ); + + const result = await discoverUpstreamRepos(octokit as never, "octocat", new Set()); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ owner: "my-org", name: "my-repo", fullName: "my-org/my-repo" }); + }); + + it("discovers repos from tracked users in addition to primary user", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("involves:primary") && v.q.includes("is:issue")) { + return makeSearchPage(["org/primary-repo"]); + } + if (v.q.includes("involves:tracked1") && v.q.includes("is:issue")) { + return makeSearchPage(["org/tracked1-repo"]); + } + return makeSearchPage([]); + } + ); + + const trackedUsers = [makeTrackedUser("tracked1")]; + const result = await discoverUpstreamRepos(octokit as never, "primary", new Set(), trackedUsers); + const names = result.map((r) => r.fullName); + expect(names).toContain("org/primary-repo"); + expect(names).toContain("org/tracked1-repo"); + }); + + it("deduplicates repos found by both primary and tracked users", async () => { + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as { q: string }; + if (v.q.includes("is:issue")) { + // Both users discover the same repo + return makeSearchPage(["org/shared-repo"]); + } + return makeSearchPage([]); + } + ); + + const trackedUsers = [makeTrackedUser("tracked1")]; + const result = await discoverUpstreamRepos(octokit as never, "primary", new Set(), trackedUsers); + expect(result).toHaveLength(1); + expect(result[0].fullName).toBe("org/shared-repo"); + }); +}); + +// ── multi-user search (fetchIssuesAndPullRequests with trackedUsers) ─────────── + +/** + * Build a LightCombinedSearchResponse for the LIGHT_COMBINED_SEARCH_QUERY. + * issueNodes: array of {databaseId, repoFullName} shapes (simplified). + * prNodes: same. + */ +function makeLightCombinedResponse( + issueItems: Array<{ databaseId: number; repoFullName: string }>, + prItems: Array<{ databaseId: number; nodeId: string; repoFullName: string }> = [] +) { + const makeIssueNode = (item: { databaseId: number; repoFullName: string }) => ({ + databaseId: item.databaseId, + number: item.databaseId, + title: `Issue ${item.databaseId}`, + state: "OPEN", + url: `https://github.com/${item.repoFullName}/issues/${item.databaseId}`, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/583231" }, + labels: { nodes: [] }, + assignees: { nodes: [] }, + repository: { nameWithOwner: item.repoFullName }, + comments: { totalCount: 0 }, + }); + + const makePRNode = (item: { databaseId: number; nodeId: string; repoFullName: string }) => ({ + id: item.nodeId, + databaseId: item.databaseId, + number: item.databaseId, + title: `PR ${item.databaseId}`, + state: "OPEN", + isDraft: false, + url: `https://github.com/${item.repoFullName}/pull/${item.databaseId}`, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/583231" }, + repository: { nameWithOwner: item.repoFullName }, + headRefName: "feature", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [] }, + }); + + return { + issues: { + issueCount: issueItems.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: issueItems.map(makeIssueNode), + }, + prInvolves: { + issueCount: prItems.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: prItems.map(makePRNode), + }, + prReviewReq: { + issueCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + rateLimit: { remaining: 4990, resetAt: "2024-01-01T01:00:00Z" }, + }; +} + +/** Empty heavy backfill response */ +function makeBackfillResponse(databaseIds: number[] = []) { + return { + nodes: databaseIds.map((id) => ({ + databaseId: id, + headRefOid: "abc123", + headRepository: null, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 5, + deletions: 2, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + })), + rateLimit: { remaining: 4980, resetAt: "2024-01-01T01:00:00Z" }, + }; +} + +/** Tracked user fixture */ +function makeTrackedUser(login: string): TrackedUser { + return { + login, + avatarUrl: `https://avatars.githubusercontent.com/u/99999`, + name: login, + }; +} + +/** Repo ref fixture */ +function makeRepo(fullName: string) { + const [owner, name] = fullName.split("/"); + return { owner, name, fullName }; +} + +describe("multi-user search", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("same issue from main and tracked user gets merged surfacedBy", async () => { + // Issue 1 appears in both main user and tracked user results + const sharedIssueId = 1001; + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + // Both main and tracked user searches are now repo-scoped; + // distinguish by the involves: login in the query + const issueQ = v["issueQ"] as string | undefined; + if (issueQ?.includes("involves:mainuser") || issueQ?.includes("involves:trackeduser")) { + return makeLightCombinedResponse([{ databaseId: sharedIssueId, repoFullName: "org/repo" }]); + } + return makeLightCombinedResponse([]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + ); + + expect(result.issues).toHaveLength(1); + const issue = result.issues[0]; + expect(issue.surfacedBy).toContain("mainuser"); + expect(issue.surfacedBy).toContain("trackeduser"); + expect(issue.surfacedBy).toHaveLength(2); + }); + + it("unique issues from tracked user appear with surfacedBy containing only tracked login", async () => { + const mainIssueId = 1001; + const trackedOnlyIssueId = 2001; + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + const issueQ = v["issueQ"] as string | undefined; + if (issueQ?.includes("involves:mainuser")) { + return makeLightCombinedResponse([{ databaseId: mainIssueId, repoFullName: "org/repo" }]); + } + if (issueQ?.includes("involves:trackeduser")) { + // Tracked user has both the shared one and a unique one + return makeLightCombinedResponse([ + { databaseId: mainIssueId, repoFullName: "org/repo" }, + { databaseId: trackedOnlyIssueId, repoFullName: "org/repo" }, + ]); + } + return makeLightCombinedResponse([]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + ); + + expect(result.issues).toHaveLength(2); + const trackedOnly = result.issues.find((i) => i.id === trackedOnlyIssueId); + expect(trackedOnly?.surfacedBy).toEqual(["trackeduser"]); + const shared = result.issues.find((i) => i.id === mainIssueId); + expect(shared?.surfacedBy).toContain("mainuser"); + expect(shared?.surfacedBy).toContain("trackeduser"); + }); + + it("multiple tracked users results are all merged correctly", async () => { + const sharedId = 1001; + const userAOnlyId = 2001; + const userBOnlyId = 3001; + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + const issueQ = v["issueQ"] as string | undefined; + if (issueQ?.includes("involves:mainuser")) { + return makeLightCombinedResponse([{ databaseId: sharedId, repoFullName: "org/repo" }]); + } + if (issueQ?.includes("involves:usera")) { + return makeLightCombinedResponse([ + { databaseId: sharedId, repoFullName: "org/repo" }, + { databaseId: userAOnlyId, repoFullName: "org/repo" }, + ]); + } + if (issueQ?.includes("involves:userb")) { + return makeLightCombinedResponse([ + { databaseId: sharedId, repoFullName: "org/repo" }, + { databaseId: userBOnlyId, repoFullName: "org/repo" }, + ]); + } + return makeLightCombinedResponse([]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, + [makeTrackedUser("usera"), makeTrackedUser("userb")] + ); + + const shared = result.issues.find((i) => i.id === sharedId); + expect(shared?.surfacedBy).toContain("mainuser"); + expect(shared?.surfacedBy).toContain("usera"); + expect(shared?.surfacedBy).toContain("userb"); + + const aOnly = result.issues.find((i) => i.id === userAOnlyId); + expect(aOnly?.surfacedBy).toEqual(["usera"]); + + const bOnly = result.issues.find((i) => i.id === userBOnlyId); + expect(bOnly?.surfacedBy).toEqual(["userb"]); + }); + + it("empty trackedUsers produces same results as before (backward compat)", async () => { + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + return makeLightCombinedResponse([{ databaseId: 1001, repoFullName: "org/repo" }]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, [] + ); + + expect(result.issues).toHaveLength(1); + // surfacedBy is set to main user + expect(result.issues[0].surfacedBy).toEqual(["mainuser"]); + }); + + it("tracked user search failure does not block main user results", async () => { + const repo = makeRepo("org/repo"); + let callCount = 0; + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + const issueQ = v["issueQ"] as string | undefined; + if (issueQ?.includes("involves:mainuser")) { + return makeLightCombinedResponse([{ databaseId: 1001, repoFullName: "org/repo" }]); + } + // Tracked user search fails + callCount++; + throw new Error("tracked user search failed"); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + ); + + // Main user's issue is still returned + expect(result.issues).toHaveLength(1); + expect(result.issues[0].id).toBe(1001); + expect(result.issues[0].surfacedBy).toEqual(["mainuser"]); + expect(result.errors.length).toBeGreaterThan(0); + expect(callCount).toBeGreaterThan(0); + }); + + it("surfacedBy survives Phase 2 PR enrichment", async () => { + const prId = 5001; + const prNodeId = "PR_node_5001"; + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) { + return makeBackfillResponse([prId]); + } + const issueQ = v["issueQ"] as string | undefined; + if (issueQ?.includes("involves:mainuser") || issueQ?.includes("involves:trackeduser")) { + return makeLightCombinedResponse([], [{ databaseId: prId, nodeId: prNodeId, repoFullName: "org/repo" }]); + } + return makeLightCombinedResponse([]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + ); + + expect(result.pullRequests).toHaveLength(1); + const pr = result.pullRequests[0]; + expect(pr.surfacedBy).toContain("mainuser"); + expect(pr.surfacedBy).toContain("trackeduser"); + expect(pr.enriched).toBe(true); + expect(pr.checkStatus).toBe("success"); + }); + + it("empty repos returns empty results even with tracked users", async () => { + const octokit = makeOctokit( + async () => { throw new Error("should not be called"); }, + async () => { throw new Error("should not be called"); } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, + [], // empty repos — tracked user searches are also repo-scoped + "mainuser", + undefined, + [makeTrackedUser("trackeduser")] + ); + + expect(result.issues).toEqual([]); + expect(result.pullRequests).toEqual([]); + expect(result.errors).toEqual([]); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); + + it("surfacedBy logins are always lowercase", async () => { + const repo = makeRepo("org/repo"); + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + return makeLightCombinedResponse([{ databaseId: 1001, repoFullName: "org/repo" }]); + } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [repo], "MainUser", undefined, [makeTrackedUser("TrackedUser")] + ); + + expect(result.issues[0].surfacedBy).toEqual(["mainuser", "trackeduser"]); + }); + + it("onLightData fires with surfacedBy already annotated", async () => { + const repo = makeRepo("org/repo"); + let lightDataSurfacedBy: string[] | undefined; + + const octokit = makeOctokit( + async () => ({}), + async (_query: string, vars: unknown) => { + const v = vars as Record; + if ("ids" in v) return makeBackfillResponse([]); + return makeLightCombinedResponse([{ databaseId: 1001, repoFullName: "org/repo" }]); + } + ); + + await fetchIssuesAndPullRequests( + octokit as never, + [repo], + "mainuser", + (data) => { lightDataSurfacedBy = data.issues[0]?.surfacedBy; }, + [] + ); + + // surfacedBy must be set when onLightData fires + expect(lightDataSurfacedBy).toEqual(["mainuser"]); + }); + + it("returns empty results immediately when repos and trackedUsers are both empty", async () => { + const octokit = makeOctokit( + async () => { throw new Error("should not be called"); }, + async () => { throw new Error("should not be called"); } + ); + + const result = await fetchIssuesAndPullRequests( + octokit as never, [], "mainuser", undefined, [] + ); + + expect(result.issues).toEqual([]); + expect(result.pullRequests).toEqual([]); + expect(result.errors).toEqual([]); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index fe8ffc06..246b771f 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -136,7 +136,7 @@ describe("fetchAllData — first call", () => { await fetchAllData(); - expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith(mockOctokit, config.selectedRepos, "octocat", undefined); + expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith(mockOctokit, config.selectedRepos, "octocat", undefined, []); expect(fetchWorkflowRuns).toHaveBeenCalledWith( mockOctokit, config.selectedRepos, @@ -587,6 +587,251 @@ describe("fetchAllData — notification gate 403 auto-disable", () => { }); }); +// ── Upstream repos + tracked users integration ──────────────────────────────── + +describe("fetchAllData — upstream repos and tracked users", () => { + + it("passes combined (selectedRepos + upstreamRepos) deduplicated to fetchIssuesAndPullRequests", async () => { + vi.resetModules(); + + // Override config mock to include upstreamRepos + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [ + { owner: "other-org", name: "upstream-repo", fullName: "other-org/upstream-repo" }, + // Duplicate of selectedRepos — should be filtered out + { owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }, + ], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + // Should be called with combined repos (2, not 3 — duplicate removed) + const callArgs = vi.mocked(fetchIssuesAndPullRequests).mock.calls[0]; + const passedRepos = callArgs[1] as Array<{ fullName: string }>; + expect(passedRepos).toHaveLength(2); + expect(passedRepos.map((r) => r.fullName)).toContain("octocat/Hello-World"); + expect(passedRepos.map((r) => r.fullName)).toContain("other-org/upstream-repo"); + }); + + it("passes only selectedRepos to fetchWorkflowRuns (upstream repos excluded)", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [{ owner: "other-org", name: "upstream-repo", fullName: "other-org/upstream-repo" }], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + // fetchWorkflowRuns should only get selectedRepos, not upstream + const callArgs = vi.mocked(fetchWorkflowRuns).mock.calls[0]; + const passedRepos = callArgs[1] as Array<{ fullName: string }>; + expect(passedRepos).toHaveLength(1); + expect(passedRepos[0].fullName).toBe("octocat/Hello-World"); + }); + + it("passes trackedUsers to fetchIssuesAndPullRequests", async () => { + vi.resetModules(); + + const trackedUsers = [ + { login: "tracked-alice", avatarUrl: "https://avatars.githubusercontent.com/u/1", name: "Alice" }, + ]; + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [], + trackedUsers, + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + // 5th argument to fetchIssuesAndPullRequests should be trackedUsers + const callArgs = vi.mocked(fetchIssuesAndPullRequests).mock.calls[0]; + expect(callArgs[4]).toEqual(trackedUsers); + }); + + it("empty upstreamRepos and trackedUsers produces identical behavior (backward compat)", async () => { + vi.resetModules(); + + const selectedRepos = [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }]; + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos, + upstreamRepos: [], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + // Combined repos == selectedRepos when no upstream repos + expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith( + mockOctokit, + selectedRepos, + "octocat", + undefined, + [] + ); + expect(fetchWorkflowRuns).toHaveBeenCalledWith( + mockOctokit, + selectedRepos, + 5, + 3 + ); + }); + + it("duplicate repo in both selectedRepos and upstreamRepos is deduplicated (first occurrence wins)", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [ + { owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }, + { owner: "octocat", name: "Other", fullName: "octocat/Other" }, + ], + upstreamRepos: [ + // Both are already in selectedRepos + { owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }, + { owner: "octocat", name: "Other", fullName: "octocat/Other" }, + // This one is new + { owner: "new-org", name: "new-repo", fullName: "new-org/new-repo" }, + ], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + const callArgs = vi.mocked(fetchIssuesAndPullRequests).mock.calls[0]; + const passedRepos = callArgs[1] as Array<{ fullName: string }>; + // 2 selected + 1 new upstream (2 duplicates filtered) + expect(passedRepos).toHaveLength(3); + const names = passedRepos.map((r) => r.fullName); + expect(names.filter((n) => n === "octocat/Hello-World")).toHaveLength(1); + expect(names.filter((n) => n === "octocat/Other")).toHaveLength(1); + expect(names).toContain("new-org/new-repo"); + }); +}); + +// ── DashboardPage fine-grained merge: surfacedBy preserved ──────────────────── + +describe("DashboardPage pollFetch — fine-grained merge preserves surfacedBy", () => { + it("surfacedBy is copied into the store during the canMerge path", () => { + // Test the canMerge loop logic directly (same code as DashboardPage.tsx pollFetch). + // Using Record to avoid the 'never' collapse from conflicting + // enriched: false vs enriched: true literal types. + type MutablePR = Record & { id: number }; + + const pr: MutablePR = { + id: 5001, + surfacedBy: ["mainuser", "trackeduser"], + enriched: false, + }; + + const enriched: MutablePR = { + id: 5001, + headSha: "abc123", + assigneeLogins: [], + reviewerLogins: [], + checkStatus: "success", + additions: 5, + deletions: 2, + changedFiles: 1, + comments: 0, + reviewThreads: 0, + totalReviewCount: 0, + enriched: true, + nodeId: "PR_node_5001", + surfacedBy: ["mainuser", "trackeduser"], + }; + + const state = { pullRequests: [pr] }; + const enrichedMap = new Map([[5001, enriched]]); + + // Simulate the canMerge loop from DashboardPage.tsx + for (let i = 0; i < state.pullRequests.length; i++) { + const e = enrichedMap.get(state.pullRequests[i].id)!; + const p = state.pullRequests[i]; + p["headSha"] = e["headSha"]; + p["assigneeLogins"] = e["assigneeLogins"]; + p["reviewerLogins"] = e["reviewerLogins"]; + p["checkStatus"] = e["checkStatus"]; + p["additions"] = e["additions"]; + p["deletions"] = e["deletions"]; + p["changedFiles"] = e["changedFiles"]; + p["comments"] = e["comments"]; + p["reviewThreads"] = e["reviewThreads"]; + p["totalReviewCount"] = e["totalReviewCount"]; + p["enriched"] = e["enriched"]; + p["nodeId"] = e["nodeId"]; + p["surfacedBy"] = e["surfacedBy"]; + } + + expect(state.pullRequests[0]["surfacedBy"]).toEqual(["mainuser", "trackeduser"]); + expect(state.pullRequests[0]["enriched"]).toBe(true); + expect(state.pullRequests[0]["checkStatus"]).toBe("success"); + }); +}); + // ── qa-4: Concurrency verification ──────────────────────────────────────────── describe("fetchAllData — parallel execution", () => { diff --git a/tests/stores/config.test.ts b/tests/stores/config.test.ts index beda0895..898788c8 100644 --- a/tests/stores/config.test.ts +++ b/tests/stores/config.test.ts @@ -107,6 +107,61 @@ describe("ConfigSchema", () => { }); }); +describe("ConfigSchema — upstream repos and tracked users", () => { + it("defaults upstreamRepos to empty array", () => { + const result = ConfigSchema.parse({}); + expect(result.upstreamRepos).toEqual([]); + }); + + it("defaults trackedUsers to empty array", () => { + const result = ConfigSchema.parse({}); + expect(result.trackedUsers).toEqual([]); + }); + + it("accepts valid tracked users", () => { + const result = ConfigSchema.parse({ + trackedUsers: [ + { login: "octocat", avatarUrl: "https://avatars.githubusercontent.com/u/583231", name: "The Octocat" }, + ], + }); + expect(result.trackedUsers).toHaveLength(1); + expect(result.trackedUsers[0].login).toBe("octocat"); + }); + + it("rejects trackedUsers array exceeding max of 10", () => { + const users = Array.from({ length: 11 }, (_, i) => ({ + login: `user${i}`, + avatarUrl: `https://avatars.githubusercontent.com/u/${i}`, + name: null, + })); + expect(() => ConfigSchema.parse({ trackedUsers: users })).toThrow(); + }); + + it("rejects trackedUser with non-GitHub-CDN avatar URL", () => { + expect(() => ConfigSchema.parse({ + trackedUsers: [ + { login: "evil", avatarUrl: "https://evil.com/avatar.png", name: null }, + ], + })).toThrow(); + }); + + it("accepts trackedUser with null name", () => { + const result = ConfigSchema.parse({ + trackedUsers: [ + { login: "noname", avatarUrl: "https://avatars.githubusercontent.com/u/1", name: null }, + ], + }); + expect(result.trackedUsers[0].name).toBeNull(); + }); + + it("accepts valid upstream repos", () => { + const result = ConfigSchema.parse({ + upstreamRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], + }); + expect(result.upstreamRepos).toHaveLength(1); + }); +}); + describe("loadConfig", () => { beforeEach(() => { localStorageMock.clear(); @@ -243,54 +298,6 @@ describe("updateConfig (real export)", () => { }); }); - it("preserves non-default values when updating a different field", () => { - createRoot((dispose) => { - // Set several fields to non-default values - updateConfig({ theme: "dark", refreshInterval: 120, itemsPerPage: 50 }); - // Now update a single unrelated field - updateConfig({ hotPollInterval: 60 }); - // All previously-set fields must survive - expect(config.hotPollInterval).toBe(60); - expect(config.theme).toBe("dark"); - expect(config.refreshInterval).toBe(120); - expect(config.itemsPerPage).toBe(50); - dispose(); - }); - }); - - it("preserves onboardingComplete when updating refreshInterval", () => { - createRoot((dispose) => { - updateConfig({ onboardingComplete: true }); - updateConfig({ refreshInterval: 600 }); - expect(config.refreshInterval).toBe(600); - expect(config.onboardingComplete).toBe(true); - dispose(); - }); - }); - - it("preserves nested notifications when updating a top-level field", () => { - createRoot((dispose) => { - updateConfig({ - notifications: { enabled: true, issues: true, pullRequests: false, workflowRuns: true }, - }); - updateConfig({ viewDensity: "compact" }); - expect(config.viewDensity).toBe("compact"); - expect(config.notifications.enabled).toBe(true); - expect(config.notifications.pullRequests).toBe(false); - dispose(); - }); - }); - - it("preserves selectedOrgs when updating theme", () => { - createRoot((dispose) => { - updateConfig({ selectedOrgs: ["my-org", "other-org"] }); - updateConfig({ theme: "forest" }); - expect(config.theme).toBe("forest"); - expect(config.selectedOrgs).toEqual(["my-org", "other-org"]); - dispose(); - }); - }); - it("does nothing when called with empty object", () => { createRoot((dispose) => { updateConfig({ theme: "dark" }); @@ -309,4 +316,69 @@ describe("updateConfig (real export)", () => { dispose(); }); }); + + // Structural guard: updating ANY single field must never wipe other fields. + // Catches Zod v4 .partial().safeParse() default inflation (BUG-001 class). + it.each([ + ["selectedOrgs", { selectedOrgs: ["new-org"] }], + ["selectedRepos", { selectedRepos: [{ owner: "x", name: "y", fullName: "x/y" }] }], + ["upstreamRepos", { upstreamRepos: [{ owner: "u", name: "v", fullName: "u/v" }] }], + ["trackedUsers", { trackedUsers: [{ login: "bob", avatarUrl: "https://avatars.githubusercontent.com/u/1", name: null }] }], + ["refreshInterval", { refreshInterval: 120 }], + ["hotPollInterval", { hotPollInterval: 60 }], + ["maxWorkflowsPerRepo", { maxWorkflowsPerRepo: 10 }], + ["maxRunsPerWorkflow", { maxRunsPerWorkflow: 5 }], + ["notifications", { notifications: { enabled: true, issues: false, pullRequests: true, workflowRuns: false } }], + ["theme", { theme: "dark" as const }], + ["viewDensity", { viewDensity: "compact" as const }], + ["itemsPerPage", { itemsPerPage: 50 }], + ["defaultTab", { defaultTab: "actions" as const }], + ["rememberLastTab", { rememberLastTab: false }], + ["onboardingComplete", { onboardingComplete: true }], + ["authMethod", { authMethod: "pat" as const }], + ])("updating only %s preserves all other fields", (fieldName, patch) => { + createRoot((dispose) => { + // Seed config with non-default values for every field + const seed = { + selectedOrgs: ["seed-org"], + selectedRepos: [{ owner: "s", name: "r", fullName: "s/r" }], + upstreamRepos: [{ owner: "a", name: "b", fullName: "a/b" }], + trackedUsers: [{ login: "alice", avatarUrl: "https://avatars.githubusercontent.com/u/2", name: "Alice" }], + refreshInterval: 600, + hotPollInterval: 45, + maxWorkflowsPerRepo: 8, + maxRunsPerWorkflow: 2, + notifications: { enabled: true, issues: false, pullRequests: false, workflowRuns: true }, + theme: "dracula" as const, + viewDensity: "compact" as const, + itemsPerPage: 50, + defaultTab: "pullRequests" as const, + rememberLastTab: false, + onboardingComplete: true, + authMethod: "pat" as const, + }; + updateConfig(seed); + + // Snapshot before the single-field update + const before = JSON.parse(JSON.stringify(config)); + + // Apply the single-field patch + updateConfig(patch); + + // The patched field should have changed + expect((config as Record)[fieldName]).toEqual( + (patch as Record)[fieldName] + ); + + // Every OTHER field must be identical to the snapshot + for (const key of Object.keys(before)) { + if (key === fieldName) continue; + expect( + (config as Record)[key], + `updateConfig({ ${fieldName} }) must not change ${key}` + ).toEqual((before as Record)[key]); + } + dispose(); + }); + }); });