Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ Each full refresh also checks GitHub's own status page for outages affecting Act
A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets:

- **PRs with actively running (`running`) or stale (`checkStatusStale`) CI checks** — re-checks status until checks leave the running state (plus a 60-second window after an inline `Approve` or `Ready?` action to catch newly triggered workflows). PRs in `pending` (`Pending condition`) are re-checked for up to 3 minutes from when they were first seen pending on their current commit, which covers jobs waiting for a runner and gaps between dependent jobs; after that, PRs parked on a condition (such as review or draft status) wait for the next full refresh. A new commit or a return to `running` starts a fresh 3-minute window. PRs paused in `waiting` (`Pending action`) are not hot-polled. For PRs from forks whose CI runs only in the fork, the status is read from the fork's head commit, the same source the full refresh uses.
- **In-progress or queued workflow runs** — re-checks until the run completes or pauses on an environment approval gate (`waiting`)
- **In-progress or queued workflow runs** — re-checks until the run completes or pauses on an environment approval gate (`waiting`), using conditional requests (`ETag` / `304 Not Modified`) so unchanged runs do not consume REST rate-limit budget

Default interval: **30 seconds** (configurable from 10 to 120 seconds in Settings).

Expand Down Expand Up @@ -850,7 +850,7 @@ Encrypted credentials are sealed by this deployment's server and can only be imp

The tracker uses GitHub's GraphQL and REST APIs. Each poll cycle consumes some of your 5,000 request hourly budget. Tracking many repos, tracked users, or having a short refresh interval increases consumption. Increasing the refresh interval or reducing the number of tracked repos will reduce API usage.

A 60-second events poll uses ETag conditional requests to detect changes at near-zero cost, triggering targeted per-repo refreshes only when needed.
A lightweight 60-second events poll (~1 REST request per cycle) detects new activity by comparing event IDs and triggers targeted per-repo refreshes only when needed.

For detailed per-source API call counts, see Settings > API Usage.

Expand All @@ -866,7 +866,7 @@ Your token does not have write permission for pull requests. If you are using a

**Data looks stale after switching back to the tab.**

When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. The events poll continues running in background tabs using ETag conditional requests (zero rate-limit cost), so changes are detected even while the tab is hidden.
When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. The lightweight events poll (~1 REST request per 60-second cycle) continues running in background tabs, so changes are detected even while the tab is hidden without the cost of a full refresh.

**I want to stop tracking a repository.**

Expand Down
35 changes: 32 additions & 3 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js";
import { createSignal, createEffect, on, onMount, onCleanup, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js";
import * as Sentry from "@sentry/solid";
import { Router, Route, Navigate, useNavigate } from "@solidjs/router";
import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth";
import { config, initConfigPersistence, resolveTheme } from "./stores/config";
import { initViewPersistence, pruneStaleIgnoredItems } from "./stores/view";
import { evictStaleEntries } from "./stores/cache";
import { evictStaleEntries, evictKeysWithPrefix } from "./stores/cache";
import { initClientWatcher } from "./services/github";
import { initMcpRelay } from "./lib/mcp-relay";
import LoginPage from "./pages/LoginPage";
Expand Down Expand Up @@ -173,6 +173,24 @@ export default function App() {
document.documentElement.dataset.density = config.viewDensity;
});

createEffect(
on(
() => config.selectedRepos.map((r) => r.fullName).join(","),
(currentCsv, prevCsv) => {
if (!prevCsv) return;
const currentSet = new Set(currentCsv.split(",").filter(Boolean));
const removed = prevCsv
.split(",")
.filter((r) => r.length > 0 && !currentSet.has(r));
if (removed.length > 0) {
evictKeysWithPrefix(removed.flatMap((r) => [`runs2:${r}:`, `run:${r}:`])).catch(() => {
// Non-fatal — cache eviction failure is acceptable
});
}
},
),
);

