Skip to content

Commit 414af1c

Browse files
authored
feat: adds bot user tracking and per-repo monitor-all mode (#38)
* feat: adds bot user tracking and per-repo monitor-all mode * fix(poll): uses serialized keys for notification reset * fix(poll): restores boolean mount guard for notification reset * fix(api): addresses PR #38 review findings - removes trackedSearchRepos fallback that sent involves: queries to monitored repos when all repos were monitored - extracts SEARCH_RESULT_CAP module-level constant (replaces 4 local declarations) - extracts LIGHT_ISSUE_FRAGMENT GraphQL fragment (eliminates 3rd copy of inline issue field selection) - simplifies SettingsPage onMonitorToggle lambda - types monitoredRepos prop as RepoRef[] in tab components - documents poll.ts mount-guard permanence across resetPollState() - adds 6 tests: unfiltered search error handling, all-monitored + tracked users, onLightData suppression, upstream repo toggle guard * fix(api): addresses Layer 1.5 domain review findings - types fetchIssuesAndPullRequests monitoredRepos param as RepoRef[] - uses SEARCH_RESULT_CAP in pushNotification strings (removes hardcoded 1,000) - removes stale section comment header - tightens test assertion for retryable error - updates test callsites to use full RepoRef objects * fix(api): pins toLocaleString locale to en-US in notification strings * fix: addresses remaining deferred findings (qa-5, perf-2) - adds poll notification reset reactive effect tests (qa-5): verifies _resetNotificationState fires on monitoredRepos and trackedUsers config changes, skips on initial mount, detects key swaps - adds .max(10) constraint on monitoredRepos schema (perf-2): limits unfiltered search volume, adds guard in setMonitoredRepo - adds schema constraint tests for monitoredRepos max * fix(auth): adds localStorage resilience and 401 retry * fix: addresses PR review findings from code review * fix: monitor-all gate bypass, hot poll guard, and UX - invalidates notifications gate when monitoredRepos or trackedUsers change so next poll fetches fresh data instead of returning stale 304 - tightens hot poll PR qualifier to require enriched === true, excluding unenriched and null-checkStatus PRs from unnecessary polling - switches monitor-all eyeball icon from opacity toggle to text-info color for better enabled/disabled contrast - adds "Monitoring all" indicator with repo names on Settings page - adds regex constraints to RepoRefSchema for defense-in-depth validation - sets makePullRequest helper default to enriched: true (steady state) - adds gate bypass integration tests and Settings indicator render tests
1 parent 08d7fd5 commit 414af1c

27 files changed

Lines changed: 2255 additions & 239 deletions

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
fetchAllData,
2020
type DashboardData,
2121
} from "../../services/poll";
22-
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
22+
import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
23+
import { pushNotification } from "../../lib/errors";
2324
import { getClient, getGraphqlRateLimit } from "../../services/github";
2425
import { formatCount } from "../../lib/format";
2526
import { setsEqual } from "../../lib/collections";
@@ -191,7 +192,7 @@ async function pollFetch(): Promise<DashboardData> {
191192
try {
192193
localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(cachePayload));
193194
} catch {
194-
// localStorage full or unavailable — non-fatal
195+
pushNotification("localStorage:dashboard", "Dashboard cache write failed — storage may be full", "warning");
195196
}
196197
}, 0);
197198
} else {
@@ -208,9 +209,9 @@ async function pollFetch(): Promise<DashboardData> {
208209
: null;
209210

210211
if (status === 401) {
211-
// Hard redirect (not navigate()) forces a full page reload, which clears
212-
// module-level state like _coordinator and dashboardData for the next user.
213-
clearAuth();
212+
// Token invalid — clear token only, preserve user config/view/dashboard.
213+
// Hard redirect forces a full page reload, clearing module-level state.
214+
expireToken();
214215
window.location.replace("/login");
215216
}
216217
setDashboardData("loading", false);
@@ -359,6 +360,7 @@ export default function DashboardPage() {
359360
userLogin={userLogin()}
360361
allUsers={allUsers()}
361362
trackedUsers={config.trackedUsers}
363+
monitoredRepos={config.monitoredRepos}
362364
/>
363365
</Match>
364366
<Match when={activeTab() === "pullRequests"}>
@@ -369,6 +371,7 @@ export default function DashboardPage() {
369371
allUsers={allUsers()}
370372
trackedUsers={config.trackedUsers}
371373
hotPollingPRIds={hotPollingPRIds()}
374+
monitoredRepos={config.monitoredRepos}
372375
/>
373376
</Match>
374377
<Match when={activeTab() === "actions"}>

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
33
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
4-
import type { Issue } from "../../services/api";
4+
import type { Issue, RepoRef } from "../../services/api";
55
import ItemRow from "./ItemRow";
66
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
77
import IgnoreBadge from "./IgnoreBadge";
@@ -25,6 +25,7 @@ export interface IssuesTabProps {
2525
userLogin: string;
2626
allUsers?: { login: string; label: string }[];
2727
trackedUsers?: TrackedUser[];
28+
monitoredRepos?: RepoRef[];
2829
}
2930

3031
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments";
@@ -68,6 +69,10 @@ export default function IssuesTab(props: IssuesTabProps) {
6869
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
6970
);
7071

72+
const monitoredRepoNameSet = createMemo(() =>
73+
new Set((props.monitoredRepos ?? []).map(r => r.fullName))
74+
);
75+
7176
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
7277
const users = props.allUsers;
7378
if (!users || users.length <= 1) return issueFilterGroups;
@@ -114,10 +119,13 @@ export default function IssuesTab(props: IssuesTabProps) {
114119
}
115120

116121
if (tabFilter.user !== "all") {
117-
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user);
118-
if (validUser) {
119-
const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()];
120-
if (!surfacedBy.includes(tabFilter.user)) return false;
122+
// Items from monitored repos bypass the surfacedBy filter (all activity is shown)
123+
if (!monitoredRepoNameSet().has(issue.repoFullName)) {
124+
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user);
125+
if (validUser) {
126+
const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()];
127+
if (!surfacedBy.includes(tabFilter.user)) return false;
128+
}
121129
}
122130
}
123131

@@ -305,6 +313,9 @@ export default function IssuesTab(props: IssuesTabProps) {
305313
>
306314
<ChevronIcon size="md" rotated={!isExpanded()} />
307315
{repoGroup.repoFullName}
316+
<Show when={monitoredRepoNameSet().has(repoGroup.repoFullName)}>
317+
<span class="badge badge-xs badge-ghost" aria-label="monitoring all activity">Monitoring all</span>
318+
</Show>
308319
<Show when={!isExpanded()}>
309320
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
310321
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
33
import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view";
4-
import type { PullRequest } from "../../services/api";
4+
import type { PullRequest, RepoRef } from "../../services/api";
55
import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format";
66
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
77
import ItemRow from "./ItemRow";
@@ -30,6 +30,7 @@ export interface PullRequestsTabProps {
3030
allUsers?: { login: string; label: string }[];
3131
trackedUsers?: TrackedUser[];
3232
hotPollingPRIds?: ReadonlySet<number>;
33+
monitoredRepos?: RepoRef[];
3334
}
3435

3536
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size";
@@ -135,6 +136,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
135136
new Set((config.upstreamRepos ?? []).map(r => r.fullName))
136137
);
137138

139+
const monitoredRepoNameSet = createMemo(() =>
140+
new Set((props.monitoredRepos ?? []).map(r => r.fullName))
141+
);
142+
138143
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
139144
const users = props.allUsers;
140145
if (!users || users.length <= 1) return prFilterGroups;
@@ -199,10 +204,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
199204
}
200205

