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
86 changes: 71 additions & 15 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,13 @@ export function applyHotPollPRUpdates(
// long as the underlying failure (e.g. a secondary-rate-limit stall) persists.
export const DEP_BODY_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
const _depBodyFailureCooldown = new Map<number, number>();
const _dashboardBodyCache = new Map<string, { updatedAt: string; deps: AbandonedDependency[] }>();
const _dashboardBodyFailureCooldown = new Map<string, number>();
let _bodyFetchAuthGen = 0;

// Clear dashboard data and stop polling on logout to prevent cross-user data leakage
onAuthCleared(() => {
_bodyFetchAuthGen++;
resetDashboardData();
setHasFetchedFresh(false);
setJiraIssues([]);
Expand All @@ -305,6 +309,8 @@ onAuthCleared(() => {
_fetchingDashboardBodies = false;
_fetchingDepBodies = false;
_depBodyFailureCooldown.clear();
_dashboardBodyCache.clear();
_dashboardBodyFailureCooldown.clear();
setApprovingPRIds(new Set<number>());
recentlyApprovedPRs.clear();
setMarkingReadyPRIds(new Set<number>());
Expand Down Expand Up @@ -374,6 +380,7 @@ async function pollFetch(): Promise<DashboardData> {
applyRecentApprovalGuard(pr, currentLogin);
}

let resolvedPullRequests: PullRequest[];
if (phaseOneFired) {
// Phase 1 fired — use fine-grained merge for the light→enriched
// transition. Only update heavy fields to avoid re-rendering the
Expand Down Expand Up @@ -422,17 +429,19 @@ async function pollFetch(): Promise<DashboardData> {
}
}
}));
resolvedPullRequests = unwrap(dashboardData.pullRequests).map((pr) => ({ ...pr }));
} else {
// Phase 1 did NOT fire (cached data existed or subsequent poll).
// Full atomic replacement — all fields (light + heavy) may have
// changed since the last cycle. Preserve scroll position: SolidJS
// DOM updates are synchronous within the setter, so save/restore
// around it to prevent scroll reset from <For> DOM rebuild.
const pullRequests = fallbackToPreviousEnrichment(dashboardData.pullRequests, data.pullRequests);
const pullRequests = fallbackToPreviousEnrichment(unwrap(dashboardData.pullRequests), data.pullRequests);
for (const pr of pullRequests) {
applyRecentReadyGuard(pr);
applyRecentApprovalGuard(pr, currentLogin);
}
resolvedPullRequests = pullRequests;
withScrollLock(() => {
setDashboardData({
issues: data.issues,
Expand All @@ -446,19 +455,19 @@ async function pollFetch(): Promise<DashboardData> {
const filteredRuns = config.enableActions ? data.workflowRuns : [];
const hotPullRequests =
recentlyMarkedReadyPRs.size > 0 || recentlyApprovedPRs.size > 0
? data.pullRequests.map((pr) =>
? resolvedPullRequests.map((pr) =>
(recentlyMarkedReadyPRs.has(pr.id) || recentlyApprovedPRs.has(pr.id)) && pr.nodeId
? { ...pr, enriched: true, checkStatus: "pending" as const }
: pr
)
: data.pullRequests;
: resolvedPullRequests;
rebuildHotSets({ ...data, pullRequests: hotPullRequests, workflowRuns: filteredRuns });
// Persist for stale-while-revalidate on full page reload.
// Errors are transient and not persisted. Deferred to avoid blocking paint.
const cachePayload = {
_v: CACHE_VERSION,
issues: data.issues,
pullRequests: data.pullRequests,
pullRequests: resolvedPullRequests,
workflowRuns: filteredRuns,
lastRefreshedAt: now.toISOString(),
};
Expand Down Expand Up @@ -1339,31 +1348,74 @@ export default function DashboardPage() {
const octokit = getClient();
if (!octokit) return;
_fetchingDashboardBodies = true;
const authGen = _bodyFetchAuthGen;
void (async () => {
try {
const dashboardIssues = findDashboardIssues(dashboardData.issues, trackedBotLogins());
const activeNodeIds = new Set(dashboardIssues.map((di) => di.nodeId));
for (const k of [..._dashboardBodyCache.keys()]) {
if (!activeNodeIds.has(k)) _dashboardBodyCache.delete(k);
}
for (const k of [..._dashboardBodyFailureCooldown.keys()]) {
if (!activeNodeIds.has(k)) _dashboardBodyFailureCooldown.delete(k);
}

const depRepos = new Set(visibleDependencyPullRequests().map((pr) => pr.repoFullName));
const relevant = dashboardIssues.filter((di) => depRepos.has(di.repoFullName));
if (relevant.length === 0) return;
if (relevant.length === 0) {
if (abandonedDepsMap().size > 0) setAbandonedDepsMap(new Map());
if (dashboardIssueUrls().size > 0) setDashboardIssueUrls(new Map());
return;
}

const nodeIds = relevant.map((di) => di.nodeId);
const { bodies: bodyMap } = await fetchDashboardIssueBodies(octokit, nodeIds);
const now = Date.now();
const toFetch = relevant.filter((di) => {
if (_dashboardBodyCache.get(di.nodeId)?.updatedAt === di.updatedAt) return false;
const failedAt = _dashboardBodyFailureCooldown.get(di.nodeId);
return !failedAt || now - failedAt >= DEP_BODY_FAILURE_COOLDOWN_MS;
});

let failedCount = 0;
if (toFetch.length > 0) {
const { bodies: bodyMap, failedIds } = await fetchDashboardIssueBodies(
octokit,
toFetch.map((di) => di.nodeId)
);
if (authGen !== _bodyFetchAuthGen || getClient() !== octokit) return;
failedCount = failedIds.size;
for (const di of toFetch) {
if (failedIds.has(di.nodeId)) {
_dashboardBodyFailureCooldown.set(di.nodeId, now);
} else {
_dashboardBodyFailureCooldown.delete(di.nodeId);
}
if (bodyMap.has(di.nodeId)) {
_dashboardBodyCache.set(di.nodeId, {
updatedAt: di.updatedAt,
deps: parseAbandonedSection(bodyMap.get(di.nodeId) ?? ""),
});
}
}
}

const newAbandonedMap = new Map<string, AbandonedDependency[]>();
const newUrlMap = new Map<string, string>();
for (const di of relevant) {
const body = bodyMap.get(di.nodeId);
if (body != null) {
newAbandonedMap.set(di.repoFullName, parseAbandonedSection(body));
const cached = _dashboardBodyCache.get(di.nodeId);
if (cached) {
newAbandonedMap.set(di.repoFullName, cached.deps);
}
newUrlMap.set(di.repoFullName, di.htmlUrl);
}
// Don't replace valid data with empty results from a failed fetch
if (bodyMap.size === 0 && relevant.length > 0) return;
setAbandonedDepsMap(newAbandonedMap);
setDashboardIssueUrls(newUrlMap);
// Don't replace valid data with empty results from a failed fetch
if (!(newAbandonedMap.size === 0 && abandonedDepsMap().size > 0 && failedCount > 0)) {
setAbandonedDepsMap(newAbandonedMap);
}
} finally {
_fetchingDashboardBodies = false;
if (authGen === _bodyFetchAuthGen) {
_fetchingDashboardBodies = false;
}
}
})();
},
Expand Down Expand Up @@ -1408,12 +1460,14 @@ export default function DashboardPage() {
}

_fetchingDepBodies = true;
const authGen = _bodyFetchAuthGen;
const effectStart = Date.now();
console.debug(`[dashboard] depBodies effect: fetch started for ${toFetch.length} PRs at ${effectStart}`);
void (async () => {
try {
const nodeIds = toFetch.map((pr) => pr.nodeId!);
const { bodies: bodyMap, failedIds } = await fetchDepPRBodies(octokit, nodeIds);
if (authGen !== _bodyFetchAuthGen || getClient() !== octokit) return;
console.debug(`[dashboard] depBodies effect: fetch resolved after ${Date.now() - effectStart}ms`, { requested: toFetch.length, returned: bodyMap.size, failed: failedIds.size });

// Record/clear per-PR cooldown so a batch that errored or timed out isn't
Expand Down Expand Up @@ -1441,7 +1495,9 @@ export default function DashboardPage() {
console.debug(`[dashboard] depBodies effect: depMeta updated, size=${merged.size}`);
setTimeout(() => persistDepMeta(merged), 0);
} finally {
_fetchingDepBodies = false;
if (authGen === _bodyFetchAuthGen) {
_fetchingDepBodies = false;
}
console.debug(`[dashboard] depBodies effect: guard released after ${Date.now() - effectStart}ms`);
}
})();
Expand Down
3 changes: 2 additions & 1 deletion src/app/lib/dependency-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function escapeRegex(s: string): string {
export function findDashboardIssues(
issues: Issue[],
botLogins: Set<string>
): { issueNumber: number; nodeId: string; repoFullName: string; htmlUrl: string }[] {
): { issueNumber: number; nodeId: string; repoFullName: string; htmlUrl: string; updatedAt: string }[] {
const allBotLogins = new Set([...KNOWN_DEP_BOT_LOGINS, ...botLogins]);
return issues
.filter((issue) => {
Expand All @@ -35,6 +35,7 @@ export function findDashboardIssues(
nodeId: issue.nodeId!,
repoFullName: issue.repoFullName,
htmlUrl: issue.htmlUrl,
updatedAt: issue.updatedAt,
}));
}

Expand Down
12 changes: 8 additions & 4 deletions src/app/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,14 @@ function fireNotification(
tag: string,
url?: string
): void {
const n = new Notification(title, { body, tag, icon: "/assets/logo-192.png" });
if (url) {
const safeUrl = url;
n.onclick = () => openUrl(safeUrl);
try {
const n = new Notification(title, { body, tag, icon: "/assets/logo-192.png" });
if (url) {
const safeUrl = url;
n.onclick = () => openUrl(safeUrl);
}
} catch (err) {
console.debug("[notifications] Notification constructor failed:", err);
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/app/lib/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,37 @@ const ALLOWED_CONSOLE_PREFIXES = [
"[notifications]",
];

const BACKGROUND_POLL_SOURCES = new Set([
"prEnrichment",
"dashboardBodies",
"depPRBodies",
"pr-pagination",
"poll-cycle",
"hot-poll-pr-batch",
"hot-poll-pr-fetch",
"hot-poll-run-fetch",
]);

function isBrowserFetchDisconnectMessage(value: string | undefined): boolean {
if (!value) return false;
return (
/^(?:Failed to fetch|Load failed)(?:\s*\([^)]+\))?$/i.test(value) ||
/^NetworkError when attempting to fetch resource\.?$/i.test(value)
);
}

export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null {
const source = event.tags?.source;
if (typeof source === "string" && BACKGROUND_POLL_SOURCES.has(source)) {
const values = event.exception?.values;
if (
values &&
values.length > 0 &&
values.every((ex) => isBrowserFetchDisconnectMessage(ex.value))
) {
return null;
}
}
// Strip OAuth params from captured URLs
if (event.request?.url) {
event.request.url = scrubUrl(event.request.url);
Expand Down
4 changes: 3 additions & 1 deletion src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,9 @@ export async function fetchPREnrichment(

export const GRAPHQL_BODY_FETCH_TIMEOUT_MS = 20_000;

export class GraphqlFetchTimeoutError extends Error {}
export class GraphqlFetchTimeoutError extends Error {
override readonly name = "GraphqlFetchTimeoutError";
}

export function raceWithTimeout<T>(promise: Promise<T>, ms: number, controller: AbortController): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
Expand Down
Loading
Loading