onMount(() => {
// Listen for system theme changes so "auto" reacts immediately
const mq = window.matchMedia("(prefers-color-scheme: dark)");
Expand All @@ -189,9 +207,20 @@ export default function App() {
initClientWatcher();
initMcpRelay();
pruneStaleIgnoredItems();
evictStaleEntries(24 * 60 * 60 * 1000).catch(() => {

const staleCacheMaxAgeMs = 24 * 60 * 60 * 1000;
const staleEvictionIntervalMs = 60 * 60 * 1000;
evictStaleEntries(staleCacheMaxAgeMs).catch(() => {
// Non-fatal — stale eviction failure is acceptable
});
const evictionInterval = setInterval(() => {
evictStaleEntries(staleCacheMaxAgeMs).catch(() => {
// Non-fatal — stale eviction failure is acceptable
});
}, staleEvictionIntervalMs);
onCleanup(() => {
clearInterval(evictionInterval);
});

// Preload dashboard chunk in parallel with token validation to avoid
// a sequential waterfall (validateToken → chunk fetch)
Expand Down
126 changes: 90 additions & 36 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ const ISSUE_FILTER_DEFAULTS = IssueFiltersSchema.parse({});
const PR_FILTER_DEFAULTS = PullRequestFiltersSchema.parse({});
const ACTIONS_FILTER_DEFAULTS = ActionsFiltersSchema.parse({});

export const _testCounters = { exclusiveOwnershipRuns: 0 };

/**
* Build a scope matcher for a custom tab's org/repo scope. Shared between
* customTabData and tabCounts. An empty scope (no orgs, no repos) matches
* nothing — see isTabUnscoped, which flags this state for the UI.
* exclusiveCustomTabData, activeCustomTabData, and tabCounts. An empty scope
* (no orgs, no repos) matches nothing — see isTabUnscoped, which flags this
* state for the UI.
*/
function buildTabScopeMatcher(tab: CustomTab): (repoFullName: string) => boolean {
const orgSet = tab.orgScope.length > 0 ? new Set(tab.orgScope.map((o) => o.toLowerCase())) : null;
Expand Down Expand Up @@ -129,6 +132,9 @@ const [jiraIssues, setJiraIssues] = createSignal<JiraIssue[]>([]);
const [jiraLoading, setJiraLoading] = createSignal(false);
const [jiraKeyMap, setJiraKeyMap] = createSignal<ReadonlyMap<string, JiraIssue | null>>(new Map());
let _jiraFetching = false;
let _inFlightJiraScope: string | null = null;
let _jiraPendingRerun = false;
let lastJiraFetchStartedAt = 0;

// Dependency dashboard state — module-level for same reasons as jira state above
const [abandonedDepsMap, setAbandonedDepsMap] = createSignal<Map<string, AbandonedDependency[]>>(new Map());
Expand Down Expand Up @@ -380,6 +386,9 @@ onAuthCleared(() => {
setJiraLoading(false);
setJiraKeyMap(new Map());
_jiraFetching = false;
_inFlightJiraScope = null;
_jiraPendingRerun = false;
lastJiraFetchStartedAt = 0;
setAbandonedDepsMap(new Map());
setDashboardIssueUrls(new Map());
setDepMeta(new Map());
Expand Down Expand Up @@ -695,12 +704,22 @@ export default function DashboardPage() {
);

async function fetchJiraAssigned(): Promise<void> {
if (_jiraFetching) return;
const scope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned";
if (_jiraFetching) {
if (scope !== _inFlightJiraScope) {
_jiraPendingRerun = true;
}
return;
}
const client = jiraClient();
if (!client) return;
if (!client) {
setJiraLoading(false);
return;
}
_jiraFetching = true;
_inFlightJiraScope = scope;
lastJiraFetchStartedAt = Date.now();
setJiraLoading(true);
const scope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned";
try {
const result = await client.searchJql(
jiraJqlForScope(scope),
Expand All @@ -711,6 +730,12 @@ export default function DashboardPage() {
if (missing.length > 0) console.info("[jira] issues missing issuetype:", missing.map((i) => `${i.key}: ${JSON.stringify(i.fields.issuetype)}`));
}
if (!isJiraAuthenticated()) return;
const currentScope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned";
if (currentScope !== scope) {
_jiraPendingRerun = true;
return;
}
_jiraPendingRerun = false;
setJiraIssues(result.issues);

// Only prune when the result set is complete. The Enhanced JQL Search
Expand Down Expand Up @@ -763,6 +788,7 @@ export default function DashboardPage() {
}
}
} catch (err) {
lastJiraFetchStartedAt = 0;
if (err instanceof JiraApiError) {
if (err.status === 401) {
clearJiraAuth();
Expand All @@ -782,7 +808,14 @@ export default function DashboardPage() {
}
} finally {
_jiraFetching = false;
setJiraLoading(false);
_inFlightJiraScope = null;
if (_jiraPendingRerun && config.jira?.enabled && isJiraAuthenticated()) {
_jiraPendingRerun = false;
void fetchJiraAssigned().catch(handleJiraError);
} else {
_jiraPendingRerun = false;
setJiraLoading(false);
}
}
}

Expand Down Expand Up @@ -1132,20 +1165,40 @@ export default function DashboardPage() {
new Set(dependencyPullRequests().map((pr) => pr.id))
);

// Eagerly compute scoped data for exclusive custom tabs (needed by exclusiveOwnership).
// Non-exclusive tabs only compute when they are the active tab.
const customTabData = createMemo(() => {
interface CustomTabScopedData {
issues: typeof dashboardData.issues;
pullRequests: typeof dashboardData.pullRequests;
workflowRuns: typeof dashboardData.workflowRuns;
}

function computeCustomTabEntry(tab: CustomTab): CustomTabScopedData {
const matchesScope = buildTabScopeMatcher(tab);
return {
issues: tab.baseType === "issues" ? dashboardData.issues.filter((i) => matchesScope(i.repoFullName)) : [],
pullRequests: tab.baseType === "pullRequests" ? dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)) : [],
workflowRuns: tab.baseType === "actions" ? dashboardData.workflowRuns.filter((w) => matchesScope(w.repoFullName)) : [],
};
}

// Scoped data for exclusive custom tabs only (does NOT depend on activeTab).
const exclusiveCustomTabData = createMemo(() => {
const result: Record<string, CustomTabScopedData> = {};
for (const tab of config.customTabs) {
if (!tab.exclusive) continue;
if (!config.enableActions && tab.baseType === "actions") continue;
result[tab.id] = computeCustomTabEntry(tab);
}
return result;
});

// Scoped data for the currently active non-exclusive custom tab (if any).
const activeCustomTabData = createMemo(() => {
const currentTabId = activeTab();
const result: Record<string, { issues: typeof dashboardData.issues; pullRequests: typeof dashboardData.pullRequests; workflowRuns: typeof dashboardData.workflowRuns }> = {};
const result: Record<string, CustomTabScopedData> = {};
for (const tab of config.customTabs) {
if (tab.exclusive || tab.id !== currentTabId) continue;
if (!config.enableActions && tab.baseType === "actions") continue;
if (!tab.exclusive && tab.id !== currentTabId) continue;
const matchesScope = buildTabScopeMatcher(tab);
result[tab.id] = {
issues: tab.baseType === "issues" ? dashboardData.issues.filter((i) => matchesScope(i.repoFullName)) : [],
pullRequests: tab.baseType === "pullRequests" ? dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)) : [],
workflowRuns: tab.baseType === "actions" ? dashboardData.workflowRuns.filter((w) => matchesScope(w.repoFullName)) : [],
};
result[tab.id] = computeCustomTabEntry(tab);
}
return result;
});
Expand All @@ -1154,6 +1207,8 @@ export default function DashboardPage() {
// Only claims items matching the tab's baseType (an exclusive Issues tab must
// not hide PRs or workflow runs from their respective tabs).
const exclusiveOwnership = createMemo(() => {
_testCounters.exclusiveOwnershipRuns += 1;
const exclusiveData = exclusiveCustomTabData();
const issueOwner = new Map<number, string>();
const prOwner = new Map<number, string>();
const runOwner = new Map<number, string>();
Expand All @@ -1164,7 +1219,7 @@ export default function DashboardPage() {
}
for (const tab of config.customTabs) {
if (!tab.exclusive) continue;
const data = customTabData()[tab.id];
const data = exclusiveData[tab.id];
if (!data) continue;
if (tab.baseType === "issues") {
for (const i of data.issues) if (!issueOwner.has(i.id)) issueOwner.set(i.id, tab.id);
Expand Down Expand Up @@ -1220,20 +1275,12 @@ export default function DashboardPage() {
const login = userLogin().toLowerCase();
const monitoredSet = new Set((config.monitoredRepos ?? []).map((r) => r.fullName));
const users = allUsers();
const exclusiveData = exclusiveCustomTabData();
const customCounts: Record<string, number> = {};
for (const tab of config.customTabs) {
if (!config.enableActions && tab.baseType === "actions") continue;
// customTabData skips non-exclusive inactive tabs (perf optimization),
// so compute scope on demand for tabs absent from the memo.
let data = customTabData()[tab.id];
if (!data) {
const matchesScope = buildTabScopeMatcher(tab);
data = {
issues: tab.baseType === "issues" ? dashboardData.issues.filter((i) => matchesScope(i.repoFullName)) : [],
pullRequests: tab.baseType === "pullRequests" ? dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)) : [],
workflowRuns: tab.baseType === "actions" ? dashboardData.workflowRuns.filter((w) => matchesScope(w.repoFullName)) : [],
};
}
// exclusiveCustomTabData caches exclusive tabs; compute scope on demand for non-exclusive tabs.
const data = exclusiveData[tab.id] ?? computeCustomTabEntry(tab);
// Merge filter chain via shared helper (same as tab components)
const preset = tab.filterPreset;
if (tab.baseType === "issues") {
Expand Down Expand Up @@ -1410,6 +1457,7 @@ export default function DashboardPage() {
() => _coordinator()?.lastRefreshAt(),
() => {
if (!config.jira?.enabled || !isJiraAuthenticated()) return;
if (Date.now() - lastJiraFetchStartedAt < 20_000) return;
fetchJiraAssigned().catch(handleJiraError);
},
{ defer: true }
Expand Down Expand Up @@ -1785,7 +1833,7 @@ export default function DashboardPage() {
`Could not approve ${pr.repoFullName}#${pr.number}: new commits were pushed — refreshing`,
"warning"
);
_coordinator()?.manualRefresh();
handleManualRefresh();
return;
}
const message = err instanceof Error ? err.message : "Unknown error";
Expand Down Expand Up @@ -1856,6 +1904,11 @@ export default function DashboardPage() {
}
}

function handleManualRefresh(): void {
lastJiraFetchStartedAt = 0;
_coordinator()?.manualRefresh();
}

const isPartialSync = createMemo(() =>
getNotifications().some((n) => n.source === PR_ENRICHMENT_NOTIFICATION_SOURCE)
);
Expand Down Expand Up @@ -1893,7 +1946,7 @@ export default function DashboardPage() {
<FilterBar
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
onRefresh={() => _coordinator()?.manualRefresh()}
onRefresh={handleManualRefresh}
sortOptions={activeTab() === "dependencies" ? undefined : globalSortOptions}
sortValue={activeTab() === "dependencies" ? undefined : viewState.globalSort.field}
sortDirection={activeTab() === "dependencies" ? undefined : viewState.globalSort.direction}
Expand Down Expand Up @@ -1950,7 +2003,7 @@ export default function DashboardPage() {
rebaseLabel={config.dependencies.rebaseLabel}
userLogin={userLogin()}
trackedBotLogins={trackedBotLogins()}
onRefresh={() => _coordinator()?.manualRefresh()}
onRefresh={handleManualRefresh}
onApprovePR={handleApprovePR}
approvingPRIds={approvingPRIds()}
onMarkPRReady={handleMarkPRReady}
Expand Down Expand Up @@ -1993,13 +2046,14 @@ export default function DashboardPage() {
{(tab) => {
// Apply exclusivity: exclude items owned by OTHER exclusive tabs
const data = createMemo(() => {
const raw = customTabData()[tab().id];
const tabId = tab().id;
const raw = exclusiveCustomTabData()[tabId] ?? activeCustomTabData()[tabId];
if (!raw) return { issues: [] as typeof dashboardData.issues, pullRequests: [] as typeof dashboardData.pullRequests, workflowRuns: [] as typeof dashboardData.workflowRuns };
const ownership = exclusiveOwnership();
return {
issues: raw.issues.filter((i) => isItemVisibleOnTab(ownership.issues, i.id, tab().id)),
pullRequests: raw.pullRequests.filter((p) => isItemVisibleOnTab(ownership.pullRequests, p.id, tab().id)),
workflowRuns: raw.workflowRuns.filter((w) => isItemVisibleOnTab(ownership.actions, w.id, tab().id)),
issues: raw.issues.filter((i) => isItemVisibleOnTab(ownership.issues, i.id, tabId)),
pullRequests: raw.pullRequests.filter((p) => isItemVisibleOnTab(ownership.pullRequests, p.id, tabId)),
workflowRuns: raw.workflowRuns.filter((w) => isItemVisibleOnTab(ownership.actions, w.id, tabId)),
};
});
return (
Expand Down
10 changes: 8 additions & 2 deletions src/app/components/dashboard/DependenciesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js";
import { config, updateConfig } from "../../stores/config";
import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, trackItem, untrackItem, DependencyFiltersSchema, setDependencyExpandedGroups } from "../../stores/view";
import IgnoreBadge from "./IgnoreBadge";
import { isSafeGitHubUrl } from "../../lib/url";
import { isSafeGitHubUrl, sizedAvatarUrl } from "../../lib/url";
import { canApprovePR, canMarkPRReady } from "../../lib/format";
import { isNonTerminalCheckStatus, type PullRequest } from "../../services/api";
import type { AbandonedDependency } from "../../lib/dependency-dashboard";
Expand Down Expand Up @@ -315,7 +315,13 @@ export default function DependenciesTab(props: DependenciesTabProps) {
{(bot) => (
<div class="flex items-center gap-2 px-4 py-2 bg-info/10 border-b border-base-300 text-sm">
<Show when={bot.avatarUrl}>
<img src={bot.avatarUrl} alt={bot.login} class="w-5 h-5 rounded-full" />
<img
src={sizedAvatarUrl(bot.avatarUrl, 40)}
alt={bot.login}
class="w-5 h-5 rounded-full"
loading="lazy"
decoding="async"
/>
</Show>
<span class="flex-1">
Dependency PRs from <strong>{bot.login}</strong> — track this bot for full coverage?
Expand Down
Loading
Loading