201206
if (tabFilters.user !== "all") {
202-
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user);
203-
if (validUser) {
204-
const surfacedBy = pr.surfacedBy ?? [props.userLogin.toLowerCase()];
205-
if (!surfacedBy.includes(tabFilters.user)) return false;
207+
// Items from monitored repos bypass the surfacedBy filter (all activity is shown)
208+
if (!monitoredRepoNameSet().has(pr.repoFullName)) {
209+
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user);
210+
if (validUser) {
211+
const surfacedBy = pr.surfacedBy ?? [props.userLogin.toLowerCase()];
212+
if (!surfacedBy.includes(tabFilters.user)) return false;
213+
}
206214
}
207215
}
208216

@@ -418,6 +426,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
418426
>
419427
<ChevronIcon size="md" rotated={!isExpanded()} />
420428
{repoGroup.repoFullName}
429+
<Show when={monitoredRepoNameSet().has(repoGroup.repoFullName)}>
430+
<span class="badge badge-xs badge-ghost" aria-label="monitoring all activity">Monitoring all</span>
431+
</Show>
421432
<Show when={!isExpanded()}>
422433
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60 shrink-0">
423434
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"}</span>

src/app/components/onboarding/OnboardingWizard.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Match,
88
} from "solid-js";
99
import { config, updateConfig, CONFIG_STORAGE_KEY } from "../../stores/config";
10+
import { pushNotification } from "../../lib/errors";
1011
import { fetchOrgs, type OrgEntry, type RepoRef } from "../../services/api";
1112
import { getClient } from "../../services/github";
1213
import RepoSelector from "./RepoSelector";
@@ -19,6 +20,9 @@ export default function OnboardingWizard() {
1920
const [upstreamRepos, setUpstreamRepos] = createSignal<RepoRef[]>(
2021
config.upstreamRepos.length > 0 ? [...config.upstreamRepos] : []
2122
);
23+
const [monitoredRepos, setMonitoredRepos] = createSignal<RepoRef[]>(
24+
config.monitoredRepos.length > 0 ? [...config.monitoredRepos] : []
25+
);
2226

2327
const [loading, setLoading] = createSignal(true);
2428
const [error, setError] = createSignal<string | null>(null);
@@ -57,10 +61,15 @@ export default function OnboardingWizard() {
5761
selectedOrgs: uniqueOrgs,
5862
selectedRepos: selectedRepos(),
5963
upstreamRepos: upstreamRepos(),
64+
monitoredRepos: monitoredRepos(),
6065
onboardingComplete: true,
6166
});
6267
// Flush synchronously — the debounced persistence effect won't fire before page unload
63-
localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(config));
68+
try {
69+
localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(config));
70+
} catch {
71+
pushNotification("localStorage:config", "Config write failed — storage may be full", "warning");
72+
}
6473
window.location.replace("/dashboard");
6574
}
6675

