From 761b500aa1ef029e32785f0e52e6fe691e8f2250 Mon Sep 17 00:00:00 2001
From: testvalue
Date: Mon, 30 Mar 2026 21:17:01 -0400
Subject: [PATCH 01/10] feat: adds bot user tracking and per-repo monitor-all
mode
---
.../components/dashboard/DashboardPage.tsx | 2 +
src/app/components/dashboard/IssuesTab.tsx | 19 +-
.../components/dashboard/PullRequestsTab.tsx | 19 +-
.../onboarding/OnboardingWizard.tsx | 15 +
.../components/onboarding/RepoSelector.tsx | 67 ++-
src/app/components/settings/SettingsPage.tsx | 5 +-
.../settings/TrackedUsersSection.tsx | 15 +-
src/app/services/api.ts | 234 +++++++++-
src/app/services/poll.ts | 19 +-
src/app/stores/config.ts | 23 +
tests/components/dashboard/IssuesTab.test.tsx | 75 +++-
.../dashboard/PullRequestsTab.test.tsx | 75 +++-
.../onboarding/OnboardingWizard.test.tsx | 38 +-
.../onboarding/RepoSelector.test.tsx | 90 ++++
.../components/settings/SettingsPage.test.tsx | 28 ++
.../settings/TrackedUsersSection.test.tsx | 57 +++
tests/services/api-users.test.ts | 4 +
tests/services/api.test.ts | 420 +++++++++++++++---
tests/services/poll-fetchAllData.test.ts | 3 +-
tests/stores/config.test.ts | 190 +++++++-
20 files changed, 1290 insertions(+), 108 deletions(-)
diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx
index bfc2408a..f35d31dc 100644
--- a/src/app/components/dashboard/DashboardPage.tsx
+++ b/src/app/components/dashboard/DashboardPage.tsx
@@ -359,6 +359,7 @@ export default function DashboardPage() {
userLogin={userLogin()}
allUsers={allUsers()}
trackedUsers={config.trackedUsers}
+ monitoredRepos={config.monitoredRepos}
/>
@@ -369,6 +370,7 @@ export default function DashboardPage() {
allUsers={allUsers()}
trackedUsers={config.trackedUsers}
hotPollingPRIds={hotPollingPRIds()}
+ monitoredRepos={config.monitoredRepos}
/>
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx
index 77491b87..5f2ab3ce 100644
--- a/src/app/components/dashboard/IssuesTab.tsx
+++ b/src/app/components/dashboard/IssuesTab.tsx
@@ -25,6 +25,7 @@ export interface IssuesTabProps {
userLogin: string;
allUsers?: { login: string; label: string }[];
trackedUsers?: TrackedUser[];
+ monitoredRepos?: { fullName: string }[];
}
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments";
@@ -68,6 +69,10 @@ export default function IssuesTab(props: IssuesTabProps) {
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
);
+ const monitoredRepoNameSet = createMemo(() =>
+ new Set((props.monitoredRepos ?? []).map(r => r.fullName))
+ );
+
const filterGroups = createMemo(() => {
const users = props.allUsers;
if (!users || users.length <= 1) return issueFilterGroups;
@@ -114,10 +119,13 @@ export default function IssuesTab(props: IssuesTabProps) {
}
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;
+ // Items from monitored repos bypass the surfacedBy filter (all activity is shown)
+ if (!monitoredRepoNameSet().has(issue.repoFullName)) {
+ 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;
+ }
}
}
@@ -305,6 +313,9 @@ export default function IssuesTab(props: IssuesTabProps) {
>
{repoGroup.repoFullName}
+
+ Monitoring all
+
{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx
index 8d8e36b0..6fd7da5d 100644
--- a/src/app/components/dashboard/PullRequestsTab.tsx
+++ b/src/app/components/dashboard/PullRequestsTab.tsx
@@ -30,6 +30,7 @@ export interface PullRequestsTabProps {
allUsers?: { login: string; label: string }[];
trackedUsers?: TrackedUser[];
hotPollingPRIds?: ReadonlySet;
+ monitoredRepos?: { fullName: string }[];
}
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size";
@@ -135,6 +136,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
);
+ const monitoredRepoNameSet = createMemo(() =>
+ new Set((props.monitoredRepos ?? []).map(r => r.fullName))
+ );
+
const filterGroups = createMemo(() => {
const users = props.allUsers;
if (!users || users.length <= 1) return prFilterGroups;
@@ -199,10 +204,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
}
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;
+ // Items from monitored repos bypass the surfacedBy filter (all activity is shown)
+ if (!monitoredRepoNameSet().has(pr.repoFullName)) {
+ 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;
+ }
}
}
@@ -418,6 +426,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
>
{repoGroup.repoFullName}
+
+ Monitoring all
+
{repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"}
diff --git a/src/app/components/onboarding/OnboardingWizard.tsx b/src/app/components/onboarding/OnboardingWizard.tsx
index 68edc3df..32958250 100644
--- a/src/app/components/onboarding/OnboardingWizard.tsx
+++ b/src/app/components/onboarding/OnboardingWizard.tsx
@@ -19,6 +19,9 @@ export default function OnboardingWizard() {
const [upstreamRepos, setUpstreamRepos] = createSignal(
config.upstreamRepos.length > 0 ? [...config.upstreamRepos] : []
);
+ const [monitoredRepos, setMonitoredRepos] = createSignal(
+ config.monitoredRepos.length > 0 ? [...config.monitoredRepos] : []
+ );
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal(null);
@@ -53,10 +56,14 @@ export default function OnboardingWizard() {
function handleFinish() {
const uniqueOrgs = [...new Set(selectedRepos().map((r) => r.owner))];
+ // Prune monitoredRepos to only repos still in selectedRepos
+ const selectedSet = new Set(selectedRepos().map((r) => r.fullName));
+ const prunedMonitoredRepos = monitoredRepos().filter((r) => selectedSet.has(r.fullName));
updateConfig({
selectedOrgs: uniqueOrgs,
selectedRepos: selectedRepos(),
upstreamRepos: upstreamRepos(),
+ monitoredRepos: prunedMonitoredRepos,
onboardingComplete: true,
});
// Flush synchronously — the debounced persistence effect won't fire before page unload
@@ -116,6 +123,14 @@ export default function OnboardingWizard() {
upstreamRepos={upstreamRepos()}
onUpstreamChange={setUpstreamRepos}
trackedUsers={config.trackedUsers}
+ monitoredRepos={monitoredRepos()}
+ onMonitorToggle={(repo, monitored) => {
+ if (monitored) {
+ setMonitoredRepos((prev) => prev.some((r) => r.fullName === repo.fullName) ? prev : [...prev, repo]);
+ } else {
+ setMonitoredRepos((prev) => prev.filter((r) => r.fullName !== repo.fullName));
+ }
+ }}
/>
diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx
index d5227c61..a8741ba7 100644
--- a/src/app/components/onboarding/RepoSelector.tsx
+++ b/src/app/components/onboarding/RepoSelector.tsx
@@ -10,6 +10,7 @@ import {
import { fetchOrgs, fetchRepos, discoverUpstreamRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api";
import { getClient } from "../../services/github";
import { user } from "../../stores/auth";
+import type { TrackedUser } from "../../stores/config";
import { relativeTime } from "../../lib/format";
import LoadingSpinner from "../shared/LoadingSpinner";
import FilterInput from "../shared/FilterInput";
@@ -25,7 +26,9 @@ interface RepoSelectorProps {
showUpstreamDiscovery?: boolean;
upstreamRepos?: RepoRef[];
onUpstreamChange?: (repos: RepoRef[]) => void;
- trackedUsers?: { login: string; avatarUrl: string; name: string | null }[];
+ trackedUsers?: TrackedUser[];
+ monitoredRepos?: RepoRef[];
+ onMonitorToggle?: (repo: RepoRef, monitored: boolean) => void;
}
interface OrgRepoState {
@@ -225,6 +228,10 @@ export default function RepoSelector(props: RepoSelectorProps) {
new Set(props.selected.map((r) => r.fullName))
);
+ const monitoredSet = createMemo(() =>
+ new Set((props.monitoredRepos ?? []).map((r) => r.fullName))
+ );
+
const sortedOrgStates = createMemo(() => {
const states = orgStates();
// Defer sorting until all orgs have loaded: prevents layout shift during
@@ -527,26 +534,48 @@ export default function RepoSelector(props: RepoSelectorProps) {
{(repo) => {
return (
-
+ 0}>
+
+
+ Monitoring all: {monitoredRepoNames()}
+
+
{
}
if (key !== _trackedUsersKey) {
_trackedUsersKey = key;
+ _lastSuccessfulFetch = null; // Force next poll to bypass notifications gate
untrack(() => _resetNotificationState());
}
});
@@ -122,6 +123,7 @@ createRoot(() => {
}
if (key !== _monitoredReposKey) {
_monitoredReposKey = key;
+ _lastSuccessfulFetch = null; // Force next poll to bypass notifications gate
untrack(() => _resetNotificationState());
}
});
@@ -458,7 +460,7 @@ export function rebuildHotSets(data: DashboardData): void {
_hotRuns.clear();
for (const pr of data.pullRequests) {
- if ((pr.checkStatus === "pending" || pr.checkStatus === null) && pr.nodeId) {
+ if (pr.enriched && pr.checkStatus === "pending" && pr.nodeId) {
if (_hotPRs.size >= MAX_HOT_PRS) {
console.warn(`[hot-poll] PR cap reached (${MAX_HOT_PRS}), skipping remaining`);
break;
diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts
index 70bda6a4..a6dcd1bd 100644
--- a/src/app/stores/config.ts
+++ b/src/app/stores/config.ts
@@ -18,10 +18,12 @@ export function resolveTheme(theme: ThemeId): string {
return prefersDark ? AUTO_DARK_THEME : AUTO_LIGHT_THEME;
}
+const REPO_SEGMENT = /^[A-Za-z0-9._-]{1,100}$/;
+
export const RepoRefSchema = z.object({
- owner: z.string(),
- name: z.string(),
- fullName: z.string(),
+ owner: z.string().regex(REPO_SEGMENT),
+ name: z.string().regex(REPO_SEGMENT),
+ fullName: z.string().regex(/^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/),
});
export const TrackedUserSchema = z.object({
diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx
index 4ad53248..1e8ae380 100644
--- a/tests/components/settings/SettingsPage.test.tsx
+++ b/tests/components/settings/SettingsPage.test.tsx
@@ -684,6 +684,31 @@ describe("SettingsPage — Manage org access button", () => {
});
describe("SettingsPage — monitor toggle wiring", () => {
+ it("shows monitored repos indicator when repos are monitored", () => {
+ updateConfig({
+ selectedRepos: [
+ { owner: "org", name: "repo1", fullName: "org/repo1" },
+ { owner: "org", name: "repo2", fullName: "org/repo2" },
+ ],
+ monitoredRepos: [
+ { owner: "org", name: "repo1", fullName: "org/repo1" },
+ { owner: "org", name: "repo2", fullName: "org/repo2" },
+ ],
+ });
+ renderSettings();
+
+ const indicator = screen.getByText(/Monitoring all:/);
+ expect(indicator.textContent).toContain("org/repo1");
+ expect(indicator.textContent).toContain("org/repo2");
+ });
+
+ it("hides monitored repos indicator when no repos are monitored", () => {
+ updateConfig({ monitoredRepos: [] });
+ renderSettings();
+
+ expect(screen.queryByText(/Monitoring all:/)).toBeNull();
+ });
+
it("includes monitoredRepos in exported settings JSON", async () => {
updateConfig({
selectedRepos: [{ owner: "org", name: "repo1", fullName: "org/repo1" }],
diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx
index 66430c3c..144853fc 100644
--- a/tests/helpers/index.tsx
+++ b/tests/helpers/index.tsx
@@ -53,6 +53,7 @@ export function makePullRequest(overrides: Partial = {}): PullReque
labels: [],
reviewDecision: null,
totalReviewCount: 0,
+ enriched: true,
surfacedBy: ["testuser"],
...overrides,
};
diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts
index 84cde603..36cfabeb 100644
--- a/tests/services/hot-poll.test.ts
+++ b/tests/services/hot-poll.test.ts
@@ -206,7 +206,7 @@ describe("resetPollState", () => {
it("clears hot sets and resets generation", async () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_x" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_x" })],
workflowRuns: [makeWorkflowRun({ id: 10, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});
expect(getHotPollGeneration()).toBe(1);
@@ -235,7 +235,7 @@ describe("rebuildHotSets", () => {
expect(getHotPollGeneration()).toBe(2);
});
- it("populates hot PRs for pending/null checkStatus with nodeId", async () => {
+ it("populates hot PRs for enriched pending checkStatus with nodeId", async () => {
const octokit = makeOctokit(undefined, () => Promise.resolve({
nodes: [],
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" },
@@ -245,20 +245,20 @@ describe("rebuildHotSets", () => {
rebuildHotSets({
...emptyData,
pullRequests: [
- makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_a" }),
- makePullRequest({ id: 2, checkStatus: null, nodeId: "PR_b" }),
- makePullRequest({ id: 3, checkStatus: "success", nodeId: "PR_c" }), // should be skipped
- makePullRequest({ id: 4, checkStatus: "pending" }), // no nodeId, should be skipped
+ makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" }),
+ makePullRequest({ id: 2, checkStatus: null, enriched: true, nodeId: "PR_b" }), // null checkStatus — skipped (not pending)
+ makePullRequest({ id: 3, checkStatus: "success", enriched: true, nodeId: "PR_c" }), // resolved — skipped
+ makePullRequest({ id: 4, checkStatus: "pending", enriched: true }), // no nodeId — skipped
+ makePullRequest({ id: 5, checkStatus: "pending", enriched: false, nodeId: "PR_e" }), // not enriched — skipped
],
});
await fetchHotData();
- // Verify graphql was called with only the 2 eligible node IDs
+ // Verify graphql was called with only the 1 eligible node ID
expect(octokit.graphql).toHaveBeenCalledTimes(1);
const calledIds = (octokit.graphql.mock.calls[0][1] as { ids: string[] }).ids;
- expect(calledIds).toHaveLength(2);
+ expect(calledIds).toHaveLength(1);
expect(calledIds).toContain("PR_a");
- expect(calledIds).toContain("PR_b");
});
it("populates hot runs for queued/in_progress, skips completed", async () => {
@@ -377,7 +377,7 @@ describe("fetchHotData", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_x" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_x" })],
});
// First fetch — PR is hot, returns success -> evicts
@@ -603,7 +603,7 @@ describe("createHotPollCoordinator", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_a" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
});
const { pushError } = await import("../../src/app/lib/errors");
@@ -661,7 +661,7 @@ describe("createHotPollCoordinator", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 42, nodeId: "PR_node42", repoFullName: "o/r" })],
+ pullRequests: [makePullRequest({ id: 42, checkStatus: "pending", enriched: true, nodeId: "PR_node42", repoFullName: "o/r" })],
workflowRuns: [makeWorkflowRun({ id: 7, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});
@@ -746,7 +746,7 @@ describe("createHotPollCoordinator", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 42, nodeId: "PR_node42", repoFullName: "o/r" })],
+ pullRequests: [makePullRequest({ id: 42, checkStatus: "pending", enriched: true, nodeId: "PR_node42", repoFullName: "o/r" })],
});
const { pushError } = await import("../../src/app/lib/errors");
@@ -868,7 +868,7 @@ describe("rebuildHotSets caps", () => {
it("caps hot PRs at MAX_HOT_PRS (200)", async () => {
const prs = Array.from({ length: 250 }, (_, i) =>
- makePullRequest({ id: i + 1, checkStatus: "pending", nodeId: `PR_${i}` })
+ makePullRequest({ id: i + 1, checkStatus: "pending", enriched: true, nodeId: `PR_${i}` })
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -945,8 +945,8 @@ describe("fetchHotData eviction edge cases", () => {
rebuildHotSets({
...emptyData,
pullRequests: [
- makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_one" }),
- makePullRequest({ id: 2, checkStatus: "pending", nodeId: "PR_two" }),
+ makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_one" }),
+ makePullRequest({ id: 2, checkStatus: "pending", enriched: true, nodeId: "PR_two" }),
],
});
@@ -979,7 +979,7 @@ describe("fetchHotData eviction edge cases", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_merged" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_merged" })],
});
const first = await fetchHotData();
@@ -1008,7 +1008,7 @@ describe("fetchHotData eviction edge cases", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 2, checkStatus: null, nodeId: "PR_closed" })],
+ pullRequests: [makePullRequest({ id: 2, checkStatus: "pending", enriched: true, nodeId: "PR_closed" })],
});
await fetchHotData();
@@ -1022,7 +1022,7 @@ describe("clearHotSets", () => {
it("empties both hot maps so next fetchHotData is a no-op", async () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_a" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
workflowRuns: [makeWorkflowRun({ id: 10, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});
@@ -1060,7 +1060,7 @@ describe("fetchHotData hadErrors", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_a" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
workflowRuns: [makeWorkflowRun({ id: 10, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});
@@ -1074,7 +1074,7 @@ describe("fetchHotData hadErrors", () => {
rebuildHotSets({
...emptyData,
- pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", nodeId: "PR_a" })],
+ pullRequests: [makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" })],
});
const { hadErrors, prUpdates } = await fetchHotData();
diff --git a/tests/services/poll-notification-effects.test.ts b/tests/services/poll-notification-effects.test.ts
index e5a85533..495193d4 100644
--- a/tests/services/poll-notification-effects.test.ts
+++ b/tests/services/poll-notification-effects.test.ts
@@ -59,7 +59,9 @@ vi.mock("../../src/app/lib/errors", () => ({
import { updateConfig, resetConfig } from "../../src/app/stores/config";
// Import poll.ts — triggers createRoot + createEffect registration at module scope
-import "../../src/app/services/poll";
+import { fetchAllData, resetPollState } from "../../src/app/services/poll";
+import { getClient } from "../../src/app/services/github";
+import { fetchIssuesAndPullRequests, fetchWorkflowRuns } from "../../src/app/services/api";
describe("poll.ts — notification reset reactive effects", () => {
beforeEach(() => {
@@ -124,3 +126,72 @@ describe("poll.ts — notification reset reactive effects", () => {
expect(mockResetNotifState).toHaveBeenCalled();
});
});
+
+describe("poll.ts — notifications gate bypass on config change", () => {
+ const mockRequest = vi.fn();
+
+ beforeEach(() => {
+ resetPollState();
+ resetConfig();
+ mockRequest.mockReset();
+ mockRequest.mockResolvedValue({
+ data: [],
+ headers: { "last-modified": "Thu, 20 Mar 2026 12:00:00 GMT" },
+ });
+ vi.mocked(getClient).mockReturnValue({
+ request: mockRequest,
+ graphql: vi.fn(),
+ hook: { before: vi.fn() },
+ } as never);
+ vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue({
+ issues: [], pullRequests: [], errors: [],
+ });
+ vi.mocked(fetchWorkflowRuns).mockResolvedValue({
+ workflowRuns: [], errors: [],
+ } as never);
+ });
+
+ it("bypasses notifications gate after monitoredRepos change", async () => {
+ // First call — no _lastSuccessfulFetch, gate skipped
+ await fetchAllData();
+ expect(mockRequest).not.toHaveBeenCalled();
+
+ // Second call — _lastSuccessfulFetch set, gate fires
+ await fetchAllData();
+ expect(mockRequest).toHaveBeenCalledWith("GET /notifications", expect.anything());
+ mockRequest.mockClear();
+
+ // Change monitoredRepos — should null _lastSuccessfulFetch
+ updateConfig({
+ selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }],
+ monitoredRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }],
+ });
+
+ // Third call — gate bypassed because _lastSuccessfulFetch was nulled
+ await fetchAllData();
+ expect(mockRequest).not.toHaveBeenCalled();
+ });
+
+ it("bypasses notifications gate after trackedUsers change", async () => {
+ // First call — sets _lastSuccessfulFetch
+ await fetchAllData();
+
+ // Second call — gate fires
+ await fetchAllData();
+ mockRequest.mockClear();
+
+ // Change trackedUsers — should null _lastSuccessfulFetch
+ updateConfig({
+ trackedUsers: [{
+ login: "octocat",
+ avatarUrl: "https://avatars.githubusercontent.com/u/583231",
+ name: "Octocat",
+ type: "user" as const,
+ }],
+ });
+
+ // Next call — gate bypassed
+ await fetchAllData();
+ expect(mockRequest).not.toHaveBeenCalled();
+ });
+});