@@ -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"
+ />
+
+
+
+
+ {manualEntryError()}
+
+
+
+ {/* Discovery loading */}
+
+
+
+ Discovering upstream repos...
+
+
+
+ {/* Discovered repos list */}
+
0}>
+
+
+
+ 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 */}
+
+
+ {validationError()}
+
+
+
+ {/* User list */}
+
+ {(trackedUser) => (
+
+
+
+

+
+
+
+
+ {trackedUser.name ?? trackedUser.login}
+
+
+
+ {trackedUser.login}
+
+
+
+
+
+ )}
+
+
+ {/* API usage warning at 3+ users */}
+
= 3}>
+
+ Each tracked user increases API usage by ~30 points per refresh. Adding many users may
+ cause GitHub rate limiting.
+
+
+
+ {/* Cap reached message */}
+
= 10}>
+
+ Maximum of 10 tracked users
+
+
+
+ );
+}
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" } : {}}
+ >
+
+

+
+
+ )}
+
+
+
+ );
+}
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();
+ });
+ });
});