@@ -116,6 +125,14 @@ export default function OnboardingWizard() {
116125
upstreamRepos={upstreamRepos()}
117126
onUpstreamChange={setUpstreamRepos}
118127
trackedUsers={config.trackedUsers}
128+
monitoredRepos={monitoredRepos()}
129+
onMonitorToggle={(repo, monitored) => {
130+
if (monitored) {
131+
setMonitoredRepos((prev) => prev.some((r) => r.fullName === repo.fullName) ? prev : [...prev, repo]);
132+
} else {
133+
setMonitoredRepos((prev) => prev.filter((r) => r.fullName !== repo.fullName));
134+
}
135+
}}
119136
/>
120137
</Match>
121138
</Switch>

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { fetchOrgs, fetchRepos, discoverUpstreamRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api";
1111
import { getClient } from "../../services/github";
1212
import { user } from "../../stores/auth";
13+
import type { TrackedUser } from "../../stores/config";
1314
import { relativeTime } from "../../lib/format";
1415
import LoadingSpinner from "../shared/LoadingSpinner";
1516
import FilterInput from "../shared/FilterInput";
@@ -25,7 +26,9 @@ interface RepoSelectorProps {
2526
showUpstreamDiscovery?: boolean;
2627
upstreamRepos?: RepoRef[];
2728
onUpstreamChange?: (repos: RepoRef[]) => void;
28-
trackedUsers?: { login: string; avatarUrl: string; name: string | null }[];
29+
trackedUsers?: TrackedUser[];
30+
monitoredRepos?: RepoRef[];
31+
onMonitorToggle?: (repo: RepoRef, monitored: boolean) => void;
2932
}
3033

3134
interface OrgRepoState {
@@ -225,6 +228,10 @@ export default function RepoSelector(props: RepoSelectorProps) {
225228
new Set(props.selected.map((r) => r.fullName))
226229
);
227230

231+
const monitoredSet = createMemo(() =>
232+
new Set((props.monitoredRepos ?? []).map((r) => r.fullName))
233+
);
234+
228235
const sortedOrgStates = createMemo(() => {
229236
const states = orgStates();
230237
// Defer sorting until all orgs have loaded: prevents layout shift during
@@ -527,26 +534,51 @@ export default function RepoSelector(props: RepoSelectorProps) {
527534
{(repo) => {
528535
return (
529536
<li>
530-
<label class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-base-200">
531-
<input
532-
type="checkbox"
533-
checked={isSelected(repo().fullName)}
534-
onChange={() => toggleRepo(repo())}
535-
class="checkbox checkbox-primary checkbox-sm mt-0.5"
536-
/>
537-
<div class="min-w-0 flex-1">
538-
<div class="flex items-center gap-2">
539-
<span class="min-w-0 truncate text-sm font-medium text-base-content">
540-
{repo().name}
541-
</span>
542-
<Show when={repo().pushedAt}>
543-
<span class="ml-auto shrink-0 text-xs text-base-content/60">
544-
{relativeTime(repo().pushedAt!)}
537+
<div class="flex items-center">
538+
<label class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-base-200 flex-1">
539+
<input
540+
type="checkbox"
541+
checked={isSelected(repo().fullName)}
542+
onChange={() => toggleRepo(repo())}
543+
class="checkbox checkbox-primary checkbox-sm mt-0.5"
544+
/>
545+
<div class="min-w-0 flex-1">
546+
<div class="flex items-center gap-2">
547+
<span class="min-w-0 truncate text-sm font-medium text-base-content">
548+
{repo().name}
545549
</span>
546-
</Show>
550+
<Show when={repo().pushedAt}>
551+
<span class="ml-auto shrink-0 text-xs text-base-content/60">
552+
{relativeTime(repo().pushedAt!)}
553+
</span>
554+
</Show>
555+
</div>
547556
</div>
548-
</div>
549-
</label>
557+
</label>
558+
<Show when={isSelected(repo().fullName) && props.onMonitorToggle && !upstreamSelectedSet().has(repo().fullName)}>
559+
<button
560+
type="button"
561+
onClick={(e) => {
562+
e.stopPropagation();
563+
props.onMonitorToggle?.(toRepoRef(repo()), !monitoredSet().has(repo().fullName));
564+
}}
565+
class="btn btn-ghost btn-sm btn-circle mr-2"
566+
classList={{
567+
"text-info": monitoredSet().has(repo().fullName),
568+
"text-base-content/20": !monitoredSet().has(repo().fullName),
569+
}}
570+
title={monitoredSet().has(repo().fullName) ? "Stop monitoring all activity" : "Monitor all activity"}
571+
aria-label={monitoredSet().has(repo().fullName) ? "Stop monitoring all activity" : "Monitor all activity"}
572+
aria-pressed={monitoredSet().has(repo().fullName)}
573+
>
574+
{/* Heroicons eye outline 16px */}
575+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width={2} aria-hidden="true">
576+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
577+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
578+
</svg>
579+
</button>
580+
</Show>
581+
</div>
550582
</li>
551583
);
552584
}}

src/app/components/settings/SettingsPage.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { createSignal, Show, onCleanup } from "solid-js";
1+
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
22
import { useNavigate } from "@solidjs/router";
3-
import { config, updateConfig } from "../../stores/config";
3+
import { config, updateConfig, setMonitoredRepo } from "../../stores/config";
44
import { clearAuth } from "../../stores/auth";
55
import { clearCache } from "../../stores/cache";
66
import { pushNotification } from "../../lib/errors";
@@ -54,6 +54,10 @@ export default function SettingsPage() {
5454
const [localRepos, setLocalRepos] = createSignal<RepoRef[]>(config.selectedRepos);
5555
const [localUpstream, setLocalUpstream] = createSignal<RepoRef[]>(config.upstreamRepos);
5656

57+
const monitoredRepoNames = createMemo(() =>
58+
config.monitoredRepos.map(r => r.fullName).join(", ")
59+
);
60+
5761
// ── Helpers ──────────────────────────────────────────────────────────────
5862

5963
async function mergeNewOrgs() {
@@ -142,6 +146,7 @@ export default function SettingsPage() {
142146
selectedOrgs: config.selectedOrgs,
143147
selectedRepos: config.selectedRepos,
144148
upstreamRepos: config.upstreamRepos,
149+
monitoredRepos: config.monitoredRepos,
145150
trackedUsers: config.trackedUsers,
146151
refreshInterval: config.refreshInterval,
147152
hotPollInterval: config.hotPollInterval,
@@ -304,6 +309,15 @@ export default function SettingsPage() {
304309
Tracking {localRepos().length} repos will use significant API quota per poll cycle
305310
</p>
306311
</Show>
312+
<Show when={config.monitoredRepos.length > 0}>
313+
<p class="text-xs text-info flex items-center gap-1 mt-0.5">
314+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width={2} aria-hidden="true">
315+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
316+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
317+
</svg>
318+
Monitoring all: {monitoredRepoNames()}
319+
</p>
320+
</Show>
307321
</div>
308322
<button
309323
type="button"
@@ -324,6 +338,8 @@ export default function SettingsPage() {
324338
upstreamRepos={localUpstream()}
325339
onUpstreamChange={handleUpstreamChange}
326340
trackedUsers={config.trackedUsers}
341+
monitoredRepos={config.monitoredRepos}
342+
onMonitorToggle={setMonitoredRepo}
327343
/>
328344
</div>
329345
</Show>

0 commit comments

Comments
 (0)