diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c531521..9858bcb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ In production, provision `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET` as Worker sec The repo uses a pnpm workspace: the root package is the SolidJS SPA; `mcp/` is a separate package (`github-tracker-mcp`) built with tsup. Running `pnpm install` at the root installs both. +`pnpm-workspace.yaml` sets `minimumReleaseAge: 10080`, so dependency resolution only picks package versions published at least 7 days ago. pnpm applies this to every resolution, including lockfile regeneration (Renovate's lockfile maintenance runs pnpm with this workspace config), which keeps `pnpm-lock.yaml` installable under local supply-chain cooldowns of up to 7 days. If an install reports `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`, the lockfile was produced without this policy — regenerate it rather than relaxing the policy. + To run the MCP server in standalone mode, set `GITHUB_TOKEN` before starting: ```bash diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d1f3a1fa..d02b242c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -89,7 +89,7 @@ To replace your token later (e.g., after expiration or rotation), go to **Settin ### Repository Selection -After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. Forked repositories are displayed with a muted name and a `Fork` badge so you can distinguish them from source repositories at a glance. Archived repositories are automatically excluded from the list (and removed from your selection if a previously tracked repository is archived on GitHub). You can change your selection at any time in **Settings > Repositories**. +After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. Forked repositories are displayed with a muted name and a `Fork` badge so you can distinguish them from source repositories at a glance. Archived repositories are automatically excluded from the list. If a tracked repository is later archived on GitHub, its issues and PRs disappear from the dashboard on the next refresh (and are removed from your Tracked items), and the repository is removed from your selection the next time you open the repository list. You can change your selection at any time in **Settings > Repositories**. ### Organization Access @@ -237,7 +237,7 @@ Draft PRs authored by other users render as a read-only indicator. #### Inline Approve Action -Open, non-draft pull requests not authored by you show an **Approve** button next to the pull request title on hover when branch protection requires a review (`Review needed`) or when a review has been requested of you or your team (unless you have already approved the PR without a fresh re-request). The **Approve** button is hidden when CI checks are failing (`checkStatus === "failure"`). +Open, non-draft pull requests not authored by you show an **Approve** button next to the pull request title on hover when branch protection requires a review (`Review needed`) or when a review has been requested of you or your team (unless you have already approved the PR without a fresh re-request). The **Approve** button is hidden when CI checks are failing (`checkStatus === "failure"`) or while the app is still re-fetching the PR's CI check status after a partial sync (`checkStatusStale`); it reappears once the next refresh or hot poll restores the check status. Approval uses a two-step inline confirmation to prevent accidental clicks: 1. Click **Approve** once to arm confirmation (**Confirm?**, or **Confirm (auto)** when auto-merge is enabled on the PR). The confirmation state resets automatically after 4 seconds, on Escape, or when focus leaves the button. @@ -328,16 +328,18 @@ Unlike the Pull Requests tab (which groups by repo), the Dependencies tab groups |-------|----------| | **Mergeable** | CI passing (or no CI configured), not a draft, and no changes requested — ready to review or merge | | **Pending Rebase** | PR has the configured rebase label — waiting for the bot to rebase | -| **Needs Action** | Failing or running CI checks, merge conflicts, draft PR, or changes requested — rows display explicit reason badges (`Failing checks`, `Merge conflict`, `Checks running`, `Draft`, `Changes`) | +| **Needs Action** | Failing or running CI checks, check status still being fetched by the app, merge conflicts, draft PR, or changes requested — rows display explicit reason badges (`Failing checks`, `Merge conflict`, `Checks running`, `Checking status`, `Draft`, `Changes`) | | **Stale** | PR has not been updated in over 14 days — may need a rebase or manual review | +Reason badges reflect each PR's current state, so the same badges also appear on rows in the **Pending Rebase** and **Stale** groups. On initial load, and during a refresh in which no PR's details have loaded yet, `Checking status` is withheld on dependency PRs without details, to avoid a flash of the badge on every row. + Within each group, PRs are sorted by repository name, then update category (maintenance, pin, digest, patch, minor, major), then update date. ### Abandoned Dependencies If a Renovate Dashboard issue is detected in one of your tracked repos, abandoned dependency entries from its "Abandoned" section are shown as pill badges on matching PR rows. Each pill links directly to the Renovate Dashboard issue so you can investigate further. -The parser reads the Renovate Dashboard issue body to extract package names from the abandoned dependencies table. Repos excluded from the Dependencies tab (see Dependencies Settings below) are skipped for abandoned-package detection too — their Renovate Dashboard issue is never checked. +The parser reads the Renovate Dashboard issue body to extract package names from the abandoned dependencies table and caches the parsed entries in `localStorage` (`github-tracker:dashboard-bodies`) keyed by issue `updatedAt`, so page reloads display `Abandoned` badges immediately without re-fetching unchanged Renovate Dependency Dashboard issue bodies. Repos excluded from the Dependencies tab (see Dependencies Settings below) are skipped for abandoned-package detection too — their Renovate Dashboard issue is never checked. ### Dependencies Settings @@ -401,7 +403,11 @@ Upstream repos are included in Issues and Pull Requests fetches but excluded fro ### Full Refresh -The dashboard polls GitHub at a configurable interval (default: **5 minutes**). Each full refresh fetches all issues, PRs, and workflow runs. You can trigger a manual refresh by clicking the refresh button in the header. +The dashboard polls GitHub at a configurable interval (default: **5 minutes**). Each full refresh fetches all issues, PRs, and workflow runs. You can trigger a manual refresh by clicking the refresh button in the toolbar. + +PR enrichment queries (reviewer lists, CI check rollups, merge conflict status, and line-change counts) run in 25-PR batches. If GitHub's GraphQL API is slow, returns a server error, or the request fails outright (`Failed to fetch`, which is how GitHub gateway timeouts appear in the browser), a failing batch is split in half repeatedly until batches are 6 PRs or fewer, then a lightweight fallback query fills in everything except CI check and merge-conflict status. Once a second batch in the same refresh fails before any PR's full details have loaded, later failing batches skip splitting and go straight to the fallback. Dependency PR and Renovate Dashboard body fetches also run in 25-node batches and split failing batches the same way (except after the 45-second safety timeout); any items still failing are retried once a 5-minute cooldown passes. Settled PR enrichments — where CI checks are not pending and the PR's `updatedAt` timestamp is unchanged — are cached in memory for your refresh interval plus one minute (at least 3 minutes), so a settled PR skips the enrichment query on the next automatic poll and is re-fetched on the one after (the cache is also invalidated automatically when repository events or PR actions occur). Changes that don't update a PR's `updatedAt` — such as a CI re-run, or a merge conflict caused by new commits on its base branch — aren't picked up while the cached entry is valid, so such a PR can show its previous check or merge status (including the **Approve** button) for up to about two refresh intervals. Clicking the manual **Refresh** button always bypasses this cache. + +A subtle amber **Partial sync** indicator appears next to the Refresh button, while cached PR details are retained, when PR enrichment fails during a refresh and either leaves any PR without details or with unverified CI check status, or is rejected outright by GitHub (for example, a permissions error); otherwise it appears once enrichment failures persist across 3 consecutive polls. PRs whose CI check status couldn't be verified — filled in by the lightweight fallback query, changed (new `headSha` or `updatedAt`) during a degraded backfill, or forks whose CI lookup failed — have their check status cleared (`checkStatusStale`) rather than carried forward: they show `Checking status`, hide **Approve**, and are resolved automatically by the next hot-poll cycle or successful refresh. The indicator clears on the next full refresh whose PR enrichment succeeds, unless a search failure in that refresh leaves a PR still in this unverified state. Setting the interval to **Off** disables automatic polling; manual refresh still works. @@ -415,7 +421,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 pending CI checks** — re-checks status until checks resolve +- **PRs with pending or stale (`checkStatusStale`) CI checks** — re-checks status until checks resolve (including PRs whose commit changed during a degraded backfill). 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 workflow runs** — re-checks until the run completes Default interval: **30 seconds** (configurable from 10 to 120 seconds in Settings). @@ -442,7 +448,7 @@ When the tab is hidden: - The **full refresh pauses** in background tabs — GraphQL requests have no 304 shortcut and every poll consumes real rate-limit budget. - The **events poll continues in background** — it is lightweight enough to run even when the tab is hidden, giving you near-real-time change detection without the cost of a full refresh. -When you return to a tab that has been hidden for more than 2 minutes, a catch-up full refresh fires immediately regardless of where the timer is in its cycle. +When you return to a tab that has been hidden for more than 2 minutes, the dashboard waits a brief 2-second grace period (so the OS network connection can stabilize after waking from sleep) before firing a catch-up full refresh regardless of where the timer is in its cycle. Automatic refreshes also pause while the browser is offline and resume automatically once connectivity returns. --- @@ -452,6 +458,8 @@ When you return to a tab that has been hidden for more than 2 minutes, a catch-u The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle. +Transient single-cycle PR enrichment blips are absorbed silently when cached PR details are available. Under the same conditions that show the **Partial sync** indicator (see [Full Refresh](#full-refresh)), a silent warning entry is logged to the Notification Drawer without popping a disruptive toast. Dependency PR and Renovate Dashboard body fetches similarly use 25-node batches with adaptive binary splitting and log a silent warning to the Notification Drawer only after 3 consecutive failing fetch cycles. For notifications that do trigger a toast, repeated identical toasts for the same source are rate-limited to once per 15 minutes. + ### GitHub Outage Notifications When a GitHub outage affecting a tracked service starts, a toast appears and an entry is added to the notification drawer. A second notification confirms it once the outage resolves. These always appear via the in-app toast and drawer — they are not part of the **Browser Push Notifications** toggles below and cannot be disabled. @@ -486,7 +494,7 @@ The Tracked tab lets you pin issues and PRs into a personal TODO list that you c **Reordering:** Use the chevron buttons on the left side of each row to move items up or down. Items slide smoothly into their new position. -**Auto-removal:** When a tracked issue is closed or a tracked PR is merged, it is automatically removed from the list. Closure is detected by absence from the `is:open` poll results. For PRs detected as closed by the hot poll, removal happens within seconds. Auto-removal is suspended when the API returns errors (e.g., rate limiting) to prevent false pruning. +**Auto-removal:** When a tracked issue is closed or a tracked PR is merged, it is automatically removed from the list. Closure is detected by absence from the `is:open` poll results, so items from a selected repository that is archived or deleted on GitHub (or that you lose access to) are also removed. For PRs detected as closed by the hot poll, removal happens within seconds. Auto-removal is suspended when the API returns errors (e.g., rate limiting) to prevent false pruning. **Relationship to other features:** The Tracked tab bypasses the org/repo filter — it always shows all your pinned items regardless of which repo filter is active. Ignoring an item from the Issues or Pull Requests tab also removes it from the tracked list. The tracked list is preserved when tracking is disabled and restored when re-enabled. diff --git a/e2e/capture-screenshot.spec.ts b/e2e/capture-screenshot.spec.ts index 44bb4d98..4f6e45c8 100644 --- a/e2e/capture-screenshot.spec.ts +++ b/e2e/capture-screenshot.spec.ts @@ -127,7 +127,7 @@ const heavyPRNodes = [ changedFiles: 8, comments: { totalCount: 6 }, reviewThreads: { totalCount: 1 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 100002, @@ -145,7 +145,7 @@ const heavyPRNodes = [ changedFiles: 4, comments: { totalCount: 12 }, reviewThreads: { totalCount: 3 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 100003, @@ -160,7 +160,7 @@ const heavyPRNodes = [ changedFiles: 31, comments: { totalCount: 2 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }, { databaseId: 100004, @@ -178,7 +178,7 @@ const heavyPRNodes = [ changedFiles: 3, comments: { totalCount: 1 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 100005, @@ -193,7 +193,7 @@ const heavyPRNodes = [ changedFiles: 14, comments: { totalCount: 8 }, reviewThreads: { totalCount: 2 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + statusCheckRollup: { state: "FAILURE" }, }, { databaseId: 100006, @@ -208,7 +208,7 @@ const heavyPRNodes = [ changedFiles: 6, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + statusCheckRollup: null, }, ]; @@ -802,7 +802,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 12, deletions: 8, changedFiles: 2, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { // webpack → mergeable (CI success, review required but not approved) @@ -815,7 +815,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 45, deletions: 12, changedFiles: 3, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { // typescript → needs-action (CI failure) @@ -828,7 +828,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 3, deletions: 3, changedFiles: 1, comments: { totalCount: 2 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + statusCheckRollup: { state: "FAILURE" }, }, { // react monorepo → needs-action (CI pending) @@ -841,7 +841,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 89, deletions: 34, changedFiles: 8, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }, { // axios → pending-rebase (has "rebase" label, CI success) @@ -854,7 +854,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 18, deletions: 6, changedFiles: 2, comments: { totalCount: 1 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { // @types/node → stale (updatedAt 20 days ago) @@ -867,7 +867,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 2, deletions: 2, changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }, { // eslint → stale (updatedAt 35 days ago, major bump) @@ -880,7 +880,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 0, nodes: [] }, additions: 312, deletions: 145, changedFiles: 18, comments: { totalCount: 5 }, reviewThreads: { totalCount: 1 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + statusCheckRollup: { state: "FAILURE" }, }, ]; @@ -932,7 +932,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 1, nodes: [{ author: { login: "msmith" } }] }, additions: 312, deletions: 47, changedFiles: 8, comments: { totalCount: 6 }, reviewThreads: { totalCount: 1 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 100002, @@ -943,7 +943,7 @@ test("capture dependencies screenshot", async ({ page }) => { latestReviews: { totalCount: 1, nodes: [{ author: { login: "jdoe" } }] }, additions: 89, deletions: 23, changedFiles: 4, comments: { totalCount: 12 }, reviewThreads: { totalCount: 3 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, ]; diff --git a/mcp/src/data-source.ts b/mcp/src/data-source.ts index 4f3fc649..f2f49693 100644 --- a/mcp/src/data-source.ts +++ b/mcp/src/data-source.ts @@ -263,7 +263,7 @@ export class OctokitDataSource implements DataSource { const batchResults = await Promise.allSettled( batches.map((batch) => { const repoFilter = batch.map((r) => `repo:${r.owner}/${r.name}`).join("+"); - const q = `is:pr+is:open+${userQualifier}+${repoFilter}`; + const q = `is:pr+is:open+archived:false+${userQualifier}+${repoFilter}`; return this.octokit.request("GET /search/issues", { q, per_page: 100 }).then(({ data }) => { const items = (data as { items: SearchItem[] }).items ?? []; const prs: PullRequest[] = []; @@ -314,7 +314,7 @@ export class OctokitDataSource implements DataSource { const batchResults = await Promise.allSettled( batches.map((batch) => { const repoFilter = batch.map((r) => `repo:${r.owner}/${r.name}`).join("+"); - const q = `is:issue+is:open+involves:${login}+${repoFilter}`; + const q = `is:issue+is:open+archived:false+involves:${login}+${repoFilter}`; return this.octokit.request("GET /search/issues", { q, per_page: 100 }).then(({ data }) => { const items = (data as { items: SearchItem[] }).items ?? []; const issues: Issue[] = []; @@ -468,9 +468,9 @@ export class OctokitDataSource implements DataSource { const approvedUnmergedCount = 0; const [prResult, issueResult, reviewResult] = await Promise.allSettled([ - this.octokit.request("GET /search/issues", { q: `is:pr+is:open${involvesPart}+${repoFilter}`, per_page: 1 }), - this.octokit.request("GET /search/issues", { q: `is:issue+is:open${involvesPart}+${repoFilter}`, per_page: 1 }), - this.octokit.request("GET /search/issues", { q: `is:pr+is:open+review-requested:${login}+${repoFilter}`, per_page: 1 }), + this.octokit.request("GET /search/issues", { q: `is:pr+is:open+archived:false${involvesPart}+${repoFilter}`, per_page: 1 }), + this.octokit.request("GET /search/issues", { q: `is:issue+is:open+archived:false${involvesPart}+${repoFilter}`, per_page: 1 }), + this.octokit.request("GET /search/issues", { q: `is:pr+is:open+archived:false+review-requested:${login}+${repoFilter}`, per_page: 1 }), ]); if (prResult.status === "fulfilled") { diff --git a/mcp/tests/data-source.test.ts b/mcp/tests/data-source.test.ts index d78edbfd..10c9e09f 100644 --- a/mcp/tests/data-source.test.ts +++ b/mcp/tests/data-source.test.ts @@ -207,6 +207,35 @@ describe("OctokitDataSource", () => { })); }); + it("excludes archived repositories from PR, issue, and summary searches", async () => { + setCachedConfig({ + selectedRepos: [{ owner: "myorg", name: "myrepo", fullName: "myorg/myrepo" }], + trackedUsers: [], + upstreamRepos: [], + monitoredRepos: [], + enableActions: false, + }); + const responses = new Map([ + ["GET /user", makeUserResponse()], + ["GET /search/issues", makeSearchResponse([])], + ]); + const octokit = makeMockOctokit(responses); + const ds = new OctokitDataSource(octokit); + + await ds.getOpenPRs("myorg/myrepo"); + await ds.getOpenPRs("myorg/myrepo", "needs_review"); + await ds.getOpenIssues("myorg/myrepo"); + await ds.getDashboardSummary("involves_me"); + + const searchQueries = (octokit.request as ReturnType).mock.calls + .filter(([route]) => route === "GET /search/issues") + .map(([, params]) => (params as { q: string }).q); + expect(searchQueries.length).toBeGreaterThanOrEqual(6); + for (const q of searchQueries) { + expect(q).toContain("archived:false"); + } + }); + it("returns empty array when config has no repos and no explicit repo", async () => { // setCachedConfig with empty selectedRepos → resolveRepos returns [] setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dc207d3..443bc64b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,7 +290,7 @@ importers: version: 8.18.2 tsup: specifier: 8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.32)(supports-color@10.2.2)(tsx@4.23.15)(typescript@7.0.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@7.0.2) tsx: specifier: 4.23.15 version: 4.23.15 @@ -351,8 +351,8 @@ packages: resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.11': - resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.29.7': @@ -367,8 +367,8 @@ packages: resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.11': - resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true @@ -390,8 +390,8 @@ packages: resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.11': - resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@cloudflare/kv-asset-handler@0.5.0': @@ -1674,141 +1674,141 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.66.0': - resolution: {integrity: sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==} + '@rollup/rollup-android-arm-eabi@4.65.0': + resolution: {integrity: sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.66.0': - resolution: {integrity: sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==} + '@rollup/rollup-android-arm64@4.65.0': + resolution: {integrity: sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.66.0': - resolution: {integrity: sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==} + '@rollup/rollup-darwin-arm64@4.65.0': + resolution: {integrity: sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.66.0': - resolution: {integrity: sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==} + '@rollup/rollup-darwin-x64@4.65.0': + resolution: {integrity: sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.66.0': - resolution: {integrity: sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==} + '@rollup/rollup-freebsd-arm64@4.65.0': + resolution: {integrity: sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.66.0': - resolution: {integrity: sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==} + '@rollup/rollup-freebsd-x64@4.65.0': + resolution: {integrity: sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.66.0': - resolution: {integrity: sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.65.0': + resolution: {integrity: sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.66.0': - resolution: {integrity: sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==} + '@rollup/rollup-linux-arm-musleabihf@4.65.0': + resolution: {integrity: sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.66.0': - resolution: {integrity: sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==} + '@rollup/rollup-linux-arm64-gnu@4.65.0': + resolution: {integrity: sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.66.0': - resolution: {integrity: sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==} + '@rollup/rollup-linux-arm64-musl@4.65.0': + resolution: {integrity: sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.66.0': - resolution: {integrity: sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==} + '@rollup/rollup-linux-loong64-gnu@4.65.0': + resolution: {integrity: sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.66.0': - resolution: {integrity: sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==} + '@rollup/rollup-linux-loong64-musl@4.65.0': + resolution: {integrity: sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.66.0': - resolution: {integrity: sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==} + '@rollup/rollup-linux-ppc64-gnu@4.65.0': + resolution: {integrity: sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.66.0': - resolution: {integrity: sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==} + '@rollup/rollup-linux-ppc64-musl@4.65.0': + resolution: {integrity: sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.66.0': - resolution: {integrity: sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==} + '@rollup/rollup-linux-riscv64-gnu@4.65.0': + resolution: {integrity: sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.66.0': - resolution: {integrity: sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==} + '@rollup/rollup-linux-riscv64-musl@4.65.0': + resolution: {integrity: sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.66.0': - resolution: {integrity: sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==} + '@rollup/rollup-linux-s390x-gnu@4.65.0': + resolution: {integrity: sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.66.0': - resolution: {integrity: sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==} + '@rollup/rollup-linux-x64-gnu@4.65.0': + resolution: {integrity: sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.66.0': - resolution: {integrity: sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==} + '@rollup/rollup-linux-x64-musl@4.65.0': + resolution: {integrity: sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.66.0': - resolution: {integrity: sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==} + '@rollup/rollup-openbsd-x64@4.65.0': + resolution: {integrity: sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.66.0': - resolution: {integrity: sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==} + '@rollup/rollup-openharmony-arm64@4.65.0': + resolution: {integrity: sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.66.0': - resolution: {integrity: sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==} + '@rollup/rollup-win32-arm64-msvc@4.65.0': + resolution: {integrity: sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.66.0': - resolution: {integrity: sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==} + '@rollup/rollup-win32-ia32-msvc@4.65.0': + resolution: {integrity: sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.66.0': - resolution: {integrity: sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==} + '@rollup/rollup-win32-x64-gnu@4.65.0': + resolution: {integrity: sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.66.0': - resolution: {integrity: sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==} + '@rollup/rollup-win32-x64-msvc@4.65.0': + resolution: {integrity: sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==} cpu: [x64] os: [win32] @@ -2280,8 +2280,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.34: - resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==} + baseline-browser-mapping@2.11.31: + resolution: {integrity: sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==} engines: {node: '>=6.0.0'} hasBin: true @@ -2302,8 +2302,8 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} - browserslist@4.29.5: - resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + browserslist@4.29.4: + resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2441,8 +2441,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.467: - resolution: {integrity: sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==} + electron-to-chromium@1.5.463: + resolution: {integrity: sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -2606,8 +2606,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.14: - resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} engines: {node: '>=16.9.0'} html-entities@2.3.3: @@ -2627,8 +2627,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.7.5: - resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2851,8 +2851,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.5: - resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -3023,8 +3023,8 @@ packages: yaml: optional: true - postcss@8.5.32: - resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -3038,8 +3038,8 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} - qs@6.17.0: - resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -3070,8 +3070,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.66.0: - resolution: {integrity: sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==} + rollup@4.65.0: + resolution: {integrity: sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3526,10 +3526,10 @@ snapshots: '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.10 - '@babel/parser': 7.29.11 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/traverse': 7.29.10(supports-color@10.2.2) - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -3541,8 +3541,8 @@ snapshots: '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.11 - '@babel/types': 7.29.11 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -3551,7 +3551,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.29.5 + browserslist: 4.29.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -3559,12 +3559,12 @@ snapshots: '@babel/helper-module-imports@7.18.6': dependencies: - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: '@babel/traverse': 7.29.10(supports-color@10.2.2) - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3579,7 +3579,7 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-string-parser@7.29.11': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3588,11 +3588,11 @@ snapshots: '@babel/helpers@7.29.10': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 - '@babel/parser@7.29.11': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: @@ -3604,24 +3604,24 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.11 - '@babel/types': 7.29.11 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@babel/traverse@7.29.10(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.11 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/types@7.29.11': + '@babel/types@7.29.8': dependencies: - '@babel/helper-string-parser': 7.29.11 + '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@cloudflare/kv-asset-handler@0.5.0': {} @@ -3960,9 +3960,9 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@hono/node-server@2.1.5(hono@4.13.14)': + '@hono/node-server@2.1.5(hono@4.13.13)': dependencies: - hono: 4.13.14 + hono: 4.13.13 '@img/colour@1.1.0': {} @@ -4226,7 +4226,7 @@ snapshots: '@modelcontextprotocol/sdk@1.32.0(supports-color@10.2.2)(zod@4.6.6)': dependencies: - '@hono/node-server': 2.1.5(hono@4.13.14) + '@hono/node-server': 2.1.5(hono@4.13.13) ajv: 8.20.0 ajv-formats: 3.0.1 content-type: 1.0.5 @@ -4236,7 +4236,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.3.0(supports-color@10.2.2) express-rate-limit: 8.7.1(express@5.3.0(supports-color@10.2.2))(supports-color@10.2.2) - hono: 4.13.14 + hono: 4.13.13 jose: 6.2.13 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4457,79 +4457,79 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.66.0': + '@rollup/rollup-android-arm-eabi@4.65.0': optional: true - '@rollup/rollup-android-arm64@4.66.0': + '@rollup/rollup-android-arm64@4.65.0': optional: true - '@rollup/rollup-darwin-arm64@4.66.0': + '@rollup/rollup-darwin-arm64@4.65.0': optional: true - '@rollup/rollup-darwin-x64@4.66.0': + '@rollup/rollup-darwin-x64@4.65.0': optional: true - '@rollup/rollup-freebsd-arm64@4.66.0': + '@rollup/rollup-freebsd-arm64@4.65.0': optional: true - '@rollup/rollup-freebsd-x64@4.66.0': + '@rollup/rollup-freebsd-x64@4.65.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.66.0': + '@rollup/rollup-linux-arm-gnueabihf@4.65.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.66.0': + '@rollup/rollup-linux-arm-musleabihf@4.65.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.66.0': + '@rollup/rollup-linux-arm64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.66.0': + '@rollup/rollup-linux-arm64-musl@4.65.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.66.0': + '@rollup/rollup-linux-loong64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.66.0': + '@rollup/rollup-linux-loong64-musl@4.65.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.66.0': + '@rollup/rollup-linux-ppc64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.66.0': + '@rollup/rollup-linux-ppc64-musl@4.65.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.66.0': + '@rollup/rollup-linux-riscv64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.66.0': + '@rollup/rollup-linux-riscv64-musl@4.65.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.66.0': + '@rollup/rollup-linux-s390x-gnu@4.65.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.66.0': + '@rollup/rollup-linux-x64-gnu@4.65.0': optional: true - '@rollup/rollup-linux-x64-musl@4.66.0': + '@rollup/rollup-linux-x64-musl@4.65.0': optional: true - '@rollup/rollup-openbsd-x64@4.66.0': + '@rollup/rollup-openbsd-x64@4.65.0': optional: true - '@rollup/rollup-openharmony-arm64@4.66.0': + '@rollup/rollup-openharmony-arm64@4.65.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.66.0': + '@rollup/rollup-win32-arm64-msvc@4.65.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.66.0': + '@rollup/rollup-win32-ia32-msvc@4.65.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.66.0': + '@rollup/rollup-win32-x64-gnu@4.65.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.66.0': + '@rollup/rollup-win32-x64-msvc@4.65.0': optional: true '@sentry/browser-utils@11.4.0': @@ -4770,24 +4770,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.11 - '@babel/types': 7.29.11 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.11 - '@babel/types': 7.29.11 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 '@types/chai@5.2.3': dependencies: @@ -4873,7 +4873,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.5 + magic-string: 1.4.3 optionalDependencies: vite: 8.3.3(@types/node@26.6.4)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15) @@ -4882,7 +4882,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.5 + magic-string: 1.4.3 optionalDependencies: vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15) @@ -4923,7 +4923,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-module-imports': 7.18.6 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 html-entities: 2.3.3 parse5: 7.3.0 @@ -4936,7 +4936,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.34: {} + baseline-browser-mapping@2.11.31: {} before-after-hook@4.0.0: {} @@ -4950,7 +4950,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.2 - qs: 6.17.0 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -4962,13 +4962,13 @@ snapshots: dependencies: balanced-match: 4.0.4 - browserslist@4.29.5: + browserslist@4.29.4: dependencies: - baseline-browser-mapping: 2.11.34 + baseline-browser-mapping: 2.11.31 caniuse-lite: 1.0.30001819 - electron-to-chromium: 1.5.467 + electron-to-chromium: 1.5.463 node-releases: 2.0.60 - update-browserslist-db: 1.3.4(browserslist@4.29.5) + update-browserslist-db: 1.3.4(browserslist@4.29.4) buffer-image-size@0.6.4: dependencies: @@ -5066,7 +5066,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.467: {} + electron-to-chromium@1.5.463: {} encodeurl@2.0.0: {} @@ -5200,7 +5200,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@10.2.2) express: 5.3.0(supports-color@10.2.2) - ip-address: 10.7.5 + ip-address: 10.7.4 transitivePeerDependencies: - supports-color @@ -5226,7 +5226,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 - qs: 6.17.0 + qs: 6.16.1 range-parser: 1.3.0 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) @@ -5266,7 +5266,7 @@ snapshots: dependencies: magic-string: 0.30.21 mlly: 1.8.2 - rollup: 4.66.0 + rollup: 4.65.0 forwarded@0.2.0: {} @@ -5320,7 +5320,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.14: {} + hono@4.13.13: {} html-entities@2.3.3: {} @@ -5340,7 +5340,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.7.5: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -5484,7 +5484,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.5: + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5643,15 +5643,15 @@ snapshots: dependencies: playwright-core: 1.63.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.32)(tsx@4.23.15): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.31)(tsx@4.23.15): dependencies: lilconfig: 3.1.4 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.32 + postcss: 8.5.31 tsx: 4.23.15 - postcss@8.5.32: + postcss@8.5.31: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 @@ -5670,7 +5670,7 @@ snapshots: pure-rand@8.4.2: {} - qs@6.17.0: + qs@6.16.1: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -5713,36 +5713,36 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.15 '@rolldown/binding-win32-x64-msvc': 1.2.15 - rollup@4.66.0: + rollup@4.65.0: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.66.0 - '@rollup/rollup-android-arm64': 4.66.0 - '@rollup/rollup-darwin-arm64': 4.66.0 - '@rollup/rollup-darwin-x64': 4.66.0 - '@rollup/rollup-freebsd-arm64': 4.66.0 - '@rollup/rollup-freebsd-x64': 4.66.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.66.0 - '@rollup/rollup-linux-arm-musleabihf': 4.66.0 - '@rollup/rollup-linux-arm64-gnu': 4.66.0 - '@rollup/rollup-linux-arm64-musl': 4.66.0 - '@rollup/rollup-linux-loong64-gnu': 4.66.0 - '@rollup/rollup-linux-loong64-musl': 4.66.0 - '@rollup/rollup-linux-ppc64-gnu': 4.66.0 - '@rollup/rollup-linux-ppc64-musl': 4.66.0 - '@rollup/rollup-linux-riscv64-gnu': 4.66.0 - '@rollup/rollup-linux-riscv64-musl': 4.66.0 - '@rollup/rollup-linux-s390x-gnu': 4.66.0 - '@rollup/rollup-linux-x64-gnu': 4.66.0 - '@rollup/rollup-linux-x64-musl': 4.66.0 - '@rollup/rollup-openbsd-x64': 4.66.0 - '@rollup/rollup-openharmony-arm64': 4.66.0 - '@rollup/rollup-win32-arm64-msvc': 4.66.0 - '@rollup/rollup-win32-ia32-msvc': 4.66.0 - '@rollup/rollup-win32-x64-gnu': 4.66.0 - '@rollup/rollup-win32-x64-msvc': 4.66.0 + '@rollup/rollup-android-arm-eabi': 4.65.0 + '@rollup/rollup-android-arm64': 4.65.0 + '@rollup/rollup-darwin-arm64': 4.65.0 + '@rollup/rollup-darwin-x64': 4.65.0 + '@rollup/rollup-freebsd-arm64': 4.65.0 + '@rollup/rollup-freebsd-x64': 4.65.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.65.0 + '@rollup/rollup-linux-arm-musleabihf': 4.65.0 + '@rollup/rollup-linux-arm64-gnu': 4.65.0 + '@rollup/rollup-linux-arm64-musl': 4.65.0 + '@rollup/rollup-linux-loong64-gnu': 4.65.0 + '@rollup/rollup-linux-loong64-musl': 4.65.0 + '@rollup/rollup-linux-ppc64-gnu': 4.65.0 + '@rollup/rollup-linux-ppc64-musl': 4.65.0 + '@rollup/rollup-linux-riscv64-gnu': 4.65.0 + '@rollup/rollup-linux-riscv64-musl': 4.65.0 + '@rollup/rollup-linux-s390x-gnu': 4.65.0 + '@rollup/rollup-linux-x64-gnu': 4.65.0 + '@rollup/rollup-linux-x64-musl': 4.65.0 + '@rollup/rollup-openbsd-x64': 4.65.0 + '@rollup/rollup-openharmony-arm64': 4.65.0 + '@rollup/rollup-win32-arm64-msvc': 4.65.0 + '@rollup/rollup-win32-ia32-msvc': 4.65.0 + '@rollup/rollup-win32-x64-gnu': 4.65.0 + '@rollup/rollup-win32-x64-msvc': 4.65.0 fsevents: 2.3.3 router@2.2.0(supports-color@10.2.2): @@ -5914,7 +5914,7 @@ snapshots: dependencies: '@babel/generator': 7.29.8 '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.11 + '@babel/types': 7.29.8 solid-js: 1.9.15 transitivePeerDependencies: - supports-color @@ -5975,7 +5975,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.32)(supports-color@10.2.2)(tsx@4.23.15)(typescript@7.0.2): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@7.0.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -5986,16 +5986,16 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.32)(tsx@4.23.15) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.31)(tsx@4.23.15) resolve-from: 5.0.0 - rollup: 4.66.0 + rollup: 4.65.0 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.32 + postcss: 8.5.31 typescript: 7.0.2 transitivePeerDependencies: - jiti @@ -6052,9 +6052,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.3.4(browserslist@4.29.5): + update-browserslist-db@1.3.4(browserslist@4.29.4): dependencies: - browserslist: 4.29.5 + browserslist: 4.29.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -6077,7 +6077,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.32 + postcss: 8.5.31 rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: @@ -6091,7 +6091,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.32 + postcss: 8.5.31 rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: @@ -6112,7 +6112,7 @@ snapshots: chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.5 + magic-string: 1.4.3 obug: 2.2.1 picomatch: 4.0.7 std-env: 4.3.0 @@ -6135,7 +6135,7 @@ snapshots: chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.5 + magic-string: 1.4.3 obug: 2.2.1 picomatch: 4.0.7 std-env: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 83120415..23736b2b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,8 @@ allowBuilds: workerd: true strictDepBuilds: false + +# Resolve only package versions published at least 7 days ago (minutes), so +# lockfile maintenance and CI never pin versions newer than local supply-chain +# cooldowns allow. +minimumReleaseAge: 10080 diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index b04ae70a..8d4a77d3 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,5 +1,6 @@ import { createSignal, createMemo, createEffect, on, Show, Switch, Match, onMount, onCleanup, untrack } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; +import { z } from "zod"; import Header from "../layout/Header"; import TabBar, { type TabId } from "../layout/TabBar"; import FilterBar from "../layout/FilterBar"; @@ -14,7 +15,8 @@ import DependenciesTab from "./DependenciesTab"; import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection"; import { isRepoExcludedFromDependencies } from "../../lib/dependency-exclusion"; import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; -import { fetchDashboardIssueBodies, fetchDepPRBodies, fallbackToPreviousEnrichment, pickEnrichmentFields, approvePullRequest, markPullRequestReady } from "../../services/api"; +import { fetchDashboardIssueBodies, fetchDepPRBodies, fallbackToPreviousEnrichment, pickEnrichmentFields, isSameRevision, hasRevisionChanged, markCheckStatusStale, isBackfillError, hasDegradedEnrichment, pushEnrichmentDegraded, approvePullRequest, markPullRequestReady, evictPREnrichmentCacheNode, resetBodyFetchFailures } from "../../services/api"; +import { PR_ENRICHMENT_NOTIFICATION_SOURCE, DASHBOARD_BODIES_STORAGE_KEY } from "../../../shared/types"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun, HotPRStatusUpdate } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -36,7 +38,7 @@ import { JIRA_SETTINGS_ACTION, type JiraIssue } from "../../../shared/jira-types import { detectAndLookupJiraKeys } from "../../services/jira-keys"; import JiraAssignedTab from "./JiraAssignedTab"; import { updateRelaySnapshot } from "../../lib/mcp-relay"; -import { pushNotification, pushError, dismissNotificationBySource } from "../../lib/errors"; +import { pushNotification, pushError, dismissNotificationBySource, getNotifications } from "../../lib/errors"; import { detectNewItems, dispatchNotifications } from "../../lib/notifications"; import { getClient, getGraphqlRateLimit, fetchRateLimitDetails } from "../../services/github"; import { canApprovePR, canMarkPRReady, formatCount, prSizeCategory, rateLimitCssClass, stripParenthetical } from "../../lib/format"; @@ -175,6 +177,63 @@ function persistDepMeta(meta: ReadonlyMap): void { } } +const MAX_DASHBOARD_BODY_CACHE_ENTRIES = 500; +const MAX_DASHBOARD_BODY_CACHE_KEY_LENGTH = 128; +const MAX_ABANDONED_DEPS_PER_ENTRY = 200; +const MAX_DATASOURCE_LENGTH = 128; +const MAX_PACKAGE_NAME_LENGTH = 512; +const MAX_TIMESTAMP_LENGTH = 64; +const DISALLOWED_CACHE_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +const AbandonedDependencySchema = z.object({ + datasource: z.string().min(1).max(MAX_DATASOURCE_LENGTH), + packageName: z.string().min(1).max(MAX_PACKAGE_NAME_LENGTH), + lastUpdated: z.string().min(1).max(MAX_TIMESTAMP_LENGTH), +}); + +const CachedDashboardBodyEntrySchema = z.object({ + updatedAt: z.string().min(1).max(MAX_TIMESTAMP_LENGTH), + deps: z.array(AbandonedDependencySchema).max(MAX_ABANDONED_DEPS_PER_ENTRY), +}); + +type CachedDashboardBodyEntry = z.infer; + +// Root is validated loosely so one corrupt entry does not discard the rest of the cache. +const DashboardBodyCacheRootSchema = z.record(z.string(), z.unknown()); + +export function loadDashboardBodyCache(): Map { + try { + const raw = localStorage.getItem?.(DASHBOARD_BODIES_STORAGE_KEY); + if (!raw) return new Map(); + const root = DashboardBodyCacheRootSchema.safeParse(JSON.parse(raw) as unknown); + if (!root.success) return new Map(); + const map = new Map(); + for (const [k, v] of Object.entries(root.data)) { + if (map.size >= MAX_DASHBOARD_BODY_CACHE_ENTRIES) break; + if (!k || k.length > MAX_DASHBOARD_BODY_CACHE_KEY_LENGTH || DISALLOWED_CACHE_KEYS.has(k)) continue; + const entry = CachedDashboardBodyEntrySchema.safeParse(v); + if (entry.success) map.set(k, entry.data); + } + return map; + } catch { + return new Map(); + } +} + +function persistDashboardBodyCache(cache: ReadonlyMap): void { + try { + const obj: Record = {}; + const entries = [...cache.entries()]; + const start = Math.max(0, entries.length - MAX_DASHBOARD_BODY_CACHE_ENTRIES); + for (const [k, v] of entries.slice(start)) { + obj[k] = v; + } + localStorage.setItem?.(DASHBOARD_BODIES_STORAGE_KEY, JSON.stringify(obj)); + } catch { + // Non-critical — storage may be full + } +} + const [depMeta, setDepMeta] = createSignal>(loadDepMetaCache()); let _fetchingDashboardBodies = false; let _fetchingDepBodies = false; @@ -195,7 +254,7 @@ function applyRecentApprovalGuard(pr: PullRequest, currentLogin: string): void { } const entry = recentlyApprovedPRs.get(pr.id); if (!entry) return; - if (pr.headSha && pr.headSha !== entry.headSha) { + if ((pr.headSha && pr.headSha !== entry.headSha) || (pr.checkStatusStale && !pr.headSha)) { recentlyApprovedPRs.delete(pr.id); return; } @@ -249,7 +308,11 @@ export function applyHotPollPRUpdates( if (!update) continue; pr.state = update.state; // detect closed/merged quickly if (typeof update.draft === "boolean") pr.draft = update.draft; + if (update.updatedAt) { + pr.updatedAt = update.updatedAt; + } pr.checkStatus = update.checkStatus; + if (pr.checkStatusStale) pr.checkStatusStale = false; pr.reviewDecision = update.reviewDecision; if (update.headSha) { pr.headSha = update.headSha; @@ -289,13 +352,49 @@ 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(); -const _dashboardBodyCache = new Map(); +const _dashboardBodyCache = loadDashboardBodyCache(); +function seedCachedDashboardBodies(): void { + if (!config.dependencies?.enabled || _dashboardBodyCache.size === 0 || dashboardData.issues.length === 0) return; + const botLogins = expandBotLogins( + (config.trackedUsers ?? []).filter((u) => u.type === "bot").map((u) => u.login.toLowerCase()), + ); + const excludedOrgs = config.dependencies?.excludedOrgs ?? []; + const excludedRepos = config.dependencies?.excludedRepos ?? []; + const depRepos = new Set( + dashboardData.pullRequests + .filter( + (pr) => + pr.state === "OPEN" && + isDependencyPr(pr, botLogins) && + !isRepoExcludedFromDependencies(pr.repoFullName, excludedOrgs, excludedRepos), + ) + .map((pr) => pr.repoFullName), + ); + if (depRepos.size === 0) return; + const relevant = findDashboardIssues(dashboardData.issues, botLogins).filter((di) => + depRepos.has(di.repoFullName), + ); + if (relevant.length === 0) return; + const seededAbandoned = new Map(); + const seededUrls = new Map(); + for (const di of relevant) { + const cached = _dashboardBodyCache.get(di.nodeId); + if (cached && cached.updatedAt === di.updatedAt) { + seededAbandoned.set(di.repoFullName, cached.deps); + } + seededUrls.set(di.repoFullName, di.htmlUrl); + } + if (seededAbandoned.size > 0) setAbandonedDepsMap(seededAbandoned); + if (seededUrls.size > 0) setDashboardIssueUrls(seededUrls); +} +seedCachedDashboardBodies(); const _dashboardBodyFailureCooldown = new Map(); let _bodyFetchAuthGen = 0; // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { _bodyFetchAuthGen++; + _targetedPushedEnrichmentDegraded = false; resetDashboardData(); setHasFetchedFresh(false); setJiraIssues([]); @@ -306,11 +405,13 @@ onAuthCleared(() => { setDashboardIssueUrls(new Map()); setDepMeta(new Map()); localStorage.removeItem?.(DEP_META_STORAGE_KEY); + localStorage.removeItem?.(DASHBOARD_BODIES_STORAGE_KEY); _fetchingDashboardBodies = false; _fetchingDepBodies = false; _depBodyFailureCooldown.clear(); _dashboardBodyCache.clear(); _dashboardBodyFailureCooldown.clear(); + resetBodyFetchFailures(); setApprovingPRIds(new Set()); recentlyApprovedPRs.clear(); setMarkingReadyPRIds(new Set()); @@ -334,7 +435,9 @@ onAuthCleared(() => { clearHotSets(); }); -async function pollFetch(): Promise { +let _targetedPushedEnrichmentDegraded = false; + +async function pollFetch(force = false): Promise { // Only show skeleton on initial load (no data yet). // Subsequent refreshes keep existing data visible — the coordinator's // isRefreshing signal handles the "Refreshing..." indicator. @@ -360,7 +463,7 @@ async function pollFetch(): Promise { lastRefreshedAt: new Date(), }); } - }); + }, force); const hasErrors = data.errors.length > 0; setLastFetchHadErrors(hasErrors); @@ -377,7 +480,9 @@ async function pollFetch(): Promise { const currentLogin = user()?.login ?? ""; for (const pr of data.pullRequests) { applyRecentReadyGuard(pr); - applyRecentApprovalGuard(pr, currentLogin); + if (pr.enriched !== false) { + applyRecentApprovalGuard(pr, currentLogin); + } } let resolvedPullRequests: PullRequest[]; @@ -410,10 +515,28 @@ async function pollFetch(): Promise { // enriched from a prior cycle. const regressing = e.enriched === false && pr.enriched !== false; if (!regressing) { + const sameRevision = + pr.enriched !== false && !pr.checkStatusStale && isSameRevision(pr, e); + const prevCheckStatus = pr.checkStatus; Object.assign(pr, pickEnrichmentFields(e)); + pr.checkStatusStale = e.checkStatusStale; + if (e.checkStatusStale) { + if (sameRevision) { + pr.checkStatus = prevCheckStatus; + pr.checkStatusStale = false; + } else { + pr.checkStatus = null; + } + } pr.reviewDecision = e.reviewDecision; pr.draft = e.draft; pr.enriched = e.enriched; + } else { + const revisionChanged = hasRevisionChanged(pr, e); + pr.updatedAt = e.updatedAt; + pr.reviewDecision = e.reviewDecision; + pr.draft = e.draft; + if (revisionChanged || pr.checkStatusStale) markCheckStatusStale(pr); } pr.nodeId = e.nodeId; pr.surfacedBy = e.surfacedBy; @@ -478,7 +601,8 @@ async function pollFetch(): Promise { pushNotification("localStorage:dashboard", "Dashboard cache write failed — storage may be full", "warning"); } }, 0); - return data; + _targetedPushedEnrichmentDegraded = false; + return { ...data, pullRequests: resolvedPullRequests }; } catch (err) { // Handle 401 auth errors const status = @@ -557,8 +681,22 @@ function handleTargetedData(data: DashboardData, affectedRepos: string[]): void } const existingPr = existingPRById.get(pr.id); if (existingPr && pr.enriched === false && existingPr.enriched !== false) { + const revisionChanged = hasRevisionChanged(existingPr, pr); Object.assign(pr, pickEnrichmentFields(existingPr)); pr.enriched = existingPr.enriched; + if (revisionChanged || existingPr.checkStatusStale) { + markCheckStatusStale(pr); + } else { + pr.checkStatusStale = existingPr.checkStatusStale; + } + } else if (existingPr && pr.checkStatusStale && existingPr.enriched !== false && !existingPr.checkStatusStale) { + if (isSameRevision(existingPr, pr)) { + pr.checkStatus = existingPr.checkStatus; + pr.checkStatusStale = false; + } else { + pr.checkStatus = null; + pr.checkStatusStale = true; + } } applyRecentReadyGuard(pr); applyRecentApprovalGuard(pr, currentLogin); @@ -595,8 +733,25 @@ function handleTargetedData(data: DashboardData, affectedRepos: string[]): void })); }); + const targetedBackfillErrors = data.errors.filter(isBackfillError); for (const err of data.errors) { - pushError(err.repo, err.message, err.retryable); + if (!isBackfillError(err)) { + pushError(err.repo, err.message, err.retryable); + } + } + if ( + targetedBackfillErrors.length > 0 && + (targetedBackfillErrors.some((e) => !e.retryable) || hasDegradedEnrichment(data.pullRequests)) + ) { + _targetedPushedEnrichmentDegraded = true; + pushEnrichmentDegraded(); + } else if ( + _targetedPushedEnrichmentDegraded && + targetedBackfillErrors.length === 0 && + !hasDegradedEnrichment(dashboardData.pullRequests) + ) { + _targetedPushedEnrichmentDegraded = false; + dismissNotificationBySource(PR_ENRICHMENT_NOTIFICATION_SOURCE); } const newItems = detectNewItems(data); @@ -1361,8 +1516,15 @@ export default function DashboardPage() { 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); + if (!lastFetchHadErrors()) { + let pruned = false; + for (const k of _dashboardBodyCache.keys()) { + if (!activeNodeIds.has(k)) { + _dashboardBodyCache.delete(k); + pruned = true; + } + } + if (pruned) persistDashboardBodyCache(_dashboardBodyCache); } for (const k of _dashboardBodyFailureCooldown.keys()) { if (!activeNodeIds.has(k)) _dashboardBodyFailureCooldown.delete(k); @@ -1373,6 +1535,7 @@ export default function DashboardPage() { if (relevant.length === 0) { if (abandonedDepsMap().size > 0) setAbandonedDepsMap(new Map()); if (dashboardIssueUrls().size > 0) setDashboardIssueUrls(new Map()); + if (_dashboardBodyFailureCooldown.size === 0) resetBodyFetchFailures("dashboardBodies"); return; } @@ -1383,6 +1546,10 @@ export default function DashboardPage() { return !failedAt || now - failedAt >= DEP_BODY_FAILURE_COOLDOWN_MS; }); + if (toFetch.length === 0 && _dashboardBodyFailureCooldown.size === 0) { + resetBodyFetchFailures("dashboardBodies"); + } + let failedCount = 0; if (toFetch.length > 0) { const { bodies: bodyMap, failedIds } = await fetchDashboardIssueBodies( @@ -1391,6 +1558,7 @@ export default function DashboardPage() { ); if (authGen !== _bodyFetchAuthGen || getClient() !== octokit) return; failedCount = failedIds.size; + let cacheUpdated = false; for (const di of toFetch) { if (failedIds.has(di.nodeId)) { _dashboardBodyFailureCooldown.set(di.nodeId, now); @@ -1402,8 +1570,10 @@ export default function DashboardPage() { updatedAt: di.updatedAt, deps: parseAbandonedSection(bodyMap.get(di.nodeId) ?? ""), }); + cacheUpdated = true; } } + if (cacheUpdated) persistDashboardBodyCache(_dashboardBodyCache); } const newAbandonedMap = new Map(); @@ -1430,21 +1600,32 @@ export default function DashboardPage() { { defer: true } )); + // True between a full refresh's light PR results (all `enriched: false`) and + // its enrichment results. + const awaitingEnrichment = createMemo( + () => + dashboardData.pullRequests.length > 0 && + dashboardData.pullRequests.every((pr) => pr.enriched === false) && + Boolean(_coordinator()?.isRefreshing()) + ); + // Fetch PR bodies for dependency PRs where title parsing can't determine update type. // Parses bodies immediately into VersionInfo and persists to localStorage so // classification survives page refresh without visual jank. createEffect(() => { if (!config.dependencies.enabled) return; + const meta = depMeta(); + const depPrs = dependencyPullRequests(); + const visibleDepPrs = visibleDependencyPullRequests(); + const enrichmentPending = awaitingEnrichment(); if (_fetchingDepBodies) { console.debug("[dashboard] depBodies effect: skipped — fetch already in flight"); return; } const octokit = getClient(); if (!octokit) return; + if (enrichmentPending) return; - const meta = depMeta(); - const depPrs = dependencyPullRequests(); - const visibleDepPrs = visibleDependencyPullRequests(); const now = Date.now(); // Prune cooldown entries for PRs no longer in the dependency set on every @@ -1463,6 +1644,7 @@ export default function DashboardPage() { return !failedAt || now - failedAt >= DEP_BODY_FAILURE_COOLDOWN_MS; }); if (toFetch.length === 0) { + if (_depBodyFailureCooldown.size === 0) resetBodyFetchFailures("depPRBodies"); console.debug("[dashboard] depBodies effect: nothing to fetch", { metaSize: meta.size, visibleDepPrCount: visibleDepPrs.length }); return; } @@ -1501,7 +1683,9 @@ export default function DashboardPage() { } setDepMeta(merged); console.debug(`[dashboard] depBodies effect: depMeta updated, size=${merged.size}`); - setTimeout(() => persistDepMeta(merged), 0); + setTimeout(() => { + if (authGen === _bodyFetchAuthGen) persistDepMeta(merged); + }, 0); } finally { if (authGen === _bodyFetchAuthGen) { _fetchingDepBodies = false; @@ -1618,6 +1802,7 @@ export default function DashboardPage() { try { const result = await approvePullRequest(client, nodeId, approvedHeadSha); if (getClient() !== client) return; + evictPREnrichmentCacheNode(pr.nodeId); const nextDecision: PullRequest["reviewDecision"] = result.reviewDecision ?? (priorReviewDecision === "REVIEW_REQUIRED" ? "APPROVED" : priorReviewDecision); @@ -1688,6 +1873,7 @@ export default function DashboardPage() { try { const result = await markPullRequestReady(client, nodeId); if (getClient() !== client) return; + evictPREnrichmentCacheNode(pr.nodeId); recentlyMarkedReadyPRs.set(pr.id, { reviewDecision: result.reviewDecision, @@ -1732,6 +1918,10 @@ export default function DashboardPage() { } } + const isPartialSync = createMemo(() => + getNotifications().some((n) => n.source === PR_ENRICHMENT_NOTIFICATION_SOURCE) + ); + return (
@@ -1771,6 +1961,7 @@ export default function DashboardPage() { sortDirection={activeTab() === "dependencies" ? undefined : viewState.globalSort.direction} onSortChange={activeTab() === "dependencies" ? undefined : (field, dir) => setSortPreference(field, dir)} hideOrgRepo={!isBuiltinTab(activeTab()) || activeTab() === "dependencies"} + partialSync={isPartialSync()} />
@@ -1813,6 +2004,7 @@ export default function DashboardPage() { pullRequests={visibleDependencyPullRequests()} depMeta={depMeta()} loading={dashboardData.loading} + enrichmentPending={dashboardData.loading || awaitingEnrichment()} abandonedDepsMap={abandonedDepsMap()} dashboardIssueUrls={dashboardIssueUrls()} hotPollingPRIds={hotPollingPRIds()} diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 31d4f190..6ec6e1ff 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -105,6 +105,11 @@ interface DependenciesTabProps { pullRequests: PullRequest[]; depMeta?: ReadonlyMap; loading?: boolean; + /** + * True while the dashboard is loading or a refresh's PR enrichment has not arrived + * yet; suppresses `Checking status` on unenriched rows. + */ + enrichmentPending?: boolean; abandonedDepsMap: Map; dashboardIssueUrls: Map; hotPollingPRIds?: ReadonlySet; @@ -375,6 +380,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { approvingPRIds={props.approvingPRIds} onMarkPRReady={props.onMarkPRReady} markingReadyPRIds={props.markingReadyPRIds} + enrichmentPending={props.enrichmentPending} /> )} @@ -402,6 +408,7 @@ interface StatusGroupProps { approvingPRIds?: ReadonlySet; onMarkPRReady?: (pr: PullRequest) => void; markingReadyPRIds?: ReadonlySet; + enrichmentPending?: boolean; } function stripCommitPrefix(title: string): string { @@ -488,6 +495,14 @@ function StatusGroup(props: StatusGroupProps) { Checks running + + Checking status + 0 || pr.deletions > 0)}> void; hideOrgRepo?: boolean; + partialSync?: boolean; } export default function FilterBar(props: FilterBarProps) { @@ -126,10 +128,35 @@ export default function FilterBar(props: FilterBarProps) {
+ + + + + Partial sync + + + + {updatedLabel()} diff --git a/src/app/components/shared/ToastContainer.tsx b/src/app/components/shared/ToastContainer.tsx index ba651346..4a827a4e 100644 --- a/src/app/components/shared/ToastContainer.tsx +++ b/src/app/components/shared/ToastContainer.tsx @@ -60,26 +60,42 @@ interface ToastItem { // re-toast. sessionStorage survives both a remount and a refresh, so this is // the one place dedup needs to persist beyond the component's own lifetime. const TOASTED_MESSAGES_KEY = "github-tracker:toasted-messages"; +export const TOAST_REPEAT_COOLDOWN_MS = 15 * 60 * 1000; -function loadToastedMessages(): Map { +interface ToastedMessageEntry { + message: string; + toastedAt: number; +} + +const _activeResetHandlers = new Set<() => void>(); + +function loadToastedMessages(): Map { try { const raw = sessionStorage.getItem(TOASTED_MESSAGES_KEY); const parsed: unknown = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return new Map(); - return new Map( - parsed.filter( - (e): e is [string, string] => - Array.isArray(e) && e.length === 2 && typeof e[0] === "string" && typeof e[1] === "string" - ) - ); + const result = new Map(); + for (const e of parsed) { + if (!Array.isArray(e) || typeof e[0] !== "string" || typeof e[1] !== "string") continue; + if (e.length === 3 && typeof e[2] === "number" && Number.isFinite(e[2])) { + result.set(e[0], { message: e[1], toastedAt: e[2] }); + } else if (e.length === 2) { + // toastedAt: 0 is a migration sentinel for legacy [source, message] 2-tuples in sessionStorage + result.set(e[0], { message: e[1], toastedAt: 0 }); + } + } + return result; } catch { return new Map(); } } -function persistToastedMessages(map: Map): void { +function persistToastedMessages(map: Map): void { try { - sessionStorage.setItem(TOASTED_MESSAGES_KEY, JSON.stringify([...map.entries()])); + sessionStorage.setItem( + TOASTED_MESSAGES_KEY, + JSON.stringify(Array.from(map.entries()).map(([source, v]) => [source, v.message, v.toastedAt])) + ); } catch { /* best-effort — dedup persistence is low-stakes, no user-facing notification needed */ } @@ -90,6 +106,7 @@ function persistToastedMessages(map: Map): void { // resetGitHubStatusState()/resetPollState() etc.). export function resetToastState(): void { sessionStorage.removeItem(TOASTED_MESSAGES_KEY); + for (const fn of _activeResetHandlers) fn(); } // toastedMessages stores per-source API/search/graphql error text, which is @@ -127,6 +144,15 @@ export default function ToastContainer() { // suppressed and never superseded still surfaces once the window elapses. const coalesceTimers = new Map>(); + const handleReset = () => { + toastedMessages.clear(); + lastToastedAt.clear(); + seenTimestamps.clear(); + for (const t of coalesceTimers.values()) clearTimeout(t); + coalesceTimers.clear(); + }; + _activeResetHandlers.add(handleReset); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const animDelay = reducedMotion ? 0 : 300; @@ -180,8 +206,9 @@ export default function ToastContainer() { } function showToast(notif: AppNotification) { - lastToastedAt.set(notif.source, Date.now()); - toastedMessages.set(notif.source, notif.message); + const now = Date.now(); + lastToastedAt.set(notif.source, now); + toastedMessages.set(notif.source, { message: notif.message, toastedAt: now }); persistToastedMessages(toastedMessages); setVisibleToasts((prev) => { const next = new Map(prev); @@ -198,8 +225,15 @@ export default function ToastContainer() { const t = setTimeout(() => { coalesceTimers.delete(source); const current = getNotifications().find(n => n.source === source); - if (!current || isMuted(source)) return; - if (toastedMessages.get(source) === current.message) return; + if (!current || current.silentToast || isMuted(source)) return; + const entry = toastedMessages.get(source); + if ( + entry !== undefined && + entry.message === current.message && + current.timestamp - entry.toastedAt < TOAST_REPEAT_COOLDOWN_MS + ) { + return; + } seenTimestamps.set(current.id, current.timestamp); showToast(current); }, remaining); @@ -217,10 +251,21 @@ export default function ToastContainer() { seenTimestamps.set(notif.id, notif.timestamp); + if (notif.silentToast) continue; + const lastToasted = lastToastedAt.get(notif.source); const coalescing = lastToasted !== undefined && Date.now() - lastToasted < COALESCE_MS; const muted = isMuted(notif.source); - const alreadyToasted = toastedMessages.get(notif.source) === notif.message; + const toastedEntry = toastedMessages.get(notif.source); + let alreadyToasted = false; + if (toastedEntry && toastedEntry.message === notif.message) { + if (toastedEntry.toastedAt === 0 && isNew) { + toastedEntry.toastedAt = notif.timestamp; + alreadyToasted = true; + } else { + alreadyToasted = notif.timestamp - toastedEntry.toastedAt < TOAST_REPEAT_COOLDOWN_MS; + } + } if (coalescing || muted || alreadyToasted) { if (coalescing) scheduleCoalesceRecheck(notif.source); @@ -239,9 +284,14 @@ export default function ToastContainer() { [...lastToastedAt.keys(), ...toastedMessages.keys()].filter(source => !currentSources.has(source)) ); let toastedMessagesChanged = false; + const now = Date.now(); for (const source of staleSources) { lastToastedAt.delete(source); - if (toastedMessages.delete(source)) toastedMessagesChanged = true; + const entry = toastedMessages.get(source); + if (entry && (entry.toastedAt === 0 || now - entry.toastedAt >= TOAST_REPEAT_COOLDOWN_MS)) { + toastedMessages.delete(source); + toastedMessagesChanged = true; + } const ct = coalesceTimers.get(source); if (ct !== undefined) { clearTimeout(ct); coalesceTimers.delete(source); } } @@ -258,13 +308,14 @@ export default function ToastContainer() { }); onCleanup(() => { + _activeResetHandlers.delete(handleReset); for (const t of timeouts.values()) clearTimeout(t); for (const t of dismissingTimeouts.values()) clearTimeout(t); for (const t of coalesceTimers.values()) clearTimeout(t); }); return ( -
+
{(item) => { const cfg = severityConfig(item.notification.severity); diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index 745c4f55..7372ab5c 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -282,11 +282,11 @@ export function classifyDepStatus( return "pending-rebase"; } - // 2. Enriched + CI green (or no CI configured) + not draft + not changes-requested → mergeable + // 2. Enriched + CI green (or no CI configured and check status is not stale) + not draft + not changes-requested → mergeable if ( pr.enriched !== false && !pr.draft && - (pr.checkStatus === "success" || pr.checkStatus === null) && + (pr.checkStatus === "success" || (pr.checkStatus === null && !pr.checkStatusStale)) && pr.reviewDecision !== "CHANGES_REQUESTED" ) { return "mergeable"; diff --git a/src/app/lib/errors.ts b/src/app/lib/errors.ts index 9b6c0063..a799dd5c 100644 --- a/src/app/lib/errors.ts +++ b/src/app/lib/errors.ts @@ -16,6 +16,11 @@ export interface AppNotification { severity: NotificationSeverity; read: boolean; action?: NotificationAction; + silentToast?: boolean; +} + +export interface PushNotificationOptions { + silentToast?: boolean; } export function isSafeInternalHref(href: string): boolean { @@ -49,12 +54,14 @@ export function pushNotification( message: string, severity: NotificationSeverity, retryable = false, - action?: NotificationAction + action?: NotificationAction, + options?: PushNotificationOptions ): void { // Record source in cycle tracking even for no-ops if (_cycleTracking) _cycleTracking.add(source); const safeAction = action && isSafeInternalHref(action.href) ? action : undefined; + const silentToast = options?.silentToast; setNotifications((prev) => { const existing = prev.find((n) => n.source === source); @@ -71,7 +78,16 @@ export function pushNotification( return prev .map((n) => n.source === source - ? { ...n, message, severity, retryable, action: safeAction, read: false, timestamp: Date.now() } + ? { + ...n, + message, + severity, + retryable, + action: safeAction, + read: false, + timestamp: Date.now(), + silentToast, + } : n ) .slice(-MAX_NOTIFICATIONS); @@ -88,6 +104,7 @@ export function pushNotification( severity, read: false, ...(safeAction ? { action: safeAction } : {}), + ...(silentToast ? { silentToast: true } : {}), }, ].slice(-MAX_NOTIFICATIONS); }); diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index a24c12b9..54c4df42 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -42,7 +42,7 @@ 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) + /^NetworkError when attempting to fetch resource\.?(?:\s*\([^)]+\))?$/i.test(value) ); } @@ -117,6 +117,7 @@ export function beforeBreadcrumbHandler( if (!ALLOWED_CONSOLE_PREFIXES.some((p) => msg.startsWith(p))) { return null; } + breadcrumb.message = scrubUrl(msg); } return breadcrumb; } @@ -129,6 +130,7 @@ export function initSentry(): void { dsn, tunnel: "/api/error-reporting", environment: import.meta.env.MODE, + enhanceFetchErrorMessages: "report-only", // ── Privacy: absolute minimum data ────────────────────────── dataCollection: { diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 6b495f58..32bd7225 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1,10 +1,10 @@ import { getClient, cachedRequest, updateGraphqlRateLimit } from "./github"; import * as Sentry from "@sentry/solid"; -import { pushNotification } from "../lib/errors"; +import { pushNotification, dismissNotificationBySource } from "../lib/errors"; import type { ApiCallSource } from "./api-usage"; import type { TrackedUser } from "../stores/config"; import { VALID_REPO_NAME, VALID_TRACKED_LOGIN, SEARCH_RESULT_CAP, APPROVE_PR_MUTATION, MARK_PR_READY_MUTATION, GITHUB_NODE_ID_RE, GIT_SHA_RE } from "../../shared/validation"; -import { REPO_CAP } from "../../shared/types"; +import { REPO_CAP, PR_ENRICHMENT_NOTIFICATION_SOURCE, PR_ENRICHMENT_DEGRADED_MESSAGE } from "../../shared/types"; import type { Issue, IssueState, PullRequest, PullRequestState, AutoMergeMethod, ViewerReviewState, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError } from "../../shared/types"; // ── Re-exports from shared/types (backward compat for existing importers) ───── @@ -106,6 +106,7 @@ function extractRejectionError(reason: unknown): { statusCode: number | null; me return { statusCode, message }; } + /** * Extracts partial data from a GraphqlResponseError for search queries. * Only matches responses containing a `search` key (issues/PRs search shape). @@ -125,6 +126,16 @@ function extractSearchPartialData(err: unknown): T | null { return null; } +/** + * Returns `err.data` (partial data GitHub sent alongside GraphQL errors) when + * the rejection carries a data object, otherwise null. + */ +function extractPartialGraphqlData(err: unknown): Partial | null { + return err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object" + ? err.data + : null; +} + function chunkArray(arr: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < arr.length; i += size) { @@ -378,15 +389,11 @@ interface LightPRSearchResponse { rateLimit?: GraphQLRateLimit; } -/** Phase 2 backfill query: enriches PRs with heavy fields using node IDs. */ -const HEAVY_PR_BACKFILL_QUERY = ` - query($ids: [ID!]!) { - nodes(ids: $ids) { - ... on PullRequest { +const BASE_PR_BACKFILL_FIELDS = ` + id databaseId headRefOid headRepository { owner { login } nameWithOwner } - mergeStateStatus assignees(first: 10) { nodes { login } } reviewRequests(first: 10) { nodes { requestedReviewer { ... on User { login } } } @@ -402,14 +409,26 @@ const HEAVY_PR_BACKFILL_QUERY = ` reviewThreads { totalCount } autoMergeRequest { mergeMethod } viewerLatestReview { state } - viewerLatestReviewRequest { id } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { state } - } - } - } + viewerLatestReviewRequest { id }`; + +/** Phase 2 backfill query: enriches PRs with heavy fields using node IDs. */ +const HEAVY_PR_BACKFILL_QUERY = ` + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest {${BASE_PR_BACKFILL_FIELDS} + mergeStateStatus + statusCheckRollup { state } + } + } + rateLimit { cost limit remaining resetAt } + } +`; + +/** Lightweight backfill fallback query omitting mergeStateStatus and statusCheckRollup. */ +const LIGHT_PR_BACKFILL_QUERY = ` + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest {${BASE_PR_BACKFILL_FIELDS} } } rateLimit { cost limit remaining resetAt } @@ -424,19 +443,16 @@ const HOT_PR_STATUS_QUERY = ` databaseId state isDraft + updatedAt headRefOid mergeStateStatus reviewDecision autoMergeRequest { mergeMethod } viewerLatestReview { state } viewerLatestReviewRequest { id } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { state } - } - } - } + statusCheckRollup { state } + isCrossRepository + headRef { target { ... on Commit { statusCheckRollup { state } } } } } } rateLimit { cost limit remaining resetAt } @@ -447,13 +463,16 @@ interface HotPRStatusNode { databaseId: number; state: PullRequestState; isDraft?: boolean; + updatedAt?: string; headRefOid?: string; mergeStateStatus: string; reviewDecision: string | null; autoMergeRequest?: { mergeMethod: string } | null; viewerLatestReview?: { state: string } | null; viewerLatestReviewRequest?: { id: string } | null; - commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }; + statusCheckRollup?: { state: string } | null; + isCrossRepository?: boolean; + headRef?: { target?: { statusCheckRollup?: { state: string } | null } | null } | null; } interface HotPRStatusResponse { @@ -480,31 +499,26 @@ interface GraphQLLightPRNode { } interface GraphQLHeavyPRNode { + id?: string; databaseId: number; headRefOid: string; - headRepository: { owner: { login: string }; nameWithOwner: string } | null; - mergeStateStatus: string; - assignees: { nodes: { login: string }[] }; - reviewRequests: { nodes: { requestedReviewer: { login: string } | null }[] }; - latestReviews: { - totalCount: number; - nodes: { author: { login: string } | null }[]; - }; - additions: number; - deletions: number; - changedFiles: number; - comments: { totalCount: number }; - reviewThreads: { totalCount: number }; + headRepository?: { owner?: { login: string } | null; nameWithOwner?: string | null } | null; + mergeStateStatus?: string | null; + assignees?: { nodes?: ({ login: string } | null)[] | null } | null; + reviewRequests?: { nodes?: ({ requestedReviewer?: { login: string } | null } | null)[] | null } | null; + latestReviews?: { + totalCount?: number | null; + nodes?: ({ author?: { login: string } | null } | null)[] | null; + } | null; + additions?: number | null; + deletions?: number | null; + changedFiles?: number | null; + comments?: { totalCount?: number | null } | null; + reviewThreads?: { totalCount?: number | null } | null; autoMergeRequest?: { mergeMethod: string } | null; viewerLatestReview?: { state: string } | null; viewerLatestReviewRequest?: { id: string } | null; - commits: { - nodes: { - commit: { - statusCheckRollup: { state: string } | null; - }; - }[]; - }; + statusCheckRollup?: { state: string } | null; } interface LightCombinedSearchResponse { @@ -531,8 +545,14 @@ interface HeavyBackfillResponse { rateLimit?: GraphQLRateLimit; } -// Max node IDs per nodes() query (GitHub limit) -const NODES_BATCH_SIZE = 100; +export const HEAVY_BACKFILL_BATCH_SIZE = 25; +export const MIN_SPLIT_BATCH_SIZE = 6; +/** + * Number of failed top-level heavy backfill batches, with no heavy node resolved + * yet in the same fetchPREnrichment call, after which failing batches skip + * binary splitting and go straight to the light fallback. + */ +export const SPLIT_SUPPRESSION_BATCH_THRESHOLD = 2; // ── GraphQL search functions ────────────────────────────────────────────────── @@ -673,7 +693,7 @@ async function runForkPRFallback( ): Promise { const forkCandidates: ForkCandidate[] = []; for (const [databaseId, pr] of prMap) { - if (pr.checkStatus !== null) continue; + if (pr.checkStatus !== null || pr.checkStatusStale) continue; const headInfo = forkInfoMap.get(databaseId); if (!headInfo) continue; const baseOwner = pr.repoFullName.split("/")[0].toLowerCase(); @@ -717,24 +737,26 @@ async function runForkPRFallback( const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined; const state = data?.object?.statusCheckRollup?.state ?? null; const pr = prMap.get(forkChunk[i].databaseId); - if (pr) pr.checkStatus = mapCheckStatus(state); + if (pr) { + pr.checkStatus = mapCheckStatus(state); + } } } catch (err) { - const errDataRaw = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") - ? err.data as Record - : null; + const errDataRaw = extractPartialGraphqlData>(err); if (errDataRaw?.rateLimit) { updateGraphqlRateLimit(errDataRaw.rateLimit as GraphQLRateLimit); } const partialData = errDataRaw as Record | null; - if (partialData) { - for (let i = 0; i < forkChunk.length; i++) { - const data = partialData[`fork${i}`]; - if (!data) continue; + for (let i = 0; i < forkChunk.length; i++) { + const data = partialData?.[`fork${i}`]; + const pr = prMap.get(forkChunk[i].databaseId); + if (!pr) continue; + if (data) { const state = data.object?.statusCheckRollup?.state ?? null; - const pr = prMap.get(forkChunk[i].databaseId); - if (pr) pr.checkStatus = mapCheckStatus(state); + pr.checkStatus = mapCheckStatus(state); + } else { + pr.checkStatusStale = true; } } @@ -845,9 +867,7 @@ async function executeLightCombinedQuery( request: { apiSource: source }, }); } catch (err) { - const partial = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") - ? err.data as Partial - : null; + const partial = extractPartialGraphqlData(err); if (partial && (partial.issues || partial.prInvolves || partial.prReviewReq)) { response = { issues: partial.issues ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, @@ -972,9 +992,11 @@ async function graphqlLightCombinedSearch( 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}`; + // archived:false drops items from repos archived since they were selected + // (archived repos are read-only, so their PRs can't be acted on). + const issueQ = `is:issue is:open archived:false involves:${userLogin} ${repoQualifiers}`; + const prInvQ = `is:pr is:open archived:false involves:${userLogin} ${repoQualifiers}`; + const prRevQ = `is:pr is:open archived:false review-requested:${userLogin} ${repoQualifiers}`; const batchLabel = `light-batch-${chunkIdx + 1}/${chunks.length}`; try { @@ -1016,8 +1038,8 @@ async function graphqlUnfilteredSearch( await Promise.allSettled(chunks.map(async (chunk, chunkIdx) => { const repoQualifiers = buildRepoQualifiers(chunk); - const issueQ = `is:issue is:open ${repoQualifiers}`; - const prQ = `is:pr is:open ${repoQualifiers}`; + const issueQ = `is:issue is:open archived:false ${repoQualifiers}`; + const prQ = `is:pr is:open archived:false ${repoQualifiers}`; const batchLabel = `unfiltered-batch-${chunkIdx + 1}/${chunks.length}`; try { @@ -1029,9 +1051,7 @@ async function graphqlUnfilteredSearch( request: { apiSource: "unfilteredSearch" }, }); } catch (err) { - const partial = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") - ? err.data as Partial - : null; + const partial = extractPartialGraphqlData(err); if (partial && (partial.issues || partial.prs)) { response = { issues: partial.issues ?? { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, @@ -1102,6 +1122,7 @@ export interface PREnrichmentData { assigneeLogins: string[]; reviewerLogins: string[]; checkStatus: CheckStatus["status"]; + checkStatusStale?: boolean; additions: number; deletions: number; changedFiles: number; @@ -1113,8 +1134,112 @@ export interface PREnrichmentData { viewerLatestReviewState: ViewerReviewState | null; } +function extractRollupState(node: { statusCheckRollup?: { state: string } | null }): string | null { + return node.statusCheckRollup?.state ?? null; +} + +function mapHeavyPRNode(node: GraphQLHeavyPRNode, isLightFallback: boolean): PREnrichmentData { + const pendingLogins = (node.reviewRequests?.nodes ?? []) + .map((n) => n?.requestedReviewer?.login) + .filter((l): l is string => l != null); + const actualLogins = (node.latestReviews?.nodes ?? []) + .map((n) => n?.author?.login) + .filter((l): l is string => l != null); + const reviewerLogins = [...new Set([...pendingLogins, ...actualLogins].map((l) => l.toLowerCase()))]; + + const checkStatus = isLightFallback + ? null + : applyMergeStateOverride( + node.mergeStateStatus ?? "", + mapCheckStatus(extractRollupState(node)), + ); + + let headRepository: PREnrichmentData["headRepository"] = null; + if (node.headRepository?.nameWithOwner && node.headRepository.owner?.login) { + const parts = node.headRepository.nameWithOwner.split("/"); + if (parts.length === 2) { + headRepository = { owner: node.headRepository.owner.login, repoName: parts[1] }; + } + } + + return { + id: node.databaseId, + headSha: node.headRefOid, + headRepository, + assigneeLogins: (node.assignees?.nodes ?? []) + .map((a) => a?.login) + .filter((l): l is string => l != null), + reviewerLogins, + checkStatus, + ...(isLightFallback ? { checkStatusStale: true } : {}), + additions: node.additions ?? 0, + deletions: node.deletions ?? 0, + changedFiles: node.changedFiles ?? 0, + comments: node.comments?.totalCount ?? 0, + reviewThreads: node.reviewThreads?.totalCount ?? 0, + totalReviewCount: node.latestReviews?.totalCount ?? 0, + autoMergeMethod: mapAutoMergeMethod(node.autoMergeRequest?.mergeMethod), + viewerReviewRequested: Boolean(node.viewerLatestReviewRequest?.id), + viewerLatestReviewState: mapViewerReviewState(node.viewerLatestReview?.state), + }; +} + +function extractErroredNodeIndices(err: unknown): ReadonlySet { + const indices = new Set(); + if (!err || typeof err !== "object" || !("errors" in err) || !Array.isArray(err.errors)) { + return indices; + } + for (const e of err.errors) { + if (!e || typeof e !== "object" || !("path" in e)) continue; + const path = (e as { path?: unknown }).path; + if ( + Array.isArray(path) && + path[0] === "nodes" && + typeof path[1] === "number" && + Number.isInteger(path[1]) && + path[1] >= 0 + ) { + indices.add(path[1]); + } + } + return indices; +} + +/** ApiError.repo prefix for phase-2 PR enrichment (backfill) batch failures. */ +const BACKFILL_ERROR_PREFIX = "backfill-batch-"; + +export function isBackfillError(e: Pick): boolean { + return e.repo.startsWith(BACKFILL_ERROR_PREFIX); +} + +/** True when any PR lacks enrichment or has unverified CI check status. */ +export function hasDegradedEnrichment( + prs: readonly Pick[], +): boolean { + return prs.some((pr) => pr.enriched === false || pr.checkStatusStale === true); +} + +/** Pushes the persistent, toast-silent "PR details degraded" warning. */ +export function pushEnrichmentDegraded(): void { + pushNotification( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true }, + ); +} + /** * Phase 2: fetches heavy PR fields using `nodes(ids: [...])` query. + * Chunks requests into HEAVY_BACKFILL_BATCH_SIZE batches, recursively splits batches + * that fail with a retryable error in half down to MIN_SPLIT_BATCH_SIZE, and falls back + * to LIGHT_PR_BACKFILL_QUERY on leaf failures. Retryable failures include browser + * `TypeError: Failed to fetch`, which is how CORS-masked GitHub gateway 502/504 timeouts + * surface. Once SPLIT_SUPPRESSION_BATCH_THRESHOLD batches have failed in this call without + * any heavy node resolving, failing batches skip splitting and go straight to the light + * fallback, so a broad outage costs about one heavy and one light request per batch. * Returns enrichment data keyed by databaseId. */ export async function fetchPREnrichment( @@ -1126,77 +1251,120 @@ export async function fetchPREnrichment( if (nodeIds.length === 0) return { enrichments, errors }; - const batches = chunkArray(nodeIds, NODES_BATCH_SIZE); - await Promise.allSettled(batches.map(async (batch, batchIdx) => { - try { - const response = await octokit.graphql(HEAVY_PR_BACKFILL_QUERY, { - ids: batch, - request: { apiSource: "heavyBackfill" }, - }); + const batches = chunkArray(nodeIds, HEAVY_BACKFILL_BATCH_SIZE); + let heavyNodesResolved = 0; + let failedBatchCount = 0; + await Promise.allSettled( + batches.map(async (batch, batchIdx) => { + const resolvedNodeIds = new Set(); + const batchLabel = `${batchIdx + 1}/${batches.length}`; + let batchErrorPushed = false; + let batchFailed = false; + + const ingestBackfillPayload = ( + payload: Partial | null | undefined, + isLightFallback: boolean, + erroredIndices?: ReadonlySet, + ): void => { + if (!payload) return; + if (payload.rateLimit) { + updateGraphqlRateLimit(payload.rateLimit); + } + if (Array.isArray(payload.nodes)) { + for (const [idx, node] of payload.nodes.entries()) { + if (!node || node.databaseId == null || erroredIndices?.has(idx)) continue; + enrichments.set(node.databaseId, mapHeavyPRNode(node, isLightFallback)); + if (!isLightFallback) heavyNodesResolved++; + if (node.id) resolvedNodeIds.add(node.id); + } + } + }; - if (response.rateLimit) { - updateGraphqlRateLimit(response.rateLimit); - } + const reportBatchFailure = ( + err: unknown, + statusCode: number | null, + message: string, + retryable: boolean, + ): void => { + if (batchErrorPushed) return; + batchErrorPushed = true; + console.warn(`[api] PR enrichment batch ${batchLabel} failed:`, err); + Sentry.captureException(err, { tags: { source: "prEnrichment" } }); + errors.push({ repo: `${BACKFILL_ERROR_PREFIX}${batchLabel}`, statusCode, message, retryable }); + }; - for (const node of response.nodes) { - if (!node || node.databaseId == null) continue; + const fetchBatchWithSplit = async (batchIds: string[]): Promise => { + try { + const response = await octokit.graphql(HEAVY_PR_BACKFILL_QUERY, { + ids: batchIds, + request: { + apiSource: "heavyBackfill", + retries: 0, + }, + }); - const pendingLogins = node.reviewRequests.nodes - .map((n) => n.requestedReviewer?.login) - .filter((l): l is string => l != null); - const actualLogins = node.latestReviews.nodes - .map((n) => n.author?.login) - .filter((l): l is string => l != null); - const reviewerLogins = [...new Set([...pendingLogins, ...actualLogins].map(l => l.toLowerCase()))]; + ingestBackfillPayload(response, false); + } catch (err) { + ingestBackfillPayload( + extractPartialGraphqlData(err), + false, + extractErroredNodeIndices(err), + ); - const checkStatus = applyMergeStateOverride( - node.mergeStateStatus, - mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null), - ); + const remainingIds = batchIds.filter((id) => !resolvedNodeIds.has(id)); + if (remainingIds.length === 0) return; + + if (!batchFailed) { + batchFailed = true; + failedBatchCount++; + } + + const { statusCode, message } = extractRejectionError(err); + const retryable = statusCode === null || statusCode >= 500; + + if (!retryable) { + reportBatchFailure(err, statusCode, message, false); + return; + } + + const canSplit = + heavyNodesResolved > 0 || failedBatchCount < SPLIT_SUPPRESSION_BATCH_THRESHOLD; - let headRepository: PREnrichmentData["headRepository"] = null; - if (node.headRepository) { - const parts = node.headRepository.nameWithOwner.split("/"); - if (parts.length === 2) { - headRepository = { owner: node.headRepository.owner.login, repoName: parts[1] }; + if (canSplit && remainingIds.length > MIN_SPLIT_BATCH_SIZE) { + const mid = Math.floor(remainingIds.length / 2); + await Promise.allSettled([ + fetchBatchWithSplit(remainingIds.slice(0, mid)), + fetchBatchWithSplit(remainingIds.slice(mid)), + ]); + return; + } + + if (canSplit && batchIds.length > MIN_SPLIT_BATCH_SIZE && remainingIds.length < batchIds.length) { + await fetchBatchWithSplit(remainingIds); + return; + } + + reportBatchFailure(err, statusCode, message, true); + + try { + const lightResponse = await octokit.graphql(LIGHT_PR_BACKFILL_QUERY, { + ids: remainingIds, + request: { apiSource: "heavyBackfill", retries: 0 }, + }); + ingestBackfillPayload(lightResponse, true); + } catch (lightErr) { + ingestBackfillPayload( + extractPartialGraphqlData(lightErr), + true, + extractErroredNodeIndices(lightErr), + ); } } + }; - enrichments.set(node.databaseId, { - id: node.databaseId, - headSha: node.headRefOid, - headRepository, - assigneeLogins: node.assignees.nodes.map((a) => a.login), - reviewerLogins, - checkStatus, - additions: node.additions, - deletions: node.deletions, - changedFiles: node.changedFiles, - comments: node.comments.totalCount, - reviewThreads: node.reviewThreads.totalCount, - totalReviewCount: node.latestReviews.totalCount, - autoMergeMethod: mapAutoMergeMethod(node.autoMergeRequest?.mergeMethod), - viewerReviewRequested: Boolean(node.viewerLatestReviewRequest?.id), - viewerLatestReviewState: mapViewerReviewState(node.viewerLatestReview?.state), - }); - } - } catch (err) { - const partialErr = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") - ? err.data as Partial - : null; - if (partialErr?.rateLimit) { - updateGraphqlRateLimit(partialErr.rateLimit); - } - const { statusCode, message } = extractRejectionError(err); - console.warn(`[api] PR enrichment batch ${batchIdx + 1}/${batches.length} failed:`, err); - Sentry.captureException(err, { tags: { source: "prEnrichment" } }); - errors.push({ - repo: `backfill-batch-${batchIdx + 1}/${batches.length}`, - statusCode, message, - retryable: statusCode === null || statusCode >= 500, - }); - } - })); + await fetchBatchWithSplit(batch); + }), + ); return { enrichments, errors }; } @@ -1205,7 +1373,23 @@ export async function fetchPREnrichment( // Prevents a hung request (e.g. octokit's secondary-rate-limit retry logic // stalling indefinitely) from wedging the caller's fetch-in-progress gate. -export const GRAPHQL_BODY_FETCH_TIMEOUT_MS = 20_000; +export const GRAPHQL_BODY_FETCH_TIMEOUT_MS = 45_000; +export const BODY_FETCH_BATCH_SIZE = 25; +export const BODY_FETCH_NOTIFICATION_THRESHOLD = 3; + +const _consecutiveBodyFetchFailures = new Map(); + +export function resetBodyFetchFailures(source?: string): void { + if (source !== undefined) { + _consecutiveBodyFetchFailures.delete(source); + dismissNotificationBySource(source); + return; + } + for (const s of _consecutiveBodyFetchFailures.keys()) { + dismissNotificationBySource(s); + } + _consecutiveBodyFetchFailures.clear(); +} export class GraphqlFetchTimeoutError extends Error { override readonly name = "GraphqlFetchTimeoutError"; @@ -1240,17 +1424,22 @@ export interface NodeBodiesFetchResult { /** * Shared batched-fetch helper for GraphQL `nodes()` queries that pull a single * field (e.g. issue/PR body text) for a set of node IDs. Chunks ids into - * NODES_BATCH_SIZE batches, races each batch via raceWithTimeout, and reports - * failures via console.warn + Sentry + a single deduped pushNotification. + * BODY_FETCH_BATCH_SIZE batches, races each sub-batch via raceWithTimeout, + * salvages partial err.data.nodes on error, recursively binary-splits retryable + * batch failures (5xx, GraphQL errors, or `Failed to fetch`; not a raceWithTimeout + * abort) down to MIN_SPLIT_BATCH_SIZE, and reports failures via + * console.warn + Sentry (capped at once per top-level batch) + a silent + * Notification Drawer warning after BODY_FETCH_NOTIFICATION_THRESHOLD + * consecutive failing calls for a source. * * `mapNode` lets each caller decide how to fold a raw node into the result map * (e.g. whether to keep or skip a null body) without duplicating the batching, - * timeout, rate-limit, and error-reporting plumbing. Batches that error or time - * out contribute their requested ids to `failedIds` so callers can avoid - * retrying a persistently-failing id on every poll cycle. + * timeout, rate-limit, and error-reporting plumbing. Leaf sub-batches or + * non-retryable batches that fail contribute their unresolved ids to `failedIds` + * so callers can avoid retrying a persistently-failing id on every poll cycle. */ // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -async function fetchNodeBodiesBatched( +async function fetchNodeBodiesBatched( octokit: GitHubOctokit, nodeIds: string[], query: string, @@ -1262,41 +1451,96 @@ async function fetchNodeBodiesBatched( const failedIds = new Set(); if (nodeIds.length === 0) return { bodies, failedIds }; - const batches = chunkArray(nodeIds, NODES_BATCH_SIZE); + const batches = chunkArray(nodeIds, BODY_FETCH_BATCH_SIZE); await Promise.allSettled(batches.map(async (batch) => { const batchStart = Date.now(); console.debug(`[api] ${source} batch started (${batch.length} ids) at ${batchStart}`); - const controller = new AbortController(); - try { - const response = await raceWithTimeout( - octokit.graphql>( - query, - { ids: batch, request: { apiSource: source, signal: controller.signal } } - ), - GRAPHQL_BODY_FETCH_TIMEOUT_MS, - controller, - ); - if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); - for (const node of response.nodes) { - if (!node) continue; - mapNode(node, bodies); + const resolvedNodeIds = new Set(); + let batchErrorPushed = false; + + const ingestBodyPayload = ( + payload: Partial> | null | undefined, + resolvedSubBatchIds?: string[], + ): void => { + if (!payload) return; + if (payload.rateLimit) updateGraphqlRateLimit(payload.rateLimit); + if (Array.isArray(payload.nodes)) { + for (const node of payload.nodes) { + if (!node) continue; + mapNode(node, bodies); + if (node.id) resolvedNodeIds.add(node.id); + } } - } catch (err) { - for (const id of batch) failedIds.add(id); - console.warn(`[api] ${source} batch failed or timed out:`, err); - Sentry.captureException(err, { tags: { source } }); - const partialErr = - err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object" - ? (err.data as Partial>) - : null; - if (partialErr?.rateLimit) updateGraphqlRateLimit(partialErr.rateLimit); + if (resolvedSubBatchIds) { + for (const id of resolvedSubBatchIds) { + resolvedNodeIds.add(id); + } + } + }; + + const fetchBatchWithSplit = async (batchIds: string[], isSplitRetry: boolean): Promise => { + const controller = new AbortController(); + try { + const response = await raceWithTimeout( + octokit.graphql>( + query, + { + ids: batchIds, + request: { + apiSource: source, + signal: controller.signal, + retries: isSplitRetry || batchIds.length > MIN_SPLIT_BATCH_SIZE ? 0 : 1, + }, + }, + ), + GRAPHQL_BODY_FETCH_TIMEOUT_MS, + controller, + ); + ingestBodyPayload(response, batchIds); + } catch (err) { + ingestBodyPayload(extractPartialGraphqlData>(err)); + + const remainingIds = batchIds.filter((id) => !resolvedNodeIds.has(id)); + if (remainingIds.length === 0) return; + + const { statusCode } = extractRejectionError(err); + const retryable = (statusCode === null || statusCode >= 500) && !controller.signal.aborted; + + if (retryable && remainingIds.length > MIN_SPLIT_BATCH_SIZE) { + const mid = Math.floor(remainingIds.length / 2); + await Promise.allSettled([ + fetchBatchWithSplit(remainingIds.slice(0, mid), true), + fetchBatchWithSplit(remainingIds.slice(mid), true), + ]); + return; + } + + for (const id of remainingIds) failedIds.add(id); + if (!batchErrorPushed) { + batchErrorPushed = true; + console.warn(`[api] ${source} batch failed or timed out:`, err); + Sentry.captureException(err, { tags: { source } }); + } + } + }; + + try { + await fetchBatchWithSplit(batch, false); } finally { console.debug(`[api] ${source} batch settled after ${Date.now() - batchStart}ms`); } })); - if (failedIds.size > 0 && getClient() === octokit) { - pushNotification(source, notificationMessage, "warning"); + if (getClient() === octokit) { + if (failedIds.size > 0) { + const consecutive = (_consecutiveBodyFetchFailures.get(source) ?? 0) + 1; + _consecutiveBodyFetchFailures.set(source, consecutive); + if (consecutive >= BODY_FETCH_NOTIFICATION_THRESHOLD) { + pushNotification(source, notificationMessage, "warning", true, undefined, { silentToast: true }); + } + } else { + resetBodyFetchFailures(source); + } } return { bodies, failedIds }; @@ -1341,13 +1585,14 @@ export async function fetchDashboardIssueBodies( const DEP_PR_BODIES_QUERY = ` query($ids: [ID!]!) { nodes(ids: $ids) { - ... on PullRequest { databaseId body } + ... on PullRequest { id databaseId body } } rateLimit { cost limit remaining resetAt } } `; interface DepPRBodyNode { + id?: string; databaseId: number; body: string | null; } @@ -1381,6 +1626,7 @@ type EnrichmentFields = Pick< | "assigneeLogins" | "reviewerLogins" | "checkStatus" + | "checkStatusStale" | "additions" | "deletions" | "changedFiles" @@ -1399,6 +1645,7 @@ export function pickEnrichmentFields(source: EnrichmentFields): EnrichmentFields assigneeLogins: source.assigneeLogins, reviewerLogins: source.reviewerLogins, checkStatus: source.checkStatus, + ...(source.checkStatusStale !== undefined ? { checkStatusStale: source.checkStatusStale } : {}), additions: source.additions, deletions: source.deletions, changedFiles: source.changedFiles, @@ -1411,6 +1658,36 @@ export function pickEnrichmentFields(source: EnrichmentFields): EnrichmentFields }; } +type RevisionFields = Pick; + +/** + * True when two snapshots of a PR are at the same revision: compares headSha + * when both are known, otherwise falls back to updatedAt. + */ +export function isSameRevision(a: RevisionFields, b: RevisionFields): boolean { + return a.headSha && b.headSha ? a.headSha === b.headSha : a.updatedAt === b.updatedAt; +} + +/** + * True when a PR may have moved to a new revision: updatedAt differs, or both + * headShas are known and differ. + */ +export function hasRevisionChanged(a: RevisionFields, b: RevisionFields): boolean { + return a.updatedAt !== b.updatedAt || (Boolean(a.headSha) && Boolean(b.headSha) && a.headSha !== b.headSha); +} + +/** + * Clears a PR's headSha and check status and sets checkStatusStale, so CI is + * treated as unverified until the hot poll or the next backfill resolves the + * current revision. Mutates and returns `pr`. + */ +export function markCheckStatusStale>(pr: T): T { + pr.headSha = ""; + pr.checkStatus = null; + pr.checkStatusStale = true; + return pr; +} + /** * Merges phase 2 enrichment data into light PRs. Returns enriched PR array. * Also detects fork PRs for the statusCheckRollup fallback. @@ -1420,12 +1697,13 @@ function mergeEnrichment( enrichments: Map, forkInfoMap: Map, viewerLogin = "", + skipForkIds?: ReadonlySet, ): PullRequest[] { return lightPRs.map((pr) => { const e = enrichments.get(pr.id); if (!e) return pr; // Keep enriched: false if backfill missed this PR - if (e.headRepository) { + if (e.headRepository && !skipForkIds?.has(pr.id)) { forkInfoMap.set(pr.id, e.headRepository); } @@ -1464,9 +1742,21 @@ export function fallbackToPreviousEnrichment( if (previous.length === 0) return next; const previousMap = new Map(previous.map((pr) => [pr.id, pr])); return next.map((pr) => { - if (pr.enriched !== false) return pr; + if (pr.enriched !== false) { + if (pr.checkStatusStale) { + const prev = previousMap.get(pr.id); + if (prev && prev.enriched !== false && !prev.checkStatusStale && isSameRevision(prev, pr)) { + return { ...pr, checkStatus: prev.checkStatus, checkStatusStale: false }; + } + return { ...pr, checkStatus: null, checkStatusStale: true }; + } + return pr; + } const prev = previousMap.get(pr.id); if (!prev || prev.enriched === false) return pr; + if (hasRevisionChanged(prev, pr) || prev.checkStatusStale) { + return markCheckStatusStale({ ...pr, ...pickEnrichmentFields(prev), enriched: true }); + } return { ...pr, ...pickEnrichmentFields(prev), @@ -1475,6 +1765,64 @@ export function fallbackToPreviousEnrichment( }); } +// ── Settled-PR enrichment cache (TTL = refresh interval + 60s, min 3 minutes) ─ + +/** Floor for the settled-PR enrichment cache TTL; also applies when auto-refresh is off. */ +export const MIN_ENRICHMENT_CACHE_TTL_MS = 3 * 60 * 1000; +/** Added to the refresh interval so the next scheduled poll (±30s jitter) lands inside the TTL. */ +const ENRICHMENT_CACHE_TTL_MARGIN_MS = 60 * 1000; + +let _enrichmentCacheTtlMs = MIN_ENRICHMENT_CACHE_TTL_MS; + +/** + * Sets the settled-PR enrichment cache TTL to the full-refresh interval plus + * ENRICHMENT_CACHE_TTL_MARGIN_MS, floored at MIN_ENRICHMENT_CACHE_TTL_MS, so an + * entry written by one scheduled poll is still valid on the next one. An + * interval of 0 (auto-refresh off) uses the floor. + */ +export function setPREnrichmentCacheRefreshInterval(refreshIntervalSec: number): void { + const derivedMs = refreshIntervalSec > 0 ? refreshIntervalSec * 1000 + ENRICHMENT_CACHE_TTL_MARGIN_MS : 0; + _enrichmentCacheTtlMs = Math.max(MIN_ENRICHMENT_CACHE_TTL_MS, derivedMs); +} + +interface CachedPREnrichment { + userLogin: string; + updatedAt: string; + fetchedAt: number; + repoFullName: string; + enrichment: PREnrichmentData; +} + +const _prEnrichmentCache = new Map(); +let _enrichmentCacheGen = 0; + +export function resetPREnrichmentCache(): void { + _enrichmentCacheGen++; + _prEnrichmentCache.clear(); + resetBodyFetchFailures(); +} + +export function invalidatePREnrichmentCacheForRepos(repoFullNames: Iterable): void { + const targets = new Set(); + for (const name of repoFullNames) { + targets.add(name.toLowerCase()); + } + if (targets.size === 0) return; + _enrichmentCacheGen++; + for (const [nodeId, entry] of _prEnrichmentCache) { + if (targets.has(entry.repoFullName.toLowerCase())) { + _prEnrichmentCache.delete(nodeId); + } + } +} + +export function evictPREnrichmentCacheNode(nodeId: string | undefined): void { + if (nodeId) { + _enrichmentCacheGen++; + _prEnrichmentCache.delete(nodeId); + } +} + /** * Merges tracked user search results into the main issue/PR maps. * Items already present get the tracked user's login appended to surfacedBy. @@ -1514,6 +1862,14 @@ function mergeTrackedUserResults( } } +export interface FetchIssuesAndPRsOptions { + onLightData?: (data: FetchIssuesAndPRsResult) => void; + trackedUsers?: TrackedUser[]; + monitoredRepos?: RepoRef[]; + /** Bypasses the settled-PR enrichment cache. */ + force?: boolean; +} + /** * Fetches open issues and PRs using a two-phase approach: * - Phase 1 (light): minimal fields for immediate rendering @@ -1530,12 +1886,12 @@ export async function fetchIssuesAndPullRequests( octokit: ReturnType, repos: RepoRef[], userLogin: string, - onLightData?: (data: FetchIssuesAndPRsResult) => void, - trackedUsers?: TrackedUser[], - monitoredRepos?: RepoRef[], + options: FetchIssuesAndPRsOptions = {}, ): Promise { + const { onLightData, trackedUsers, monitoredRepos, force = false } = options; if (!octokit) throw new Error("No GitHub client available"); + const cacheGen = _enrichmentCacheGen; const hasTrackedUsers = (trackedUsers?.length ?? 0) > 0; // Partition repos into normal (involves: search) and monitored (unfiltered search) @@ -1555,8 +1911,26 @@ export async function fetchIssuesAndPullRequests( const issueMap = new Map(); const prMap = new Map(); const nodeIdMap = new Map(); + const cachedEnrichments = new Map(); + const cachedPrIds = new Set(); + + const tryGetCachedEnrichment = (pr: PullRequest, nodeId: string): PREnrichmentData | null => { + if (force) return null; + const cached = _prEnrichmentCache.get(nodeId); + if ( + cached && + cached.userLogin === normalizedLogin && + cached.updatedAt === pr.updatedAt && + !cached.enrichment.checkStatusStale && + cached.enrichment.checkStatus !== "pending" && + Date.now() - cached.fetchedAt < _enrichmentCacheTtlMs + ) { + return cached.enrichment; + } + return null; + }; - // Phase 1: main user light search over normal repos (those not in monitoredRepos) + // Phase 1: main user light search over normalRepos (those not in monitoredRepos) if (normalRepos.length > 0 && userLogin) { const lightResult = await graphqlLightCombinedSearch(octokit, normalRepos, userLogin, "lightSearch"); allErrors.push(...lightResult.errors); @@ -1580,9 +1954,20 @@ export async function fetchIssuesAndPullRequests( }); } - // 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()]; + // Main user node IDs known — check settled-PR cache first, then start backfill + // in parallel with tracked user searches. + const mainNodeIds: string[] = []; + for (const pr of prMap.values()) { + const nodeId = nodeIdMap.get(pr.id); + if (!nodeId) continue; + const cached = tryGetCachedEnrichment(pr, nodeId); + if (cached) { + cachedEnrichments.set(pr.id, cached); + cachedPrIds.add(pr.id); + } else { + mainNodeIds.push(nodeId); + } + } const mainBackfillPromise = mainNodeIds.length > 0 ? fetchPREnrichment(octokit, mainNodeIds) : Promise.resolve({ enrichments: new Map(), errors: [] as ApiError[] }); @@ -1659,23 +2044,37 @@ export async function fetchIssuesAndPullRequests( 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, normalizedLogin) - : mergedPRs; - // Delta backfill: enrich PRs not already covered by main backfill — // includes both monitored repo PRs and new PRs from tracked users const deltaNodeIds: string[] = []; for (const pr of mergedPRs) { if (!preNewPrIds.has(pr.id)) { const nodeId = nodeIdMap.get(pr.id); - if (nodeId) deltaNodeIds.push(nodeId); + if (!nodeId) continue; + const cached = tryGetCachedEnrichment(pr, nodeId); + if (cached) { + cachedEnrichments.set(pr.id, cached); + cachedPrIds.add(pr.id); + } else { + deltaNodeIds.push(nodeId); + } } } + // Apply cached enrichments (skipping forkInfoMap population for already-resolved cached fork PRs) + let enrichedPRs = cachedEnrichments.size > 0 + ? mergeEnrichment(mergedPRs, cachedEnrichments, forkInfoMap, normalizedLogin, cachedPrIds) + : mergedPRs; + + // Apply main backfill enrichments + if (mainBackfill.enrichments.size > 0) { + enrichedPRs = mergeEnrichment(enrichedPRs, mainBackfill.enrichments, forkInfoMap, normalizedLogin); + } + + let deltaEnrichments = new Map(); if (deltaNodeIds.length > 0) { const delta = await fetchPREnrichment(octokit, deltaNodeIds); + deltaEnrichments = delta.enrichments; backfillErrors.push(...delta.errors); enrichedPRs = mergeEnrichment(enrichedPRs, delta.enrichments, forkInfoMap, normalizedLogin); } @@ -1684,6 +2083,44 @@ export async function fetchIssuesAndPullRequests( const enrichedPRMap = new Map(enrichedPRs.map(pr => [pr.id, pr])); await runForkPRFallback(octokit, enrichedPRMap, forkInfoMap); + if (cacheGen === _enrichmentCacheGen && (getClient() === octokit || getClient() == null)) { + const now = Date.now(); + const freshEnrichments = new Map([ + ...mainBackfill.enrichments, + ...deltaEnrichments, + ]); + for (const [prId, rawEnrichment] of freshEnrichments) { + const finalPr = enrichedPRMap.get(prId); + const nodeId = nodeIdMap.get(prId); + if (finalPr && nodeId && !finalPr.checkStatusStale && finalPr.checkStatus !== "pending") { + _prEnrichmentCache.set(nodeId, { + userLogin: normalizedLogin, + updatedAt: finalPr.updatedAt, + fetchedAt: now, + repoFullName: finalPr.repoFullName, + enrichment: { + ...rawEnrichment, + checkStatus: finalPr.checkStatus, + checkStatusStale: undefined, + }, + }); + } + } + const polledRepoSet = new Set(repos.map((r) => r.fullName.toLowerCase())); + const activeNodeIds = allErrors.length === 0 ? new Set(nodeIdMap.values()) : null; + for (const [nodeId, entry] of _prEnrichmentCache) { + if (now - entry.fetchedAt >= _enrichmentCacheTtlMs) { + _prEnrichmentCache.delete(nodeId); + } else if ( + activeNodeIds && + polledRepoSet.has(entry.repoFullName.toLowerCase()) && + !activeNodeIds.has(nodeId) + ) { + _prEnrichmentCache.delete(nodeId); + } + } + } + return { issues: mergedIssues, pullRequests: [...enrichedPRMap.values()], @@ -1987,6 +2424,7 @@ export async function fetchWorkflowRuns( export interface HotPRStatusUpdate { state: PullRequestState; draft?: boolean; + updatedAt?: string; checkStatus: CheckStatus["status"]; mergeStateStatus: string; reviewDecision: PullRequest["reviewDecision"]; @@ -1999,7 +2437,8 @@ export interface HotPRStatusUpdate { /** * Fetches current status fields (check status, review decision, state) for a * batch of PR node IDs using the nodes() GraphQL query. Returns a map keyed - * by databaseId. Uses Promise.allSettled per batch for error resilience. + * by databaseId. Uses Promise.allSettled per batch and salvages resolved nodes + * from a failed batch's partial data (skipping errored node indices). */ export async function fetchHotPRStatus( octokit: GitHubOctokit, @@ -2009,25 +2448,29 @@ export async function fetchHotPRStatus( const results = new Map(); if (nodeIds.length === 0) return { results, hadErrors: false }; - const batches = chunkArray(nodeIds, NODES_BATCH_SIZE); + // Same batch size as heavy backfill: HOT_PR_STATUS_QUERY requests the same + // mergeStateStatus + statusCheckRollup fields that time out at larger batch sizes. + const batches = chunkArray(nodeIds, HEAVY_BACKFILL_BATCH_SIZE); let hadErrors = false; - const settled = await Promise.allSettled(batches.map(async (batch) => { - const response = await octokit.graphql(HOT_PR_STATUS_QUERY, { ids: batch, request: { apiSource: "hotPRStatus" } }); - if (response.rateLimit) { - updateGraphqlRateLimit(response.rateLimit); - } - - for (const node of response.nodes) { - if (!node || node.databaseId == null) continue; - - const checkStatus = applyMergeStateOverride( - node.mergeStateStatus, - mapCheckStatus(node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null), - ); + const ingestHotNodes = ( + nodes: readonly (HotPRStatusNode | null)[] | undefined, + erroredIndices?: ReadonlySet, + ): void => { + if (!Array.isArray(nodes)) return; + for (const [idx, node] of nodes.entries()) { + if (!node || node.databaseId == null || erroredIndices?.has(idx)) continue; + + // Fork PRs whose CI runs only in the fork report no PR-level rollup; read the + // fork head commit's rollup instead (the same source runForkPRFallback uses). + const rollupState = + extractRollupState(node) ?? + (node.isCrossRepository ? (node.headRef?.target?.statusCheckRollup?.state ?? null) : null); + const checkStatus = applyMergeStateOverride(node.mergeStateStatus, mapCheckStatus(rollupState)); results.set(node.databaseId, { state: node.state, ...(typeof node.isDraft === "boolean" ? { draft: node.isDraft } : {}), + ...(node.updatedAt ? { updatedAt: node.updatedAt } : {}), checkStatus, mergeStateStatus: node.mergeStateStatus, reviewDecision: mapReviewDecision(node.reviewDecision), @@ -2037,6 +2480,14 @@ export async function fetchHotPRStatus( viewerLatestReviewState: mapViewerReviewState(node.viewerLatestReview?.state), }); } + }; + + const settled = await Promise.allSettled(batches.map(async (batch) => { + const response = await octokit.graphql(HOT_PR_STATUS_QUERY, { ids: batch, request: { apiSource: "hotPRStatus" } }); + if (response.rateLimit) { + updateGraphqlRateLimit(response.rateLimit); + } + ingestHotNodes(response.nodes); })); for (const s of settled) { @@ -2048,13 +2499,13 @@ export async function fetchHotPRStatus( if (options?.reportToSentry) { Sentry.captureException(s.reason, { tags: { source: "hot-poll-pr-batch" } }); } - const reason = s.reason; - const partialErr = (reason && typeof reason === "object" && "data" in reason && reason.data && typeof reason.data === "object") - ? reason.data as Partial - : null; + // Salvage nodes GraphQL resolved before the batch errored so stale/pending + // PRs in a partially failing batch still get a status this cycle. + const partialErr = extractPartialGraphqlData(s.reason); if (partialErr?.rateLimit) { updateGraphqlRateLimit(partialErr.rateLimit); } + ingestHotNodes(partialErr?.nodes, extractErroredNodeIndices(s.reason)); } } diff --git a/src/app/services/github.ts b/src/app/services/github.ts index 17db8f21..e8fbbb8e 100644 --- a/src/app/services/github.ts +++ b/src/app/services/github.ts @@ -62,7 +62,85 @@ function isAllowedGraphqlRequest(options: { query?: unknown; variables?: unknown // ── Plugin-extended Octokit class ──────────────────────────────────────────── -const GitHubOctokit = Octokit.plugin(throttling, retry, paginateRest); +export const REQUEST_TIMEOUT_MS = 15_000; +export const MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC = 15; + +const _syntheticResponseErrors = new WeakSet(); + +/** + * Runs inside @octokit/plugin-throttling (closest to @octokit/request) to abort + * requests whose AbortSignal fired while queued in Bottleneck and to attach + * a temporary empty response when a network or timeout error has no response object. + * This prevents plugin-throttling from throwing TypeError on error.response.headers + * when evaluating /graphql errors. + */ +function preThrottlingPlugin(octokit: Octokit): void { + octokit.hook.wrap("request", async (request, options) => { + const reqOpts = (options as { request?: { signal?: AbortSignal } }).request; + try { + if (reqOpts?.signal?.aborted) { + const reason: unknown = reqOpts.signal.reason; + throw reason instanceof Error + ? reason + : new DOMException("The operation was aborted.", "AbortError"); + } + return await request(options); + } catch (err) { + if ( + err && + typeof err === "object" && + (!("response" in err) || (err as { response?: unknown }).response == null) + ) { + (err as { response?: unknown }).response = { + url: options.url ?? "", + status: (err as { status?: number }).status ?? 500, + headers: {}, + data: "", + }; + _syntheticResponseErrors.add(err); + } + throw err; + } + }); +} + +/** + * Runs between @octokit/plugin-throttling and @octokit/plugin-retry to strip the + * synthetic response attached by preThrottlingPlugin, preserve options.request.retryCount + * when plugin-throttling resets it on non-rate-limit /graphql errors, and suppress retries + * when the caller's AbortSignal is already aborted. + */ +function postThrottlingPlugin(octokit: Octokit): void { + octokit.hook.wrap("request", async (request, options) => { + const reqOpts = ( + options as { request?: { retryCount?: number; retries?: number; signal?: AbortSignal } } + ).request; + const priorRetryCount = reqOpts?.retryCount; + try { + return await request(options); + } catch (err) { + if (err && typeof err === "object" && _syntheticResponseErrors.has(err)) { + delete (err as { response?: unknown }).response; + _syntheticResponseErrors.delete(err); + } + if (reqOpts && priorRetryCount !== undefined) { + reqOpts.retryCount = Math.max(reqOpts.retryCount ?? 0, priorRetryCount); + } + if (reqOpts?.signal?.aborted) { + reqOpts.retries = 0; + } + throw err; + } + }); +} + +const GitHubOctokit = Octokit.plugin( + preThrottlingPlugin, + throttling, + postThrottlingPlugin, + retry, + paginateRest +); // ── Types ──────────────────────────────────────────────────────────────────── @@ -148,14 +226,56 @@ export function onApiRequest(cb: (info: ApiRequestInfo) => void): void { // ── Client factory ─────────────────────────────────────────────────────────── +/** + * Returns a signal that aborts with the reason of whichever input signal aborts first. + * Falls back to manual listener wiring where AbortSignal.any is unavailable + * (Safari < 17.4, Chrome < 116, Firefox < 124); the fallback detaches its remaining + * listeners once any input fires. + */ +function anyAbortSignal(signals: AbortSignal[]): AbortSignal { + if (typeof AbortSignal.any === "function") return AbortSignal.any(signals); + const controller = new AbortController(); + const listeners: Array<[AbortSignal, () => void]> = []; + const detach = () => { + for (const [s, l] of listeners) s.removeEventListener("abort", l); + listeners.length = 0; + }; + for (const s of signals) { + if (s.aborted) { + controller.abort(s.reason); + detach(); + break; + } + const onAbort = () => { + controller.abort(s.reason); + detach(); + }; + listeners.push([s, onAbort]); + s.addEventListener("abort", onAbort, { once: true }); + } + return controller.signal; +} + export function createGitHubClient(authToken: string): GitHubOctokitInstance { + const boundedFetch: typeof fetch = (url, init) => { + const baseFetch = globalThis.fetch; + const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const signal = init?.signal + ? anyAbortSignal([init.signal, timeoutSignal]) + : timeoutSignal; + return baseFetch(url, { ...init, signal }); + }; + const client = new GitHubOctokit({ auth: authToken, userAgent: "github-tracker", + request: { + fetch: boundedFetch, + }, throttle: { onRateLimit: ( retryAfter: number, - options: { method: string; url: string }, + options: { method: string; url: string; request?: { signal?: AbortSignal } }, _octokit: GitHubOctokitInstance, retryCount: number ) => { @@ -163,11 +283,15 @@ export function createGitHubClient(authToken: string): GitHubOctokitInstance { `[github] Rate limit hit for ${options.method} ${options.url}. Retry after ${retryAfter}s.` ); pushNotification("rate-limit", `Rate limit hit — retrying in ${retryAfter}s`, "warning", true); - return retryCount < 1; + return ( + retryCount < 1 && + retryAfter <= MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC && + !options.request?.signal?.aborted + ); }, onSecondaryRateLimit: ( retryAfter: number, - options: { method: string; url: string }, + options: { method: string; url: string; request?: { signal?: AbortSignal } }, _octokit: GitHubOctokitInstance, retryCount: number ) => { @@ -175,7 +299,11 @@ export function createGitHubClient(authToken: string): GitHubOctokitInstance { `[github] Secondary rate limit for ${options.method} ${options.url}. Retry after ${retryAfter}s.` ); pushNotification("rate-limit", `Secondary rate limit — retrying in ${retryAfter}s. Consider reducing tracked repos.`, "warning", true); - return retryCount < 1; + return ( + retryCount < 1 && + retryAfter <= MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC && + !options.request?.signal?.aborted + ); }, }, retry: { diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 73fac790..38ad962c 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -16,13 +16,28 @@ import { type PullRequest, type WorkflowRun, type ApiError, + type FetchIssuesAndPRsOptions, type HotPRStatusUpdate, type HotWorkflowRunUpdate, resetEmptyActionRepos, + resetPREnrichmentCache, + invalidatePREnrichmentCacheForRepos, + setPREnrichmentCacheRefreshInterval, + isBackfillError, + hasDegradedEnrichment, + pushEnrichmentDegraded, } from "./api"; import { fetchUserEvents, parseRepoEvents, resetEventsState, type RepoEventSummary } from "./events"; +import { PR_ENRICHMENT_NOTIFICATION_SOURCE } from "../../shared/types"; import { detectNewItems, dispatchNotifications, _resetNotificationState } from "../lib/notifications"; -import { pushError, getNotifications, dismissNotificationBySource, startCycleTracking, endCycleTracking, resetNotificationState } from "../lib/errors"; +import { + pushError, + getNotifications, + dismissNotificationBySource, + startCycleTracking, + endCycleTracking, + resetNotificationState, +} from "../lib/errors"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -74,11 +89,60 @@ export function clearHotSets(): void { _hotRuns.clear(); } +export const WAKE_GRACE_MS = 2_000; +export const ONLINE_RECOVERY_GRACE_MS = 1_500; + +/** Consecutive cycles with a backfill batch failure before the degraded-enrichment + * warning is shown even when every PR still has carried-forward enrichment. */ +const BACKFILL_FAILURE_NOTIFICATION_THRESHOLD = 3; + +let _consecutiveBackfillFailures = 0; + +export function processBackfillErrors( + errors: ApiError[], + pullRequests: PullRequest[], +): ApiError[] { + const backfillErrors = errors.filter(isBackfillError); + const otherErrors = errors.filter((e) => !isBackfillError(e)); + + if (backfillErrors.length > 0) { + _consecutiveBackfillFailures++; + if ( + backfillErrors.some((e) => !e.retryable) || + _consecutiveBackfillFailures >= BACKFILL_FAILURE_NOTIFICATION_THRESHOLD || + hasDegradedEnrichment(pullRequests) + ) { + pushEnrichmentDegraded(); + } + } else { + // Light/unfiltered search failures (pagination follow-ups report under the + // same batch label) leave PRs missing, so they don't reset the streak. They + // keep an active warning alive only while some PR is still degraded; the + // search failure itself is surfaced through its own error entry. + const hasSearchError = otherErrors.some( + (e) => e.repo.startsWith("light-batch-") || e.repo.startsWith("unfiltered-batch-"), + ); + if (!hasSearchError && (pullRequests.length > 0 || otherErrors.length === 0)) { + _consecutiveBackfillFailures = 0; + } else if ( + hasDegradedEnrichment(pullRequests) && + (_consecutiveBackfillFailures >= BACKFILL_FAILURE_NOTIFICATION_THRESHOLD || + getNotifications().some((n) => n.source === PR_ENRICHMENT_NOTIFICATION_SOURCE)) + ) { + pushEnrichmentDegraded(); + } + } + + return otherErrors; +} + export function resetPollState(): void { _hotPRs.clear(); _hotPRsByDbId.clear(); _hotRuns.clear(); _hotPollGeneration = 0; + _consecutiveBackfillFailures = 0; + resetPREnrichmentCache(); _resetNotificationState(); resetEmptyActionRepos(); resetNotificationState(); @@ -182,6 +246,7 @@ createRoot(() => { */ export async function fetchAllData( onLightData?: (data: DashboardData) => void, + force = false, ): Promise { const octokit = getClient(); if (!octokit) { @@ -204,22 +269,31 @@ export async function fetchAllData( const trackedUsers = config.trackedUsers ?? []; const monitoredRepos = config.monitoredRepos ?? []; + setPREnrichmentCacheRefreshInterval(config.refreshInterval); // 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). // Note: monitoredRepos are NOT added to combinedRepos for workflow runs — // Actions fetches are already per selectedRepo. + // Phase 1: forward light issues + PRs to the caller (no workflow runs yet) + const forwardLightData: FetchIssuesAndPRsOptions["onLightData"] = onLightData + ? (lightData) => + onLightData({ + issues: lightData.issues, + pullRequests: lightData.pullRequests, + workflowRuns: [], + errors: lightData.errors, + }) + : undefined; + const [issuesAndPrsResult, runResult] = await Promise.allSettled([ - fetchIssuesAndPullRequests(octokit, combinedRepos, userLogin, onLightData ? (lightData) => { - // Phase 1: fire callback with light issues + PRs (no workflow runs yet) - onLightData({ - issues: lightData.issues, - pullRequests: lightData.pullRequests, - workflowRuns: [], - errors: lightData.errors, - }); - } : undefined, trackedUsers, monitoredRepos), + fetchIssuesAndPullRequests(octokit, combinedRepos, userLogin, { + onLightData: forwardLightData, + trackedUsers, + monitoredRepos, + force, + }), config.enableActions ? fetchWorkflowRuns(octokit, selectedRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow) : Promise.resolve({ workflowRuns: [] as WorkflowRun[], errors: [] as ApiError[] }), @@ -269,7 +343,14 @@ const REJITTER_WINDOW_MS = 30_000; // ±30 seconds jitter const REVISIT_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes // Sources managed by the poll coordinator — used for reconciliation -const POLL_MANAGED_SOURCES = new Set(["poll", "graphql", "rate-limit", "search/issues", "search/prs"]); +const POLL_MANAGED_SOURCES = new Set([ + "poll", + "graphql", + "rate-limit", + "search/issues", + "search/prs", + PR_ENRICHMENT_NOTIFICATION_SOURCE, +]); function withJitter(intervalMs: number): number { const jitter = (Math.random() * 2 - 1) * REJITTER_WINDOW_MS; @@ -282,30 +363,78 @@ function withJitter(intervalMs: number): number { * - Polls at getInterval() seconds (reactive — restarts when interval changes) * - If getInterval() === 0, disables auto-polling * - Skips background polls when hidden (GraphQL POST has no 304 shortcut) - * - On re-visible after >2 min hidden, fires catch-up fetch (safety net for - * browser tab throttling/freezing — Safari purge, Chrome Energy Saver) + * - On re-visible after >2 min hidden, fires catch-up fetch after WAKE_GRACE_MS + * (safety net for browser tab throttling/freezing — Safari purge, Chrome Energy Saver) * - Applies ±30 second jitter to poll interval * * Must be called inside a reactive root (e.g., createRoot or component body). */ export function createPollCoordinator( getInterval: () => number, - fetchAll: () => Promise + fetchAll: (force?: boolean) => Promise ): PollCoordinator { const [isRefreshing, setIsRefreshing] = createSignal(false); const [lastRefreshAt, setLastRefreshAt] = createSignal(null); let intervalId: ReturnType | null = null; + let wakeTimeoutId: ReturnType | null = null; + let onlineTimeoutId: ReturnType | null = null; + let onlineListener: (() => void) | null = null; let hiddenAt: number | null = null; let destroyed = false; let pendingForce = false; + function clearWakeAndOnlineTimers(): void { + if (wakeTimeoutId !== null) { + clearTimeout(wakeTimeoutId); + wakeTimeoutId = null; + } + if (onlineTimeoutId !== null) { + clearTimeout(onlineTimeoutId); + onlineTimeoutId = null; + } + if (onlineListener !== null && typeof window !== "undefined") { + window.removeEventListener("online", onlineListener); + onlineListener = null; + } + } + async function doFetch(force = false): Promise { if (destroyed) return; if (isRefreshing()) { if (force) pendingForce = true; return; } + if (!force && typeof navigator !== "undefined" && !navigator.onLine) { + if (!onlineListener && !onlineTimeoutId && typeof window !== "undefined") { + onlineListener = () => { + if (onlineListener) { + window.removeEventListener("online", onlineListener); + onlineListener = null; + } + if (onlineTimeoutId !== null) clearTimeout(onlineTimeoutId); + onlineTimeoutId = setTimeout(() => { + onlineTimeoutId = null; + if (destroyed) return; + if (typeof navigator !== "undefined" && !navigator.onLine) { + void doFetch(); + return; + } + if (document.visibilityState === "hidden") { + hiddenAt = Date.now() - REVISIT_THRESHOLD_MS - 1; + return; + } + void doFetch(); + const currentInterval = getInterval(); + if (currentInterval > 0) { + startTimer(currentInterval); + } + }, ONLINE_RECOVERY_GRACE_MS); + }; + window.addEventListener("online", onlineListener); + } + return; + } checkAndResetIfExpired(); setIsRefreshing(true); // Fire-and-forget: seeds footer signals concurrently with fetchAll. If GET /rate_limit @@ -326,10 +455,11 @@ export function createPollCoordinator( startCycleTracking(); try { - const data = await fetchAll(); + const data = await fetchAll(force); setLastRefreshAt(new Date()); - // Surface per-repo API errors globally - for (const err of data.errors) { + // Surface per-repo API errors globally, routing backfill-batch-* through 3-Tier escalation + const nonBackfillErrors = processBackfillErrors(data.errors, data.pullRequests); + for (const err of nonBackfillErrors) { pushError(err.repo, err.message, err.retryable); } // Reconcile: dismiss notifications for sources that didn't push this cycle @@ -380,22 +510,40 @@ export function createPollCoordinator( // Safety net for browser-level tab throttling/freezing. Background polls are // skipped (no 304 shortcut for GraphQL), but browsers may also freeze hidden // tab timers (Chrome Energy Saver, Safari tab purge, Firefox timer capping). - // When the tab becomes visible again after >2 min, fire a catch-up fetch. + // When the tab becomes visible again after >2 min, fire a catch-up fetch + // after WAKE_GRACE_MS so wake-from-sleep network interfaces can settle. function handleVisibilityChange(): void { if (document.visibilityState === "hidden") { - hiddenAt = Date.now(); + if (wakeTimeoutId !== null) { + clearTimeout(wakeTimeoutId); + wakeTimeoutId = null; + hiddenAt = hiddenAt ?? (Date.now() - REVISIT_THRESHOLD_MS - 1); + } else { + hiddenAt = Date.now(); + } } else { // Became visible const wasHiddenFor = hiddenAt !== null ? Date.now() - hiddenAt : 0; - hiddenAt = null; if (wasHiddenFor > REVISIT_THRESHOLD_MS) { - void doFetch(); - // Reset the interval timer so we don't double-fire shortly after + // Reset the interval timer immediately so an existing interval tick + // cannot fire inside the 2s post-wake grace window. const currentInterval = getInterval(); if (currentInterval > 0) { startTimer(currentInterval); } + if (wakeTimeoutId !== null) { + clearTimeout(wakeTimeoutId); + } + wakeTimeoutId = setTimeout(() => { + wakeTimeoutId = null; + hiddenAt = null; + if (!destroyed && document.visibilityState !== "hidden") { + void doFetch(); + } + }, WAKE_GRACE_MS); + } else { + hiddenAt = null; } } } @@ -415,12 +563,15 @@ export function createPollCoordinator( destroyed = true; pendingForce = false; clearTimer(); + clearWakeAndOnlineTimers(); document.removeEventListener("visibilitychange", handleVisibilityChange); } onCleanup(destroy); function manualRefresh(): void { + clearWakeAndOnlineTimers(); + hiddenAt = null; void doFetch(true); // Reset interval timer so next auto-poll is a full interval from now const currentInterval = getInterval(); @@ -447,7 +598,7 @@ export function rebuildHotSets(data: DashboardData): void { _hotRuns.clear(); for (const pr of data.pullRequests) { - if (pr.enriched && pr.checkStatus === "pending" && pr.nodeId) { + if (pr.enriched && (pr.checkStatus === "pending" || pr.checkStatusStale === true) && pr.nodeId) { if (_hotPRs.size >= MAX_HOT_PRS) { console.warn(`[hot-poll] PR cap reached (${MAX_HOT_PRS}), skipping remaining`); break; @@ -774,8 +925,13 @@ export async function fetchTargetedRepoData( .filter((r): r is NonNullable => r !== null) : []; + setPREnrichmentCacheRefreshInterval(config.refreshInterval); + invalidatePREnrichmentCacheForRepos(targetRepos.map((r) => r.fullName)); + + const trackedUsers = config.trackedUsers ?? []; + const monitoredRepos = config.monitoredRepos ?? []; const [issuesAndPrsResult, runResult] = await Promise.allSettled([ - fetchIssuesAndPullRequests(octokit, targetRepos, userLogin), + fetchIssuesAndPullRequests(octokit, targetRepos, userLogin, { trackedUsers, monitoredRepos }), workflowRepos.length > 0 ? fetchWorkflowRuns(octokit, workflowRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow) : Promise.resolve({ workflowRuns: [] as WorkflowRun[], errors: [] as ApiError[] }), @@ -806,7 +962,7 @@ export async function fetchTargetedRepoData( export function seedHotSetsFromTargeted(data: DashboardData): void { for (const pr of data.pullRequests) { - if (pr.enriched && pr.checkStatus === "pending" && pr.nodeId) { + if (pr.enriched && (pr.checkStatus === "pending" || pr.checkStatusStale === true) && pr.nodeId) { if (_hotPRs.size >= MAX_HOT_PRS) break; if (!_hotPRs.has(pr.nodeId)) { _hotPRs.set(pr.nodeId, pr.id); diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 6cbcf508..84d6ee99 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -8,6 +8,7 @@ import { pushNotification } from "../lib/errors"; import { clearJiraKeyCache } from "../services/jira-keys"; import { JIRA_SETTINGS_ACTION, type JiraAuthState } from "../../shared/jira-types"; import { JiraConfigSchema } from "../../shared/schemas"; +import { DASHBOARD_BODIES_STORAGE_KEY } from "../../shared/types"; // Zod schema for JiraAuthState — validates the localStorage-persisted blob on load. const JiraAuthStateSchema = z.object({ @@ -257,6 +258,9 @@ export const setAuthFromCredential = setAuthFromPat; * non-null) and `expireToken()` never clears IndexedDB. */ export async function clearIdentityData(): Promise { + localStorage.removeItem?.(DASHBOARD_STORAGE_KEY); + localStorage.removeItem?.(DEP_META_STORAGE_KEY); + localStorage.removeItem?.(DASHBOARD_BODIES_STORAGE_KEY); // Synchronous per-identity resets first (matches the ordering the identity- // switch branch has always had: callbacks complete before the awaited work). for (const cb of _onClearCallbacks) { @@ -294,6 +298,8 @@ export function clearAuth(): void { localStorage.removeItem(CONFIG_STORAGE_KEY); localStorage.removeItem(VIEW_STORAGE_KEY); localStorage.removeItem(DASHBOARD_STORAGE_KEY); + localStorage.removeItem(DEP_META_STORAGE_KEY); + localStorage.removeItem(DASHBOARD_BODIES_STORAGE_KEY); _setToken(null); setUser(null); // Clear IndexedDB cache to prevent data leakage between users @@ -323,6 +329,8 @@ export function clearAuth(): void { export function expireToken(): void { localStorage.removeItem(AUTH_STORAGE_KEY); localStorage.removeItem(DASHBOARD_STORAGE_KEY); + localStorage.removeItem(DEP_META_STORAGE_KEY); + localStorage.removeItem(DASHBOARD_BODIES_STORAGE_KEY); _setToken(null); setUser(null); _onClearCallbacks.forEach((cb) => { try { cb(); } catch (e) { console.warn("[auth] callback failed during expireToken:", e); } }); diff --git a/src/shared/format.ts b/src/shared/format.ts index 2d972114..02d77696 100644 --- a/src/shared/format.ts +++ b/src/shared/format.ts @@ -140,7 +140,8 @@ export function formatStarCount(count: number): string { /** * Returns true when the authenticated user can approve the given PR from a row action: - * - PR is OPEN, enriched, not a draft, CI checks are not failing, and has a valid GraphQL nodeId + headSha + * - PR is OPEN, enriched, not a draft, CI checks are not failing, CI check status is not stale + * (`checkStatusStale`), and has a valid GraphQL nodeId + headSha * - Authenticated user is not the PR author (case-insensitive) * - Viewer has not already approved the PR without a fresh review request * (`!(viewerLatestReviewState === "APPROVED" && !viewerReviewRequested)`) @@ -151,7 +152,7 @@ export function canApprovePR(pr: PullRequest, userLogin: string): boolean { if (!userLogin || !pr.nodeId || !pr.headSha) return false; if (pr.enriched === false) return false; if (pr.state !== "OPEN" || pr.draft) return false; - if (pr.checkStatus === "failure") return false; + if (pr.checkStatus === "failure" || pr.checkStatusStale) return false; if (pr.userLogin.toLowerCase() === userLogin.toLowerCase()) return false; if (pr.viewerLatestReviewState === "APPROVED" && !pr.viewerReviewRequested) return false; return pr.reviewDecision === "REVIEW_REQUIRED" || pr.viewerReviewRequested === true; diff --git a/src/shared/types.ts b/src/shared/types.ts index f97fe421..6e4ca1df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -49,6 +49,11 @@ export interface CheckStatus { export type AutoMergeMethod = "MERGE" | "SQUASH" | "REBASE"; export type ViewerReviewState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"; +export const PR_ENRICHMENT_NOTIFICATION_SOURCE = "pr-enrichment"; +export const PR_ENRICHMENT_DEGRADED_MESSAGE = + "Some pull request details could not be loaded — showing cached data where available"; +export const DASHBOARD_BODIES_STORAGE_KEY = "github-tracker:dashboard-bodies"; + export interface PullRequest { id: number; number: number; @@ -84,6 +89,14 @@ export interface PullRequest { starCount?: number; /** False when only light fields are loaded (phase 1); true/undefined when fully enriched */ enriched?: boolean; + /** + * True when CI check status for the PR's current revision is unverified: set by + * the lightweight backfill fallback (no check rollup), a failed fork rollup + * lookup, or carried-forward enrichment whose revision changed or was already + * stale. Cleared by a hot-poll status update or a successful backfill; while set, the PR is hot-polled + * and approve/mergeable gating treats CI as unknown. + */ + checkStatusStale?: boolean; /** GraphQL global node ID — used for hot-poll status updates */ nodeId?: string; /** PR body — fetched on demand for dependency update type detection */ diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index a540614c..2cd90dba 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -187,8 +187,10 @@ beforeEach(async () => { vi.mocked(authStore.expireToken).mockClear(); vi.mocked(githubService.getClient).mockReset(); vi.mocked(githubService.getClient).mockReturnValue(null); + vi.mocked(errorsLib.pushError).mockClear(); vi.mocked(errorsLib.pushNotification).mockClear(); vi.mocked(errorsLib.dismissNotificationBySource).mockClear(); + vi.mocked(errorsLib.getNotifications).mockReturnValue([]); vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], pullRequests: [], @@ -4369,6 +4371,92 @@ describe("DashboardPage — PR approval integration", () => { }); expect(screen.getByText("Changes")).toBeTruthy(); }); + + it("hides the Approve button while pr.checkStatusStale is true, then shows it and allows approval once hot-poll clears checkStatusStale", async () => { + const user = userEvent.setup(); + const graphqlMock = vi.fn().mockResolvedValue({ + addPullRequestReview: { + pullRequestReview: { + state: "APPROVED", + pullRequest: { + reviewDecision: "APPROVED", + }, + }, + }, + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-04-20T00:00:00Z" }, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const staleCheckPR = makePullRequest({ + id: 42, + number: 42, + title: "Fix race condition", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: true, + nodeId: "PR_kwDOtest0042", + headSha: validSha, + reviewDecision: "REVIEW_REQUIRED", + checkStatus: null, + checkStatusStale: true, + autoMergeMethod: null, + viewerReviewRequested: false, + viewerLatestReviewState: null, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [staleCheckPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await user.click(screen.getByText("Pull Requests")); + await waitFor(() => screen.getByText("owner/repo")); + await user.click(screen.getByText("owner/repo")); + + // While checkStatusStale === true, canApprovePR hides the Approve button entirely + await waitFor(() => expect(screen.getByText("Review needed")).toBeTruthy()); + expect(screen.queryByLabelText("Approve #42 Fix race condition")).toBeNull(); + expect(graphqlMock).not.toHaveBeenCalled(); + expect( + vi.mocked(errorsLib.pushNotification).mock.calls.some(([source]) => source === "approve-pr:42") + ).toBe(false); + + // Hot poll clears checkStatusStale and updates checkStatus to "success" + capturedOnHotData!( + new Map([ + [ + 42, + { + state: "OPEN", + draft: false, + checkStatus: "success", + reviewDecision: "REVIEW_REQUIRED", + mergeStateStatus: "BLOCKED", + headSha: validSha, + autoMergeMethod: null, + viewerReviewRequested: false, + viewerLatestReviewState: null, + }, + ], + ]), + new Map(), + 0 + ); + + // Retry approval after checkStatusStale is cleared -> succeeds + const retryBtn = await waitFor(() => screen.getByLabelText("Approve #42 Fix race condition")); + await user.click(retryBtn); + await user.click(retryBtn); + await waitFor(() => expect(graphqlMock).toHaveBeenCalledOnce()); + await waitFor(() => expect(screen.getByText("Approved")).toBeTruthy()); + }); }); // ── Mark PR Ready Integration ──────────────────────────────────────────────── @@ -4820,6 +4908,183 @@ describe("DashboardPage — Mark PR ready integration", () => { // ── Fallback PR Enrichment Propagation ─────────────────────────────────────── +describe("DashboardPage — PR mutation enrichment cache eviction", () => { + const validSha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const approveResponse = { + addPullRequestReview: { + pullRequestReview: { state: "APPROVED", pullRequest: { reviewDecision: "APPROVED" } }, + }, + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-04-20T00:00:00Z" }, + }; + const markReadyResponse = { + markPullRequestReadyForReview: { pullRequest: { isDraft: false, reviewDecision: "REVIEW_REQUIRED" } }, + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-04-20T00:00:00Z" }, + }; + + // DashboardPage imports the real api module (fresh per vi.resetModules in beforeEach); + // fetchAllData is mocked, so the cache itself is exercised in api.test.ts. + async function spyOnEvict() { + const apiModule = await import("../../src/app/services/api"); + return vi.spyOn(apiModule, "evictPREnrichmentCacheNode"); + } + + async function renderWithPR(pr: ReturnType) { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [pr], + workflowRuns: [], + errors: [], + }); + const user = userEvent.setup(); + render(() => ); + await user.click(screen.getByText("Pull Requests")); + await waitFor(() => screen.getByText("owner/repo")); + await user.click(screen.getByText("owner/repo")); + return user; + } + + const makeApprovablePR = () => + makePullRequest({ + id: 42, + number: 42, + title: "Fix race condition", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: true, + nodeId: "PR_kwDOtest0042", + headSha: validSha, + reviewDecision: "REVIEW_REQUIRED", + checkStatus: "success", + }); + + const makeDraftPR = () => + makePullRequest({ + id: 77, + number: 77, + title: "Ship draft feature", + repoFullName: "owner/repo", + userLogin: "testuser", + state: "OPEN", + draft: true, + enriched: true, + nodeId: "PR_kwDOtest0077", + reviewDecision: null, + checkStatus: "success", + }); + + it("evicts the PR's enrichment cache entry after a successful approval", async () => { + const evictSpy = await spyOnEvict(); + const graphqlMock = vi.fn().mockResolvedValue(approveResponse); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const user = await renderWithPR(makeApprovablePR()); + const approveBtn = await waitFor(() => screen.getByLabelText("Approve #42 Fix race condition")); + await user.click(approveBtn); + await user.click(approveBtn); + + await waitFor(() => expect(evictSpy).toHaveBeenCalledWith("PR_kwDOtest0042")); + expect(evictSpy).toHaveBeenCalledOnce(); + }); + + it("does not evict the cache entry when approval fails", async () => { + const evictSpy = await spyOnEvict(); + const forbiddenErr = Object.assign(new Error("Resource not accessible by personal access token"), { + status: 403, + errors: [{ type: "FORBIDDEN", message: "Resource not accessible by personal access token" }], + }); + const graphqlMock = vi.fn().mockRejectedValue(forbiddenErr); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const user = await renderWithPR(makeApprovablePR()); + const approveBtn = await waitFor(() => screen.getByLabelText("Approve #42 Fix race condition")); + await user.click(approveBtn); + await user.click(approveBtn); + + await waitFor(() => { + expect(errorsLib.pushNotification).toHaveBeenCalledWith( + "approve-pr:42", + expect.stringContaining("lacks Pull Requests write permission"), + "error" + ); + }); + expect(evictSpy).not.toHaveBeenCalled(); + }); + + it("evicts the PR's enrichment cache entry after a successful mark-ready", async () => { + const evictSpy = await spyOnEvict(); + const graphqlMock = vi.fn().mockResolvedValue(markReadyResponse); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const user = await renderWithPR(makeDraftPR()); + const draftBtn = await waitFor(() => screen.getByLabelText("Mark #77 Ship draft feature ready for review")); + await user.click(draftBtn); + await user.click(draftBtn); + + await waitFor(() => expect(evictSpy).toHaveBeenCalledWith("PR_kwDOtest0077")); + expect(evictSpy).toHaveBeenCalledOnce(); + }); + + it("does not evict the cache entry when the GitHub client changes while mark-ready is in flight", async () => { + const evictSpy = await spyOnEvict(); + const otherClient = { graphql: vi.fn() } as unknown as ReturnType; + const graphqlMock = vi.fn().mockImplementation(async () => { + vi.mocked(githubService.getClient).mockReturnValue(otherClient); + return markReadyResponse; + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const user = await renderWithPR(makeDraftPR()); + const draftBtn = await waitFor(() => screen.getByLabelText("Mark #77 Ship draft feature ready for review")); + await user.click(draftBtn); + await user.click(draftBtn); + + await waitFor(() => expect(graphqlMock).toHaveBeenCalledOnce()); + // Handler finished: in-flight state cleared (button re-enabled) and the PR is still a draft + // because the swapped-client early return skips the store update. + await waitFor(() => { + const btn = screen.getByLabelText("Mark #77 Ship draft feature ready for review") as HTMLButtonElement; + expect(btn.disabled).toBe(false); + }); + expect(evictSpy).not.toHaveBeenCalled(); + }); + + it("does not evict the cache entry when the GitHub client changes while approval is in flight", async () => { + const evictSpy = await spyOnEvict(); + const otherClient = { graphql: vi.fn() } as unknown as ReturnType; + const graphqlMock = vi.fn().mockImplementation(async () => { + vi.mocked(githubService.getClient).mockReturnValue(otherClient); + return approveResponse; + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + const user = await renderWithPR(makeApprovablePR()); + const approveBtn = await waitFor(() => screen.getByLabelText("Approve #42 Fix race condition")); + await user.click(approveBtn); + await user.click(approveBtn); + + await waitFor(() => expect(graphqlMock).toHaveBeenCalledOnce()); + // Handler finished: in-flight state cleared, and the review decision was not updated + await waitFor(() => { + const btn = screen.getByLabelText("Approve #42 Fix race condition") as HTMLButtonElement; + expect(btn.disabled).toBe(false); + }); + expect(screen.getByText("Review needed")).toBeTruthy(); + expect(evictSpy).not.toHaveBeenCalled(); + }); +}); + describe("DashboardPage — fallbackToPreviousEnrichment propagation to hot sets and localStorage", () => { it("retains fallback-merged enrichment in rebuildHotSets and localStorage when a second poll cycle returns enriched: false on a previously-enriched PR", async () => { const enrichedPR = makePullRequest({ @@ -5154,7 +5419,7 @@ describe("DashboardPage — dashboardBodies caching and cooldown", () => { } }); - it("prunes _dashboardBodyCache and _dashboardBodyFailureCooldown when a dashboard issue leaves dashboardData.issues and later reappears", async () => { + it("prunes _dashboardBodyCache and _dashboardBodyFailureCooldown when a dashboard issue leaves dashboardData.issues on a clean poll, but preserves _dashboardBodyCache when lastFetchHadErrors() is true", async () => { const graphqlSpy = vi.fn() .mockRejectedValueOnce(new Error("transient fail")) .mockResolvedValue({ @@ -5188,7 +5453,7 @@ describe("DashboardPage — dashboardBodies caching and cooldown", () => { await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); await new Promise((resolve) => setTimeout(resolve, 50)); - // Poll 2: dashboard issue is closed/absent — prunes DASH_PRUNE from cooldown and cache + // Poll 2: dashboard issue is closed/absent — prunes DASH_PRUNE from cooldown vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], pullRequests: [depPRInRepo()], @@ -5198,7 +5463,7 @@ describe("DashboardPage — dashboardBodies caching and cooldown", () => { await capturedFetchAll!(); await new Promise((resolve) => setTimeout(resolve, 50)); - // Poll 3: dashboard issue reappears within cooldown window — re-fetched immediately because cooldown was pruned + // Poll 3: dashboard issue reappears within cooldown window — re-fetched immediately because cooldown was pruned, populating _dashboardBodyCache vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [makeDashIssue()], pullRequests: [depPRInRepo()], @@ -5207,6 +5472,57 @@ describe("DashboardPage — dashboardBodies caching and cooldown", () => { }); await capturedFetchAll!(); await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(2)); + await waitFor(() => { + const stored = JSON.parse(localStorage.getItem("github-tracker:dashboard-bodies") ?? "{}") as Record; + expect(stored.DASH_PRUNE).toBeDefined(); + }); + + // Poll 4: issue search fails (errors non-empty, issues: [], pullRequests non-empty) -> !lastFetchHadErrors() guard prevents pruning _dashboardBodyCache + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [{ repo: "search-issues", statusCode: 502, message: "Bad Gateway", retryable: true }], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect( + (JSON.parse(localStorage.getItem("github-tracker:dashboard-bodies") ?? "{}") as Record).DASH_PRUNE + ).toBeDefined(); + + // Poll 5: dashboard issue returns on next clean poll -> cache hit (no new GraphQL call) because Poll 4 did not prune DASH_PRUNE + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(graphqlSpy).toHaveBeenCalledTimes(2); + + // Poll 6: clean poll (errors: []) with issues: [] -> prunes DASH_PRUNE from _dashboardBodyCache and persists removal + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => { + const stored = JSON.parse(localStorage.getItem("github-tracker:dashboard-bodies") ?? "{}") as Record; + expect(stored.DASH_PRUNE).toBeUndefined(); + }); + + // Poll 7: dashboard issue reappears after clean prune -> re-fetched via GraphQL + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(3)); }); it("clears _dashboardBodyCache on onAuthCleared and ignores in-flight fetch resolution after logout", async () => { @@ -5339,4 +5655,1142 @@ describe("DashboardPage — dashboardBodies caching and cooldown", () => { expect(screen.queryByText(/abandoned/i)).toBeNull(); }); }); + + it("persists parsed _dashboardBodyCache to localStorage ('github-tracker:dashboard-bodies'), hydrates via seedCachedDashboardBodies() before the first refresh resolves across vi.resetModules() with 0 GraphQL calls, and removes it on onAuthCleared", async () => { + const user = userEvent.setup(); + const dashIssue = makeIssue({ + id: 16, + number: 16, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_PERSIST_1", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }); + + const graphqlSpy1 = vi.fn().mockResolvedValue({ + nodes: [ + { + id: "DASH_PERSIST_1", + body: [ + "## Abandoned", + "", + "| Datasource | Package | Last Updated |", + "| --- | --- | --- |", + "| npm | axios | 2020-01-01 |", + ].join("\n"), + }, + ], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy1 } as unknown as ReturnType + ); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [dashIssue], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + const { unmount } = render(() => ); + await waitFor(() => expect(graphqlSpy1).toHaveBeenCalledTimes(1)); + await waitFor(() => { + // Wait for both the deferred setTimeout write of github-tracker:dashboard and github-tracker:dashboard-bodies + expect(localStorage.getItem("github-tracker:dashboard")).not.toBeNull(); + const storedRaw = localStorage.getItem("github-tracker:dashboard-bodies"); + expect(storedRaw).not.toBeNull(); + const stored = JSON.parse(storedRaw!) as Record }>; + expect(stored.DASH_PERSIST_1).toEqual({ + updatedAt: "2026-04-01T00:00:00Z", + deps: [{ datasource: "npm", packageName: "axios", lastUpdated: "2020-01-01" }], + }); + }); + unmount(); + + // Simulate reload: reset modules while keeping localStorage intact (both dashboard cache and dashboard-bodies cache) + vi.resetModules(); + authClearCallbacks.length = 0; + + let resolveReloadFetch: ((data: DashboardData) => void) | null = null; + const graphqlSpy2 = vi.fn(); + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveReloadFetch = resolve; + }) + ), + createPollCoordinator: vi.fn().mockImplementation((_getInterval: unknown, fetchAll: () => Promise) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + const wrappedFetchAll = async () => { + const res = await fetchAll(); + setLastRefreshAt(new Date()); + return res; + }; + capturedFetchAll = wrappedFetchAll; + void wrappedFetchAll().catch(() => {}); + return { + isRefreshing: () => true, + lastRefreshAt, + manualRefresh: vi.fn(), + destroy: vi.fn(), + }; + }), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + + const freshGithub = await import("../../src/app/services/github"); + vi.mocked(freshGithub.getClient).mockReturnValue( + { graphql: graphqlSpy2 } as unknown as ReturnType + ); + const freshDashMod = await import("../../src/app/components/dashboard/DashboardPage"); + + render(() => ); + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + // Pre-refresh assertion: before fetchAllData resolves (and before the { defer: true } dashboardBodies effect runs), + // seedCachedDashboardBodies() has already hydrated abandonedDepsMap and dashboardIssueUrls at module load + await waitFor(() => { + expect(screen.getByText(/abandoned/i)).toBeDefined(); + }); + expect(graphqlSpy2).not.toHaveBeenCalled(); + + // Now let the first refresh resolve — still 0 GraphQL calls because updatedAt matches _dashboardBodyCache + resolveReloadFetch!({ + issues: [dashIssue], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(graphqlSpy2).not.toHaveBeenCalled(); + + // Invoking onAuthCleared removes github-tracker:dashboard-bodies from localStorage + for (const cb of authClearCallbacks) cb(); + expect(localStorage.getItem("github-tracker:dashboard-bodies")).toBeNull(); + }); + + it("drops a 'github-tracker:dashboard-bodies' entry with invalid deps even when updatedAt matches, keeps valid entries, and re-fetches only the invalid issue", async () => { + const user = userEvent.setup(); + localStorage.setItem( + "github-tracker:dashboard-bodies", + JSON.stringify({ + DASH_VALID: { + updatedAt: "2026-04-01T00:00:00Z", + deps: [{ datasource: "npm", packageName: "axios", lastUpdated: "2020-01-01" }], + }, + // updatedAt matches the live issue, so only entry validation (empty datasource) can force the re-fetch + DASH_CORRUPT: { + updatedAt: "2026-04-01T00:00:00Z", + deps: [{ datasource: "", packageName: "lodash", lastUpdated: "2020-01-01" }], + }, + }) + ); + + vi.resetModules(); + const graphqlSpy = vi.fn().mockResolvedValue({ + nodes: [{ id: "DASH_CORRUPT", body: "" }], + rateLimit: null, + }); + + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockResolvedValue({ + issues: [ + makeIssue({ + id: 17, + number: 17, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_VALID", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + makeIssue({ + id: 18, + number: 18, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_CORRUPT", + repoFullName: "owner/other", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [ + depPRInRepo(), + makePullRequest({ + id: 902, + nodeId: "PR_DEP_902", + repoFullName: "owner/other", + title: "Bump lodash from 4.17.20 to 4.17.21", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.17.21", + }), + ], + workflowRuns: [], + errors: [], + }), + createPollCoordinator: vi.fn().mockImplementation((_getInterval: unknown, fetchAll: () => Promise) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + const wrappedFetchAll = async () => { + const res = await fetchAll(); + setLastRefreshAt(new Date()); + return res; + }; + capturedFetchAll = wrappedFetchAll; + void wrappedFetchAll().catch(() => {}); + return { + isRefreshing: () => false, + lastRefreshAt, + manualRefresh: vi.fn(), + destroy: vi.fn(), + }; + }), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + + const freshGithub = await import("../../src/app/services/github"); + vi.mocked(freshGithub.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + const freshDashMod = await import("../../src/app/components/dashboard/DashboardPage"); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + const [, variables] = graphqlSpy.mock.calls[0] as [string, { ids: string[] }]; + expect(variables.ids).toEqual(["DASH_CORRUPT"]); + + // Valid cached entry still drives the owner/repo badge; the dropped corrupt entry + // (lodash) does not mark the owner/other PR abandoned. + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => expect(screen.getAllByText("Abandoned")).toHaveLength(1)); + }); + + describe("loadDashboardBodyCache", () => { + const KEY = "github-tracker:dashboard-bodies"; + const validDep = { datasource: "npm", packageName: "axios", lastUpdated: "2020-01-01" }; + const validEntry = { updatedAt: "2026-04-01T00:00:00Z", deps: [validDep] }; + let loadDashboardBodyCache: typeof import("../../src/app/components/dashboard/DashboardPage").loadDashboardBodyCache; + + beforeEach(async () => { + ({ loadDashboardBodyCache } = await import("../../src/app/components/dashboard/DashboardPage")); + }); + + it.each(["{bad json", "[]", "42", "null", '"str"'])("returns an empty map for invalid or non-object root %s", (raw) => { + localStorage.setItem(KEY, raw); + expect(loadDashboardBodyCache().size).toBe(0); + }); + + it("keeps valid entries, drops each invalid entry independently, and strips unknown fields", () => { + localStorage.setItem( + KEY, + JSON.stringify({ + DASH_OK: { ...validEntry, extra: "x", deps: [{ ...validDep, extra: 1 }] }, + DASH_NUM_UPDATED_AT: { updatedAt: 12345, deps: [] }, + DASH_EMPTY_UPDATED_AT: { updatedAt: "", deps: [] }, + DASH_LONG_UPDATED_AT: { updatedAt: "x".repeat(65), deps: [] }, + DASH_EMPTY_DATASOURCE: { ...validEntry, deps: [{ ...validDep, datasource: "" }] }, + DASH_LONG_PACKAGE: { ...validEntry, deps: [{ ...validDep, packageName: "a".repeat(513) }] }, + DASH_DEPS_NOT_ARRAY: { ...validEntry, deps: {} }, + DASH_DEP_IS_ARRAY: { ...validEntry, deps: [[]] }, + DASH_TOO_MANY_DEPS: { ...validEntry, deps: Array.from({ length: 201 }, () => validDep) }, + DASH_ENTRY_IS_ARRAY: [], + ["k".repeat(129)]: validEntry, + constructor: validEntry, + prototype: validEntry, + }) + ); + const map = loadDashboardBodyCache(); + expect([...map.keys()]).toEqual(["DASH_OK"]); + expect(map.get("DASH_OK")).toEqual(validEntry); + }); + + it("ignores an own __proto__ key", () => { + localStorage.setItem(KEY, `{"__proto__":${JSON.stringify(validEntry)},"DASH_OK":${JSON.stringify(validEntry)}}`); + expect([...loadDashboardBodyCache().keys()]).toEqual(["DASH_OK"]); + }); + + it("accepts boundary-length values and truncates to the first 500 valid entries", () => { + const boundary = { + updatedAt: "x".repeat(64), + deps: Array.from({ length: 200 }, () => ({ + datasource: "d".repeat(128), + packageName: "p".repeat(512), + lastUpdated: "l".repeat(64), + })), + }; + const obj: Record = { ["k".repeat(128)]: boundary, DASH_BAD: { updatedAt: 1, deps: [] } }; + for (let i = 0; i < 500; i++) obj[`DASH_${i}`] = validEntry; + localStorage.setItem(KEY, JSON.stringify(obj)); + const map = loadDashboardBodyCache(); + expect(map.size).toBe(500); + expect(map.get("k".repeat(128))).toEqual(boundary); + expect(map.has("DASH_498")).toBe(true); + expect(map.has("DASH_499")).toBe(false); + }); + }); + + it("defers depBodies GraphQL fetch during onLightData while all PRs have enriched === false and isRefreshing() is true, then fetches when enrichment completes", async () => { + const [isRefreshingSig, setIsRefreshingSig] = createSignal(true); + let resolveFullFetch: ((data: DashboardData) => void) | null = null; + + const unenrichedDepPR = makePullRequest({ + id: 950, + nodeId: "PR_DEP_950", + repoFullName: "owner/repo", + title: "Update dependency some-pkg", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/some-pkg", + enriched: false, + }); + const enrichedDepPR = makePullRequest({ + id: 950, + nodeId: "PR_DEP_950", + repoFullName: "owner/repo", + title: "Update dependency some-pkg", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/some-pkg", + enriched: true, + }); + + const graphqlSpy = vi.fn().mockResolvedValue({ + nodes: [ + { + id: "PR_DEP_950", + databaseId: 950, + body: "| Package | Change |\n| --- | --- |\n| some-pkg | `1.0.0` -> `1.1.0` |", + }, + ], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + vi.mocked(pollService.fetchAllData).mockImplementation((onLightData) => { + onLightData?.({ + issues: [], + pullRequests: [unenrichedDepPR], + workflowRuns: [], + errors: [], + }); + return new Promise((resolve) => { + resolveFullFetch = resolve; + }); + }); + + vi.mocked(pollService.createPollCoordinator).mockImplementation((_getInterval, fetchAll) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + const wrappedFetchAll = async () => { + setIsRefreshingSig(true); + try { + const res = await fetchAll(); + setLastRefreshAt(new Date()); + return res; + } finally { + setIsRefreshingSig(false); + } + }; + capturedFetchAll = wrappedFetchAll; + void wrappedFetchAll().catch(() => {}); + return { + isRefreshing: isRefreshingSig, + lastRefreshAt, + manualRefresh: vi.fn(), + destroy: vi.fn(), + }; + }); + + render(() => ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // While light search data is displayed (all PRs enriched === false) and isRefreshing() is true, + // depBodies must NOT have fired a GraphQL query + expect(graphqlSpy).not.toHaveBeenCalled(); + + // Now full enrichment completes with enriched === true and isRefreshing transitions to false + resolveFullFetch!({ + issues: [], + pullRequests: [enrichedDepPR], + workflowRuns: [], + errors: [], + }); + + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + }); + + it("suppresses the Dependencies 'Checking status' badge on light PRs while enrichment is pending, then shows it for PRs still unenriched after the refresh", async () => { + const user = userEvent.setup(); + const [isRefreshingSig, setIsRefreshingSig] = createSignal(true); + let resolveFullFetch: ((data: DashboardData) => void) | null = null; + const lightDepPR = () => + makePullRequest({ + id: 960, + nodeId: "PR_DEP_960", + repoFullName: "owner/repo", + title: "Bump lodash from 4.17.20 to 4.17.21", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.17.21", + enriched: false, + checkStatus: null, + updatedAt: new Date().toISOString(), + }); + + vi.mocked(pollService.fetchAllData).mockImplementation((onLightData) => { + onLightData?.({ issues: [], pullRequests: [lightDepPR()], workflowRuns: [], errors: [] }); + return new Promise((resolve) => { + resolveFullFetch = resolve; + }); + }); + vi.mocked(pollService.createPollCoordinator).mockImplementation((_getInterval, fetchAll) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + const wrappedFetchAll = async () => { + setIsRefreshingSig(true); + try { + const res = await fetchAll(); + setLastRefreshAt(new Date()); + return res; + } finally { + setIsRefreshingSig(false); + } + }; + capturedFetchAll = wrappedFetchAll; + void wrappedFetchAll().catch(() => {}); + return { isRefreshing: isRefreshingSig, lastRefreshAt, manualRefresh: vi.fn(), destroy: vi.fn() }; + }); + + render(() => ); + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => expect(document.getElementById("dep-group-needs-action")).not.toBeNull()); + // Light results set loading: false, but enrichment is still pending — no badge flash + expect(screen.queryByText("Checking status")).toBeNull(); + + // Full fetch resolves with the PR still unenriched (degraded) and isRefreshing → false + resolveFullFetch!({ issues: [], pullRequests: [lightDepPR()], workflowRuns: [], errors: [] }); + await waitFor(() => expect(screen.getAllByText("Checking status")).toHaveLength(1)); + }); + + it("suppresses the Dependencies 'Checking status' badge while the dashboard is still loading from a cached snapshot", async () => { + const user = userEvent.setup(); + // Snapshot without lastRefreshedAt: pollFetch keeps `loading: true` until the first fetch resolves + localStorage.setItem( + "github-tracker:dashboard", + JSON.stringify({ + _v: 2, + issues: [], + pullRequests: [ + makePullRequest({ + id: 970, + nodeId: "PR_DEP_970", + repoFullName: "owner/repo", + title: "Bump lodash from 4.17.20 to 4.17.21", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/lodash-4.17.21", + enriched: false, + checkStatus: null, + updatedAt: new Date().toISOString(), + }), + ], + workflowRuns: [], + }) + ); + + vi.resetModules(); + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockImplementation(() => new Promise(() => {})), + createPollCoordinator: vi.fn().mockImplementation((_getInterval: unknown, fetchAll: () => Promise) => { + void fetchAll().catch(() => {}); + return { + isRefreshing: () => false, + lastRefreshAt: () => null, + manualRefresh: vi.fn(), + destroy: vi.fn(), + }; + }), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + const freshDashMod = await import("../../src/app/components/dashboard/DashboardPage"); + + render(() => ); + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => expect(document.getElementById("dep-group-needs-action")).not.toBeNull()); + expect(screen.queryByText("Checking status")).toBeNull(); + }); +}); + +describe("DashboardPage — 3-Tier backfill-batch-* handling, checkStatusStale restoration, and partial-sync indicator", () => { + it("returns resolvedPullRequests with enriched: true from pollFetch on cached backfill failure and suppresses Tier 0 targeted backfill errors while escalating Tier 1 with silentToast: true", async () => { + const enrichedPR = makePullRequest({ + id: 601, + number: 601, + title: "Already enriched PR", + repoFullName: "owner/repo", + enriched: true, + checkStatus: "success", + additions: 25, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(capturedFetchAll).not.toBeNull()); + + // Poll 2 fails backfill-batch-1/1, returning enriched: false for PR 601 + const unenrichedPR = makePullRequest({ + id: 601, + number: 601, + title: "Already enriched PR", + repoFullName: "owner/repo", + enriched: false, + checkStatus: null, + additions: 0, + }); + const backfillError = { + repo: "backfill-batch-1/1", + statusCode: null, + message: "Failed to fetch", + retryable: true, + }; + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [unenrichedPR], + workflowRuns: [], + errors: [backfillError], + }); + + const poll2Result = await capturedFetchAll!(); + // pollFetch returns post-fallbackToPreviousEnrichment pullRequests so doFetch sees enriched: true + expect(poll2Result.pullRequests).toHaveLength(1); + expect(poll2Result.pullRequests[0].enriched).toBe(true); + expect(poll2Result.pullRequests[0].checkStatus).toBe("success"); + + // Targeted update with backfill-batch-1/1 error on an already-enriched PR: Tier 0 silent + const targetedUnenriched601 = makePullRequest({ + id: 601, + number: 601, + title: "Already enriched PR", + repoFullName: "owner/repo", + enriched: false, + checkStatus: null, + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [targetedUnenriched601], + workflowRuns: [], + errors: [backfillError], + }, + ["owner/repo"] + ); + expect(errorsLib.pushError).not.toHaveBeenCalledWith( + "backfill-batch-1/1", + expect.anything(), + expect.anything() + ); + expect(errorsLib.pushNotification).not.toHaveBeenCalledWith( + "pr-enrichment", + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything() + ); + + // Targeted update with a brand-new unenriched PR (cold start) and backfill-batch-1/1 error: Tier 1 silentToast + const coldTargetedPR = makePullRequest({ + id: 602, + number: 602, + title: "Cold targeted PR", + repoFullName: "owner/repo", + enriched: false, + checkStatus: null, + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [coldTargetedPR], + workflowRuns: [], + errors: [backfillError], + }, + ["owner/repo"] + ); + expect(errorsLib.pushError).not.toHaveBeenCalledWith( + "backfill-batch-1/1", + expect.anything(), + expect.anything() + ); + expect(errorsLib.pushNotification).toHaveBeenCalledWith( + "pr-enrichment", + "Some pull request details could not be loaded — showing cached data where available", + "warning", + true, + undefined, + { silentToast: true } + ); + }); + + it("renders FilterBar partial-sync-indicator when 'pr-enrichment' notification is active in getNotifications()", async () => { + const [notifs, setNotifs] = createSignal([]); + vi.mocked(errorsLib.getNotifications).mockImplementation(() => notifs()); + + render(() => ); + expect(screen.queryByTestId("partial-sync-indicator")).toBeNull(); + + setNotifs([ + { + id: "notif-pr-enrichment", + source: "pr-enrichment", + message: "Some pull request details could not be loaded — showing cached data where available", + timestamp: Date.now(), + retryable: true, + severity: "warning", + read: false, + silentToast: true, + }, + ]); + + await waitFor(() => { + expect(screen.getByTestId("partial-sync-indicator")).toBeDefined(); + }); + }); + + it("does not trigger Tier 1 pr-enrichment warning when onLightData fires with enriched: false and full fetch completes cleanly", async () => { + const lightPR = makePullRequest({ + id: 603, + number: 603, + title: "Light PR", + repoFullName: "owner/repo", + enriched: false, + checkStatus: null, + }); + const fullPR = makePullRequest({ + id: 603, + number: 603, + title: "Light PR", + repoFullName: "owner/repo", + enriched: true, + checkStatus: "success", + }); + + vi.mocked(pollService.fetchAllData).mockImplementation(async (onLightData) => { + onLightData?.({ + issues: [], + pullRequests: [lightPR], + workflowRuns: [], + errors: [], + }); + return { + issues: [], + pullRequests: [fullPR], + workflowRuns: [], + errors: [], + }; + }); + + render(() => ); + await waitFor(() => expect(pollService.rebuildHotSets).toHaveBeenCalled()); + + expect(errorsLib.pushNotification).not.toHaveBeenCalledWith( + "pr-enrichment", + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything() + ); + }); + + it("preserves prior non-stale checkStatus in pollFetch and handleTargetedData when light backfill fallback sets checkStatusStale: true", async () => { + const initialPR = makePullRequest({ + id: 604, + number: 604, + title: "PR with CI status", + repoFullName: "owner/repo", + enriched: true, + checkStatus: "failure", + additions: 12, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [initialPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(pollService.rebuildHotSets).toHaveBeenCalledTimes(1)); + + // Subsequent full poll returns light-fallback PR with checkStatus: null, checkStatusStale: true + const staleFullPR = makePullRequest({ + id: 604, + number: 604, + title: "PR with CI status", + repoFullName: "owner/repo", + enriched: true, + checkStatus: null, + checkStatusStale: true, + additions: 30, + }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [staleFullPR], + workflowRuns: [], + errors: [], + }); + + const fullResult = await capturedFetchAll!(); + expect(fullResult.pullRequests[0].checkStatus).toBe("failure"); + expect(fullResult.pullRequests[0].checkStatusStale).toBe(false); + expect(fullResult.pullRequests[0].additions).toBe(30); + + // Targeted update also returns light-fallback PR with checkStatus: null, checkStatusStale: true + const staleTargetedPR = makePullRequest({ + id: 604, + number: 604, + title: "PR with CI status", + repoFullName: "owner/repo", + enriched: true, + checkStatus: null, + checkStatusStale: true, + additions: 45, + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [staleTargetedPR], + workflowRuns: [], + errors: [ + { + repo: "backfill-batch-1/1", + statusCode: null, + message: "Failed to fetch", + retryable: true, + }, + ], + }, + ["owner/repo"] + ); + + expect(staleTargetedPR.checkStatus).toBe("failure"); + expect(staleTargetedPR.checkStatusStale).toBe(false); + // Because checkStatusStale was restored from existingPr, Tier 1 is not triggered + expect(errorsLib.pushNotification).not.toHaveBeenCalledWith( + "pr-enrichment", + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything() + ); + + // Cold-start targeted PR with checkStatusStale: true (no existingPr) pushes 'pr-enrichment', + // hot-poll update clears checkStatusStale: false, and subsequent clean targeted refresh dismisses 'pr-enrichment' + const coldStalePR = makePullRequest({ + id: 605, + number: 605, + title: "Cold stale PR", + repoFullName: "owner/repo", + enriched: true, + checkStatus: null, + checkStatusStale: true, + nodeId: "PR_kwDO605", + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [coldStalePR], + workflowRuns: [], + errors: [ + { + repo: "backfill-batch-1/1", + statusCode: null, + message: "Failed to fetch", + retryable: true, + }, + ], + }, + ["owner/repo"] + ); + expect(errorsLib.pushNotification).toHaveBeenCalledWith( + "pr-enrichment", + "Some pull request details could not be loaded — showing cached data where available", + "warning", + true, + undefined, + { silentToast: true } + ); + + // Hot-poll update clears checkStatusStale: false on PR 605 + capturedOnHotData!( + new Map([ + [ + 605, + { + state: "OPEN", + draft: false, + checkStatus: "success", + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN", + headSha: "abc1234", + autoMergeMethod: null, + viewerReviewRequested: false, + viewerLatestReviewState: null, + }, + ], + ]), + new Map(), + 0 + ); + + // Subsequent clean targeted refresh dismisses 'pr-enrichment' + vi.mocked(errorsLib.dismissNotificationBySource).mockClear(); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [ + makePullRequest({ + id: 605, + number: 605, + title: "Cold stale PR", + repoFullName: "owner/repo", + enriched: true, + checkStatus: "success", + }), + ], + workflowRuns: [], + errors: [], + }, + ["owner/repo"] + ); + expect(errorsLib.dismissNotificationBySource).toHaveBeenCalledWith("pr-enrichment"); + }); + + it("clears stale checkStatus to null and keeps checkStatusStale: true when headSha changes across a degraded pollFetch (phaseOneFired branch) or handleTargetedData", async () => { + const oldSha = "1111111111111111111111111111111111111111"; + const newSha = "2222222222222222222222222222222222222222"; + const thirdSha = "3333333333333333333333333333333333333333"; + + // Initial light callback populates dashboardData.pullRequests with headSha: oldSha and + // checkStatus: "failure", then the full fetch resolves with a light-fallback PR on newSha. + vi.mocked(pollService.fetchAllData).mockImplementationOnce(async (onLightData) => { + onLightData?.({ + issues: [], + pullRequests: [ + makePullRequest({ + id: 610, + number: 610, + title: "Force-pushed PR", + repoFullName: "owner/repo", + nodeId: "PR_kwDO610", + enriched: true, + headSha: oldSha, + updatedAt: "2026-04-20T10:00:00Z", + checkStatus: "failure", + }), + ], + workflowRuns: [], + errors: [], + }); + return { + issues: [], + pullRequests: [ + makePullRequest({ + id: 610, + number: 610, + title: "Force-pushed PR", + repoFullName: "owner/repo", + nodeId: "PR_kwDO610", + enriched: true, + headSha: newSha, + updatedAt: "2026-04-20T10:05:00Z", + checkStatus: null, + checkStatusStale: true, + }), + ], + workflowRuns: [], + errors: [], + }; + }); + + render(() => ); + await waitFor(() => expect(pollService.rebuildHotSets).toHaveBeenCalledTimes(1)); + + // Because headSha changed (oldSha -> newSha), the prior 'failure' status is cleared + // to null, checkStatusStale remains true, and rebuildHotSets receives the stale PR. + expect(pollService.rebuildHotSets).toHaveBeenLastCalledWith( + expect.objectContaining({ + pullRequests: [ + expect.objectContaining({ + id: 610, + nodeId: "PR_kwDO610", + headSha: newSha, + checkStatus: null, + checkStatusStale: true, + }), + ], + }) + ); + + // Hot poll resolves newSha to 'failure' and syncs updatedAt via applyHotPollPRUpdates + capturedOnHotData!( + new Map([ + [ + 610, + { + state: "OPEN", + draft: false, + updatedAt: "2026-04-20T10:06:00Z", + checkStatus: "failure", + reviewDecision: "REVIEW_REQUIRED", + mergeStateStatus: "BLOCKED", + headSha: newSha, + }, + ], + ]), + new Map(), + 0 + ); + + // Degraded handleTargetedData with a new commit (thirdSha) and checkStatusStale: true + // clears checkStatus to null and keeps checkStatusStale: true. + const targetedStaleNewCommit = makePullRequest({ + id: 610, + number: 610, + title: "Force-pushed PR", + repoFullName: "owner/repo", + nodeId: "PR_kwDO610", + enriched: true, + headSha: thirdSha, + updatedAt: "2026-04-20T10:10:00Z", + checkStatus: null, + checkStatusStale: true, + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [targetedStaleNewCommit], + workflowRuns: [], + errors: [], + }, + ["owner/repo"] + ); + + expect(targetedStaleNewCommit.checkStatus).toBeNull(); + expect(targetedStaleNewCommit.checkStatusStale).toBe(true); + }); + + it("clears checkStatus to null, clears headSha to '', sets checkStatusStale: true, evicts recentlyApprovedPRs, and enrolls PR in hot sets when updatedAt changes across an unenriched regression in pollFetch or handleTargetedData", async () => { + const user = userEvent.setup(); + const validSha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const graphqlMock = vi.fn().mockResolvedValue({ + addPullRequestReview: { + pullRequestReview: { + state: "APPROVED", + pullRequest: { reviewDecision: "APPROVED" }, + }, + }, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlMock } as unknown as ReturnType + ); + + // Initial poll: PR #620 is enriched and approved via UI so recentlyApprovedPRs has an active entry + const initialPR = makePullRequest({ + id: 620, + number: 620, + title: "Approved PR before force-push", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: true, + nodeId: "PR_kwDO620", + headSha: validSha, + updatedAt: "2026-04-20T10:00:00Z", + reviewDecision: "REVIEW_REQUIRED", + checkStatus: "success", + additions: 50, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [initialPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await user.click(screen.getByText("Pull Requests")); + await waitFor(() => screen.getByText("owner/repo")); + await user.click(screen.getByText("owner/repo")); + + const approveBtn = await waitFor(() => + screen.getByLabelText("Approve #620 Approved PR before force-push") + ); + await user.click(approveBtn); + await user.click(approveBtn); + await waitFor(() => expect(screen.getByText("Approved")).toBeTruthy()); + + // Verify applyHotPollPRUpdates updates pr.updatedAt when HotPRStatusUpdate includes updatedAt + const dashboardMod = await import("../../src/app/components/dashboard/DashboardPage"); + const dummyPr = makePullRequest({ id: 999, updatedAt: "2026-04-20T09:00:00Z" }); + dashboardMod.applyHotPollPRUpdates( + { pullRequests: [dummyPr] }, + new Map([ + [ + 999, + { + state: "OPEN", + updatedAt: "2026-04-20T11:30:00Z", + checkStatus: "success", + mergeStateStatus: "CLEAN", + reviewDecision: null, + }, + ], + ]), + "testuser" + ); + expect(dummyPr.updatedAt).toBe("2026-04-20T11:30:00Z"); + + // Targeted refresh regresses PR #620 to enriched: false with a newer updatedAt: + // clears headSha to '', clears checkStatus to null, sets checkStatusStale: true, + // and evicts recentlyApprovedPRs so REVIEW_REQUIRED is not overridden to APPROVED. + const targetedRegressedPR = makePullRequest({ + id: 620, + number: 620, + title: "Approved PR before force-push", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: false, + nodeId: "PR_kwDO620", + headSha: "", + updatedAt: "2026-04-20T10:05:00Z", + reviewDecision: "REVIEW_REQUIRED", + checkStatus: null, + additions: 0, + }); + capturedOnTargetedData!( + { + issues: [], + pullRequests: [targetedRegressedPR], + workflowRuns: [], + errors: [], + }, + ["owner/repo"] + ); + + expect(targetedRegressedPR.enriched).toBe(true); + expect(targetedRegressedPR.additions).toBe(50); + expect(targetedRegressedPR.headSha).toBe(""); + expect(targetedRegressedPR.checkStatus).toBeNull(); + expect(targetedRegressedPR.checkStatusStale).toBe(true); + expect(targetedRegressedPR.reviewDecision).toBe("REVIEW_REQUIRED"); + + // Restore PR #620 via hot poll with a new SHA and updatedAt, re-approve it, then test pollFetch regression + const secondSha = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; + capturedOnHotData!( + new Map([ + [ + 620, + { + state: "OPEN", + draft: false, + updatedAt: "2026-04-20T10:06:00Z", + checkStatus: "success", + mergeStateStatus: "CLEAN", + reviewDecision: "REVIEW_REQUIRED", + headSha: secondSha, + viewerLatestReviewState: null, + }, + ], + ]), + new Map(), + 0 + ); + + const reApproveBtn = await waitFor(() => + screen.getByLabelText("Approve #620 Approved PR before force-push") + ); + await user.click(reApproveBtn); + await user.click(reApproveBtn); + await waitFor(() => expect(screen.getByText("Approved")).toBeTruthy()); + + // Full pollFetch returns enriched: false with a newer updatedAt ("2026-04-20T10:15:00Z"): + // fallbackToPreviousEnrichment + applyRecentApprovalGuard clears headSha to "", checkStatus to null, + // sets checkStatusStale: true, evicts recentlyApprovedPRs, and enrolls PR in rebuildHotSets. + vi.mocked(pollService.rebuildHotSets).mockClear(); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ + id: 620, + number: 620, + title: "Approved PR before force-push", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: false, + nodeId: "PR_kwDO620", + headSha: "", + updatedAt: "2026-04-20T10:15:00Z", + reviewDecision: "REVIEW_REQUIRED", + checkStatus: null, + additions: 0, + }), + ], + workflowRuns: [], + errors: [], + }); + + const pollRes = await capturedFetchAll!(); + expect(pollRes.pullRequests[0]).toMatchObject({ + id: 620, + enriched: true, + headSha: "", + checkStatus: null, + checkStatusStale: true, + reviewDecision: "REVIEW_REQUIRED", + }); + expect(pollService.rebuildHotSets).toHaveBeenCalledWith( + expect.objectContaining({ + pullRequests: [ + expect.objectContaining({ + id: 620, + nodeId: "PR_kwDO620", + enriched: true, + headSha: "", + checkStatus: null, + checkStatusStale: true, + }), + ], + }) + ); + }); }); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 4bf19de2..097eb751 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -1021,6 +1021,80 @@ describe("DependenciesTab — auto-merge badge and Approve action", () => { const needsActionGroup = document.getElementById("dep-group-needs-action")!; expect(needsActionGroup.textContent).toContain("react: 17.0.0 → 18.0.0"); }); + + it("places PR with checkStatus: null and checkStatusStale: true into 'Needs Action' and renders 'Checking status'", () => { + setDependencyExpandedGroups(["mergeable", "needs-action"]); + const staleNullCheckPR = makeMergeablePR({ + id: 8050, + number: 850, + title: "Bump axios from 0.27.2 to 1.0.0", + enriched: true, + draft: false, + checkStatus: null, + checkStatusStale: true, + updatedAt: new Date().toISOString(), + }); + + renderTab({ + pullRequests: [staleNullCheckPR], + userLogin: "testuser", + loading: false, + }); + + expect(document.getElementById("dep-group-mergeable")).toBeNull(); + const needsActionGroup = document.getElementById("dep-group-needs-action")!; + expect(needsActionGroup).not.toBeNull(); + expect(needsActionGroup.textContent).toContain("axios: 0.27.2 → 1.0.0"); + expect(screen.getByText("Checking status")).toBeDefined(); + expect(screen.queryByText("Checks running")).toBeNull(); + }); + + it("renders 'Checking status' for unenriched PR once enrichment is no longer pending, and suppresses it while light results await enrichment (loading: false, enrichmentPending: true)", () => { + setDependencyExpandedGroups(["mergeable", "needs-action"]); + const unenrichedPR = makeNeedsActionPR({ + id: 8051, + number: 851, + title: "Bump lodash from 4.17.20 to 4.17.21", + enriched: false, + checkStatus: null, + }); + + const { unmount } = renderTab({ + pullRequests: [unenrichedPR], + userLogin: "testuser", + loading: false, + enrichmentPending: false, + }); + + const needsActionGroup = document.getElementById("dep-group-needs-action")!; + expect(needsActionGroup.textContent).toContain("lodash: 4.17.20 → 4.17.21"); + expect(screen.getAllByText("Checking status")).toHaveLength(1); + unmount(); + + // DashboardPage has already set loading: false once light results render + renderTab({ + pullRequests: [unenrichedPR], + userLogin: "testuser", + loading: false, + enrichmentPending: true, + }); + + expect(document.getElementById("dep-group-needs-action")!.textContent).toContain("lodash: 4.17.20 → 4.17.21"); + expect(screen.queryByText("Checking status")).toBeNull(); + }); + + it("still renders 'Checking status' for an enriched checkStatusStale PR while enrichmentPending is true", () => { + const stalePR = makeMergeablePR({ + id: 8052, + number: 852, + title: "Bump axios from 0.27.2 to 1.0.0", + checkStatus: null, + checkStatusStale: true, + updatedAt: new Date().toISOString(), + }); + renderTab({ pullRequests: [stalePR], loading: false, enrichmentPending: true }); + expect(screen.getAllByText("Checking status")).toHaveLength(1); + }); }); // ── Ignore button ───────────────────────────────────────────────────────────── diff --git a/tests/components/layout/FilterBar.test.tsx b/tests/components/layout/FilterBar.test.tsx index d8abe3ee..2ccdc82f 100644 --- a/tests/components/layout/FilterBar.test.tsx +++ b/tests/components/layout/FilterBar.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import FilterBar from "../../../src/app/components/layout/FilterBar"; +import { PR_ENRICHMENT_DEGRADED_MESSAGE } from "../../../src/shared/types"; // Mock view store vi.mock("../../../src/app/stores/view", () => ({ @@ -202,4 +203,44 @@ describe("FilterBar", () => { screen.getByLabelText("Filter by organization"); screen.getByLabelText("Filter by repository"); }); + + it("renders partial-sync-indicator with 'Partial sync' label and accessible tooltip when partialSync={true}", () => { + const tenSecondsAgo = new Date(Date.now() - 10_000); + render(() => ( + + )); + + const indicator = screen.getByTestId("partial-sync-indicator"); + expect(indicator).toBeDefined(); + expect(indicator.getAttribute("role")).toBe("status"); + expect(indicator.textContent).toContain("Partial sync"); + + // Updated timestamp label stays visible past 8s fade-out while partialSync is active + vi.advanceTimersByTime(10_000); + const updatedSpan = screen.getByText(/Updated \d+[sm] ago/); + expect(updatedSpan.className).toContain("opacity-100"); + + // Hovering the indicator shows the degraded-enrichment explanation tooltip + const tooltipTrigger = indicator.parentElement!; + expect(tooltipTrigger.classList.contains("inline-flex")).toBe(true); + // Keyboard users can reach the explanation via Tab + expect(tooltipTrigger.getAttribute("tabindex")).toBe("0"); + fireEvent.pointerEnter(tooltipTrigger); + vi.advanceTimersByTime(300); + const tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + expect(tooltipContent?.textContent).toBe(PR_ENRICHMENT_DEGRADED_MESSAGE); + fireEvent.pointerLeave(tooltipTrigger); + // Cover the 100ms closeDelay and Kobalte's 300ms globalSkipDelayTimeout + vi.advanceTimersByTime(300); + }); + + it("does not render partial-sync-indicator when partialSync is false or omitted", () => { + const { unmount } = render(() => ); + expect(screen.queryByTestId("partial-sync-indicator")).toBeNull(); + unmount(); + + render(() => ); + expect(screen.queryByTestId("partial-sync-indicator")).toBeNull(); + }); }); diff --git a/tests/components/shared/ToastContainer.test.tsx b/tests/components/shared/ToastContainer.test.tsx index b421cf9f..07fe94bf 100644 --- a/tests/components/shared/ToastContainer.test.tsx +++ b/tests/components/shared/ToastContainer.test.tsx @@ -6,9 +6,13 @@ import { addMutedSource, clearMutedSources, dismissError, + dismissNotificationBySource, getNotifications, } from "../../../src/app/lib/errors"; -import ToastContainer, { resetToastState } from "../../../src/app/components/shared/ToastContainer"; +import ToastContainer, { + resetToastState, + TOAST_REPEAT_COOLDOWN_MS, +} from "../../../src/app/components/shared/ToastContainer"; // Mirrors the private TOASTED_MESSAGES_KEY constant in ToastContainer.tsx — // hardcoded here the same way tests/stores/config.test.ts hardcodes STORAGE_KEY. @@ -332,15 +336,24 @@ describe("ToastContainer", () => { pushNotification("github-status", "Actions Outage", "error"); expect(screen.queryAllByRole("alert")).toHaveLength(1); - // Incident fully resolves — source drops out of the store entirely. + // Incident resolves — source drops out of the store while cooldown entry remains. const notifId = getNotifications()[0].id; dismissError(notifId); expect(screen.queryAllByRole("alert")).toHaveLength(0); first.unmount(); - // A later, unrelated incident happens to have identical text — should - // not be suppressed forever just because the same string was toasted once. + // Recurring within TOAST_REPEAT_COOLDOWN_MS (1 minute later) is suppressed. + vi.advanceTimersByTime(60_000); + pushNotification("github-status", "Actions Outage", "error"); + const second = render(() => ); + expect(screen.queryAllByRole("alert")).toHaveLength(0); + + // Dismiss again, advance past the 15-minute cooldown, and re-push: it re-toasts. + dismissError(getNotifications()[0].id); + second.unmount(); + vi.advanceTimersByTime(TOAST_REPEAT_COOLDOWN_MS); + pushNotification("github-status", "Actions Outage", "error"); render(() => ); expect(screen.queryAllByRole("alert")).toHaveLength(1); @@ -446,4 +459,78 @@ describe("ToastContainer", () => { const alert = screen.getByRole("alert"); expect(alert.querySelector("a")).toBeNull(); }); + + it("suppresses toast popup when silentToast is true while keeping notification in store", () => { + render(() => ); + pushNotification("pr-enrichment", "Msg", "warning", true, undefined, { + silentToast: true, + }); + expect(getNotifications()).toHaveLength(1); + expect(getNotifications()[0].source).toBe("pr-enrichment"); + expect(getNotifications()[0].silentToast).toBe(true); + expect(screen.queryAllByRole("alert")).toHaveLength(0); + + // Updating the same source without silentToast clears silentToast and toasts + vi.advanceTimersByTime(10); + pushNotification("pr-enrichment", "Escalated msg", "error", true); + expect(getNotifications()[0].silentToast).toBeUndefined(); + expect(screen.queryAllByRole("alert")).toHaveLength(1); + + // Updating during the 3s coalescing window with silentToast: true does not promote on recheck + vi.advanceTimersByTime(10); + pushNotification("pr-enrichment", "Silent coalesced update", "warning", true, undefined, { + silentToast: true, + }); + expect(getNotifications()[0].silentToast).toBe(true); + vi.advanceTimersByTime(3_500); + expect(screen.queryByText("Silent coalesced update")).toBeNull(); + }); + + it("suppresses flapping poll notifications across dismissNotificationBySource within 15 minutes and re-toasts after cooldown", () => { + render(() => ); + + // Poll 1 at t=0 pushes notification -> toasts + pushNotification("hot-poll", "Failed to fetch", "error", true); + expect(screen.queryAllByRole("alert")).toHaveLength(1); + + // Poll 2 at t=60s succeeds and reconciles via dismissNotificationBySource + vi.advanceTimersByTime(60_000); + dismissNotificationBySource("hot-poll"); + expect(screen.queryAllByRole("alert")).toHaveLength(0); + + // Poll 3 at t=120s fails again with the same message -> suppressed by 15m cooldown + vi.advanceTimersByTime(60_000); + pushNotification("hot-poll", "Failed to fetch", "error", true); + expect(screen.queryAllByRole("alert")).toHaveLength(0); + + // Dismiss and advance past the 15-minute cooldown from the initial toast at t=0 + dismissNotificationBySource("hot-poll"); + vi.advanceTimersByTime(TOAST_REPEAT_COOLDOWN_MS); + pushNotification("hot-poll", "Failed to fetch", "error", true); + expect(screen.queryAllByRole("alert")).toHaveLength(1); + }); + + it("clears in-memory toastedMessages cooldown map when resetToastState() is called while mounted", () => { + render(() => ); + pushNotification("api", "Transient error", "error", true); + expect(screen.queryAllByRole("alert")).toHaveLength(1); + + // Clear the active notification within the 15m cooldown + vi.advanceTimersByTime(10_000); + dismissNotificationBySource("api"); + expect(screen.queryAllByRole("alert")).toHaveLength(0); + + // Resetting toast state while mounted clears the in-memory cooldown map + resetToastState(); + + vi.advanceTimersByTime(5_000); + pushNotification("api", "Transient error", "error", true); + expect(screen.queryAllByRole("alert")).toHaveLength(1); + }); + + it("does not set redundant aria-live='assertive' on the outer wrapper", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.getAttribute("aria-live")).toBeNull(); + expect(container.firstElementChild?.getAttribute("aria-atomic")).toBeNull(); + }); }); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 0f2a18c1..fb03ada2 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -477,6 +477,27 @@ describe("classifyDepStatus", () => { expect(classifyDepStatus(pr, "", 14)).toBe("mergeable"); }); + it("returns needs-action when checkStatus is null and checkStatusStale is true, and mergeable when checkStatusStale is false", () => { + const stalePr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: null, + checkStatusStale: true, + reviewDecision: null, + updatedAt: RECENT, + }); + const resolvedNoCiPr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: null, + checkStatusStale: false, + reviewDecision: null, + updatedAt: RECENT, + }); + expect(classifyDepStatus(stalePr, "", 14)).toBe("needs-action"); + expect(classifyDepStatus(resolvedNoCiPr, "", 14)).toBe("mergeable"); + }); + it("returns mergeable for approved PR with passing CI", () => { const pr = makePullRequest({ enriched: true, diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts index c381d787..0a8af958 100644 --- a/tests/lib/format.test.ts +++ b/tests/lib/format.test.ts @@ -592,6 +592,19 @@ describe("canApprovePR", () => { expect(canApprovePR(makePullRequest({ ...base, checkStatus: "conflict" }), "reviewer")).toBe(true); expect(canApprovePR(makePullRequest({ ...base, checkStatus: null }), "reviewer")).toBe(true); }); + + it("returns false while checkStatusStale is true and true once checkStatusStale is false", () => { + const base = { + nodeId: "PR_kwDOtest0001", + headSha: validSha, + userLogin: "author", + reviewDecision: "REVIEW_REQUIRED" as const, + }; + + expect(canApprovePR(makePullRequest({ ...base, checkStatus: null, checkStatusStale: true }), "reviewer")).toBe(false); + expect(canApprovePR(makePullRequest({ ...base, checkStatus: "success", checkStatusStale: true }), "reviewer")).toBe(false); + expect(canApprovePR(makePullRequest({ ...base, checkStatus: null, checkStatusStale: false }), "reviewer")).toBe(true); + }); }); describe("canMarkPRReady", () => { diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index ca94fb06..3e322d0e 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -252,7 +252,10 @@ describe("beforeSendHandler", () => { beforeSendHandler({ tags: { source }, exception: { - values: [{ type: "TypeError", value: "NetworkError when attempting to fetch resource." }], + values: [ + { type: "TypeError", value: "NetworkError when attempting to fetch resource." }, + { type: "TypeError", value: "NetworkError when attempting to fetch resource. (api.github.com)" }, + ], }, } as never) ).toBeNull(); @@ -375,7 +378,7 @@ describe("beforeBreadcrumbHandler", () => { category: "console", message: `${prefix} some message`, }; - expect(beforeBreadcrumbHandler(breadcrumb as never)).not.toBeNull(); + expect(beforeBreadcrumbHandler(breadcrumb)).not.toBeNull(); } }); @@ -384,22 +387,35 @@ describe("beforeBreadcrumbHandler", () => { category: "console", message: "random third-party log", }; - expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + expect(beforeBreadcrumbHandler(breadcrumb)).toBeNull(); }); it("drops console breadcrumbs with empty message", () => { const breadcrumb = { category: "console", message: "" }; - expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + expect(beforeBreadcrumbHandler(breadcrumb)).toBeNull(); }); it("drops console breadcrumbs with no message", () => { const breadcrumb = { category: "console" }; - expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + expect(beforeBreadcrumbHandler(breadcrumb)).toBeNull(); }); it("passes through non-console, non-navigation breadcrumbs unchanged", () => { const breadcrumb = { category: "ui.click", message: "button" }; - expect(beforeBreadcrumbHandler(breadcrumb as never)).toBe(breadcrumb); + expect(beforeBreadcrumbHandler(breadcrumb)).toBe(breadcrumb); + }); + + it("scrubs tokens and OAuth params in allowed console breadcrumb messages", () => { + const breadcrumb = { + category: "console", + message: + "[api] Request failed for https://api.github.com/graphql?code=abc123&state=xyz789 with token ghp_secretToken123 and ATATT3xFfGF0abc123=", + }; + const result = beforeBreadcrumbHandler(breadcrumb); + expect(result).not.toBeNull(); + expect(result!.message).toBe( + "[api] Request failed for https://api.github.com/graphql?code=[REDACTED]&state=[REDACTED] with token ghp_[REDACTED] and ATATT[REDACTED]", + ); }); }); @@ -434,7 +450,7 @@ describe("initSentry", () => { expect(mockInit).not.toHaveBeenCalled(); }); - it("calls Sentry.init with correct DSN when VITE_SENTRY_DSN is set", () => { + it("calls Sentry.init with correct DSN and enhanceFetchErrorMessages: 'report-only' when VITE_SENTRY_DSN is set", () => { vi.stubEnv("DEV", false); vi.stubEnv("VITE_SENTRY_DSN", "https://test-key@o1.ingest.us.sentry.io/1"); initSentry(); @@ -442,6 +458,7 @@ describe("initSentry", () => { expect(mockInit).toHaveBeenCalledWith( expect.objectContaining({ dsn: "https://test-key@o1.ingest.us.sentry.io/1", + enhanceFetchErrorMessages: "report-only", }), ); }); diff --git a/tests/services/api-dashboard-bodies.test.ts b/tests/services/api-dashboard-bodies.test.ts index 4643fc00..b94e5662 100644 --- a/tests/services/api-dashboard-bodies.test.ts +++ b/tests/services/api-dashboard-bodies.test.ts @@ -3,10 +3,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fetchDashboardIssueBodies, GRAPHQL_BODY_FETCH_TIMEOUT_MS, + BODY_FETCH_BATCH_SIZE, + BODY_FETCH_NOTIFICATION_THRESHOLD, + MIN_SPLIT_BATCH_SIZE, + resetPREnrichmentCache, + resetBodyFetchFailures, GraphqlFetchTimeoutError, } from "../../src/app/services/api"; import { getClient } from "../../src/app/services/github"; -import { pushNotification } from "../../src/app/lib/errors"; +import { pushNotification, dismissNotificationBySource } from "../../src/app/lib/errors"; import { captureException } from "@sentry/solid"; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -26,6 +31,7 @@ vi.mock("../../src/app/services/github", () => ({ vi.mock("../../src/app/lib/errors", () => ({ pushNotification: vi.fn(), + dismissNotificationBySource: vi.fn(), pushError: vi.fn(), getErrors: vi.fn().mockReturnValue([]), dismissError: vi.fn(), @@ -41,8 +47,7 @@ vi.mock("@sentry/solid", () => ({ // ── Helpers ─────────────────────────────────────────────────────────────────── -// NODES_BATCH_SIZE is 100 (internal to api.ts — confirmed from fetchPREnrichment usage) -const NODES_BATCH_SIZE = 100; +const NODES_BATCH_SIZE = BODY_FETCH_BATCH_SIZE; function makeRateLimit() { return { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-01-01T01:00:00Z" }; @@ -58,6 +63,7 @@ function makeOctokit(graphqlImpl: (query: string, variables: unknown) => Promise beforeEach(() => { vi.clearAllMocks(); + resetPREnrichmentCache(); }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -245,12 +251,12 @@ describe("fetchDashboardIssueBodies — GraphQL error handling", () => { }); it("Promise.allSettled: second batch succeeds even if first batch throws", async () => { - let callCount = 0; const ids = Array.from({ length: NODES_BATCH_SIZE + 1 }, (_, i) => `N_${i}`); const octokit = makeOctokit(async (_query: string, variables: unknown) => { - callCount++; const { ids: batchIds } = variables as { ids: string[] }; - if (callCount === 1) throw new Error("first batch failed"); + if (batchIds.some((id) => id !== `N_${BODY_FETCH_BATCH_SIZE}`)) { + throw new Error("first batch failed"); + } return { nodes: batchIds.map((id) => ({ id, body: `body-${id}` })) }; }); @@ -261,24 +267,153 @@ describe("fetchDashboardIssueBodies — GraphQL error handling", () => { expect(result.bodies.get(`N_${NODES_BATCH_SIZE}`)).toBe(`body-N_${NODES_BATCH_SIZE}`); expect(result.failedIds.size).toBe(NODES_BATCH_SIZE); }); + + it("exports GRAPHQL_BODY_FETCH_TIMEOUT_MS === 45_000, BODY_FETCH_BATCH_SIZE === 25, and BODY_FETCH_NOTIFICATION_THRESHOLD === 3", () => { + expect(GRAPHQL_BODY_FETCH_TIMEOUT_MS).toBe(45_000); + expect(BODY_FETCH_BATCH_SIZE).toBe(25); + expect(BODY_FETCH_NOTIFICATION_THRESHOLD).toBe(3); + }); + + it("recursively binary-splits a 25-node batch (25 -> [12, 13] -> [6, 6] / [6, 7]) on retryable failure, recovering healthy siblings and capturing Sentry once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `N_${i}`); + const poisonId = "N_14"; // in second half (indices 12..24 -> leaf 12..17 of size 6) + const observedRetries: Array = []; + + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds, request } = variables as { + ids: string[]; + request?: { retries?: number }; + }; + observedRetries.push(request?.retries); + if (batchIds.includes(poisonId)) { + const err = Object.assign(new Error("502 Bad Gateway"), { status: 502 }); + throw err; + } + return { + nodes: batchIds.map((id) => ({ id, body: `body-${id}` })), + }; + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + // Splittable (> 6) top-level batch and split sub-batches all use retries: 0 so 502/504 splits immediately + expect(observedRetries.every((r) => r === 0)).toBe(true); + + // Non-splittable (<= 6) top-level batch uses retries: 1 + observedRetries.length = 0; + await fetchDashboardIssueBodies(octokit, ["N_0"]); + expect(observedRetries).toEqual([1]); + + // 25 -> [0..11 (12 ok), 12..24 (13 fail)] -> [12..17 (6 fail leaf), 18..24 (7 ok)] + // 19 healthy nodes recovered, 6 leaf nodes in failedIds + expect(result.bodies.size).toBe(19); + expect(result.failedIds.size).toBe(6); + expect(result.failedIds.has(poisonId)).toBe(true); + expect(result.bodies.get("N_0")).toBe("body-N_0"); + expect(result.bodies.get("N_24")).toBe("body-N_24"); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it("short-circuits without binary splitting when batch fails with a non-retryable 403 status", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `N_${i}`); + const octokit = makeOctokit(async () => { + throw Object.assign(new Error("Forbidden"), { status: 403 }); + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(result.bodies.size).toBe(0); + expect(result.failedIds.size).toBe(BODY_FETCH_BATCH_SIZE); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it.each([ + [ + "status-500 RequestError with a TypeError cause", + () => Object.assign(new Error("Failed to fetch", { cause: new TypeError("Failed to fetch") }), { status: 500 }), + ], + ["bare TypeError", () => new TypeError("Failed to fetch")], + ])( + "binary-splits a batch that fails with 'Failed to fetch' (CORS-masked gateway 502/504) as a %s", + async (_label, makeError) => { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `N_${i}`); + const batchSizes: number[] = []; + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const batchIds = (variables as { ids: string[] }).ids; + batchSizes.push(batchIds.length); + if (batchIds.length > MIN_SPLIT_BATCH_SIZE) throw makeError(); + return { nodes: batchIds.map((id) => ({ id, body: `body-${id}` })), rateLimit: null }; + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + expect(batchSizes).toContain(12); + expect(batchSizes).toContain(13); + expect(result.bodies.size).toBe(BODY_FETCH_BATCH_SIZE); + expect(result.failedIds.size).toBe(0); + } + ); + + it("salvages resolved nodes from partial err.data.nodes before computing remainingIds", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: 10 }, (_, i) => `N_${i}`); + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds } = variables as { ids: string[] }; + if (batchIds.length === 10) { + // 6 of the 10 nodes resolved in partial err.data.nodes; only 4 remain (<= MIN_SPLIT_BATCH_SIZE = 6) + throw Object.assign(new Error("Partial GraphQL failure"), { + status: 502, + data: { + nodes: batchIds.slice(0, 6).map((id) => ({ id, body: `salvaged-${id}` })), + }, + }); + } + return { nodes: [] }; + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + // Because 6 nodes were salvaged, remainingIds had length 4 (<= 6), so no split retry was needed + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(result.bodies.size).toBe(6); + expect(result.bodies.get("N_0")).toBe("salvaged-N_0"); + expect(result.bodies.get("N_5")).toBe("salvaged-N_5"); + expect([...result.failedIds]).toEqual(["N_6", "N_7", "N_8", "N_9"]); + } finally { + warnSpy.mockRestore(); + } + }); }); describe("fetchDashboardIssueBodies — hung request timeout", () => { - it("times out a never-resolving batch, warns, reports to Sentry, and notifies the user", async () => { + it("times out a never-resolving batch, warns, reports to Sentry on each failure, and notifies with silentToast only after 3 consecutive failures (dismissing on recovery)", async () => { vi.useFakeTimers(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const octokit = makeOctokit(() => new Promise(() => {})); vi.mocked(getClient).mockReturnValue(octokit); - const resultPromise = fetchDashboardIssueBodies(octokit, ["N_1"]); + // Failure 1: records failedIds & Sentry, but does NOT push notification (< 3 threshold) + const p1 = fetchDashboardIssueBodies(octokit, ["N_1"]); await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); - const result = await resultPromise; + const result1 = await p1; - expect(result.bodies).toBeInstanceOf(Map); - expect(result.bodies.size).toBe(0); - expect(result.failedIds.has("N_1")).toBe(true); - expect(warnSpy).toHaveBeenCalled(); + expect(result1.bodies).toBeInstanceOf(Map); + expect(result1.bodies.size).toBe(0); + expect(result1.failedIds.has("N_1")).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ name: "GraphqlFetchTimeoutError", @@ -289,11 +424,41 @@ describe("fetchDashboardIssueBodies — hung request timeout", () => { const capturedErr = vi.mocked(captureException).mock.calls[0]?.[0]; expect(capturedErr).toBeInstanceOf(GraphqlFetchTimeoutError); expect((capturedErr as Error).name).toBe("GraphqlFetchTimeoutError"); + expect(pushNotification).not.toHaveBeenCalled(); + + // Failure 2: still below threshold (2 < 3) + const p2 = fetchDashboardIssueBodies(octokit, ["N_1"]); + await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); + await p2; + expect(pushNotification).not.toHaveBeenCalled(); + + // Failure 3: reaches BODY_FETCH_NOTIFICATION_THRESHOLD (3) -> pushes silent drawer warning + const p3 = fetchDashboardIssueBodies(octokit, ["N_1"]); + await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); + await p3; + expect(pushNotification).toHaveBeenCalledTimes(1); expect(pushNotification).toHaveBeenCalledWith( "dashboardBodies", expect.stringContaining("could not be loaded"), - "warning" + "warning", + true, + undefined, + { silentToast: true } ); + + // Subsequent recovery resets failure counter and dismisses notification by source + const healthyOctokit = makeOctokit(async () => ({ + nodes: [{ id: "N_1", body: "recovered body" }], + })); + vi.mocked(getClient).mockReturnValue(healthyOctokit); + const recovered = await fetchDashboardIssueBodies(healthyOctokit, ["N_1"]); + expect(recovered.failedIds.size).toBe(0); + expect(dismissNotificationBySource).toHaveBeenCalledWith("dashboardBodies"); + + // And resetBodyFetchFailures("dashboardBodies") also dismisses directly when invoked + vi.mocked(dismissNotificationBySource).mockClear(); + resetBodyFetchFailures("dashboardBodies"); + expect(dismissNotificationBySource).toHaveBeenCalledWith("dashboardBodies"); } finally { warnSpy.mockRestore(); vi.useRealTimers(); @@ -320,4 +485,37 @@ describe("fetchDashboardIssueBodies — hung request timeout", () => { vi.useRealTimers(); } }); + + it("aborts a hung multi-node batch on timeout and marks every id failed without binary-splitting", async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `N_${i}`); + const requests: Array<{ signal?: AbortSignal; retries?: number }> = []; + const octokit = makeOctokit((_query: string, variables: unknown) => { + const { request } = variables as { request?: { signal?: AbortSignal; retries?: number } }; + requests.push(request ?? {}); + return new Promise(() => {}); + }); + vi.mocked(getClient).mockReturnValue(null); + + const resultPromise = fetchDashboardIssueBodies(octokit, ids); + await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); + const result = await resultPromise; + + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(requests[0].retries).toBe(0); + expect(requests[0].signal?.aborted).toBe(true); + expect(result.bodies.size).toBe(0); + expect([...result.failedIds]).toEqual(ids); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + expect.any(GraphqlFetchTimeoutError), + { tags: { source: "dashboardBodies" } } + ); + } finally { + warnSpy.mockRestore(); + vi.useRealTimers(); + } + }); }); diff --git a/tests/services/api-dep-pr-bodies.test.ts b/tests/services/api-dep-pr-bodies.test.ts index 2f0c2ad1..087f062d 100644 --- a/tests/services/api-dep-pr-bodies.test.ts +++ b/tests/services/api-dep-pr-bodies.test.ts @@ -1,8 +1,16 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fetchDepPRBodies, raceWithTimeout, GRAPHQL_BODY_FETCH_TIMEOUT_MS } from "../../src/app/services/api"; +import { + fetchDepPRBodies, + raceWithTimeout, + GRAPHQL_BODY_FETCH_TIMEOUT_MS, + BODY_FETCH_BATCH_SIZE, + BODY_FETCH_NOTIFICATION_THRESHOLD, + resetPREnrichmentCache, + resetBodyFetchFailures, +} from "../../src/app/services/api"; import { getClient } from "../../src/app/services/github"; -import { pushNotification } from "../../src/app/lib/errors"; +import { pushNotification, dismissNotificationBySource } from "../../src/app/lib/errors"; import { captureException } from "@sentry/solid"; const mockUpdateGraphqlRateLimit = vi.fn(); @@ -19,6 +27,7 @@ vi.mock("../../src/app/services/github", () => ({ vi.mock("../../src/app/lib/errors", () => ({ pushNotification: vi.fn(), + dismissNotificationBySource: vi.fn(), pushError: vi.fn(), getErrors: vi.fn().mockReturnValue([]), dismissError: vi.fn(), @@ -32,7 +41,7 @@ vi.mock("@sentry/solid", () => ({ captureException: vi.fn(), })); -const NODES_BATCH_SIZE = 100; +const NODES_BATCH_SIZE = BODY_FETCH_BATCH_SIZE; function makeRateLimit() { return { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-01-01T01:00:00Z" }; @@ -48,6 +57,7 @@ function makeOctokit(graphqlImpl: (query: string, variables: unknown) => Promise beforeEach(() => { vi.clearAllMocks(); + resetPREnrichmentCache(); }); describe("fetchDepPRBodies — empty input", () => { @@ -202,33 +212,173 @@ describe("fetchDepPRBodies — error resilience", () => { expect(mockUpdateGraphqlRateLimit).toHaveBeenCalledWith(rl); }); + + it("exports GRAPHQL_BODY_FETCH_TIMEOUT_MS === 45_000, BODY_FETCH_BATCH_SIZE === 25, and BODY_FETCH_NOTIFICATION_THRESHOLD === 3", () => { + expect(GRAPHQL_BODY_FETCH_TIMEOUT_MS).toBe(45_000); + expect(BODY_FETCH_BATCH_SIZE).toBe(25); + expect(BODY_FETCH_NOTIFICATION_THRESHOLD).toBe(3); + }); + + it("recursively binary-splits a 25-node batch (25 -> [12, 13] -> [6, 6] / [6, 7]) on retryable failure, recovering healthy siblings and capturing Sentry once", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `PR_${i}`); + const poisonId = "PR_20"; // in second half (indices 12..24 -> leaf 18..24 of size 7) + const observedRetries: Array = []; + + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds, request } = variables as { + ids: string[]; + request?: { retries?: number }; + }; + observedRetries.push(request?.retries); + if (batchIds.includes(poisonId)) { + throw Object.assign(new Error("502 Bad Gateway"), { status: 502 }); + } + return { + nodes: batchIds.map((id) => ({ + id, + databaseId: Number(id.replace("PR_", "")) + 100, + body: `body-${id}`, + })), + }; + }); + + const result = await fetchDepPRBodies(octokit, ids); + + // Splittable (> 6) top-level batch and split sub-batches all use retries: 0 so 502/504 splits immediately + expect(observedRetries.every((r) => r === 0)).toBe(true); + + // Non-splittable (<= 6) top-level batch uses retries: 1 + observedRetries.length = 0; + await fetchDepPRBodies(octokit, ["PR_0"]); + expect(observedRetries).toEqual([1]); + + // 25 -> [0..11 (12 ok), 12..24 (13 fail)] -> [12..17 (6 ok), 18..24 (7 fail)] + // Because 7 > MIN_SPLIT_BATCH_SIZE (6), [18..24] splits once more into [18..20 (3 fail leaf), 21..24 (4 ok)] + // 22 healthy nodes recovered (12 + 6 + 4), 3 leaf nodes in failedIds (PR_18, PR_19, PR_20) + expect(result.bodies.size).toBe(22); + expect(result.failedIds.size).toBe(3); + expect(result.failedIds.has(poisonId)).toBe(true); + expect(result.bodies.get(100)).toBe("body-PR_0"); + expect(result.bodies.get(117)).toBe("body-PR_17"); + expect(result.bodies.get(124)).toBe("body-PR_24"); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it("short-circuits without binary splitting when batch fails with a non-retryable 403 status", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: BODY_FETCH_BATCH_SIZE }, (_, i) => `PR_${i}`); + const octokit = makeOctokit(async () => { + throw Object.assign(new Error("Forbidden"), { status: 403 }); + }); + + const result = await fetchDepPRBodies(octokit, ids); + + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(result.bodies.size).toBe(0); + expect(result.failedIds.size).toBe(BODY_FETCH_BATCH_SIZE); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it("salvages resolved nodes with id from partial err.data.nodes before computing remainingIds", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const ids = Array.from({ length: 10 }, (_, i) => `PR_${i}`); + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds } = variables as { ids: string[] }; + if (batchIds.length === 10) { + throw Object.assign(new Error("Partial GraphQL failure"), { + status: 502, + data: { + nodes: batchIds.slice(0, 6).map((id, idx) => ({ + id, + databaseId: 500 + idx, + body: `salvaged-${id}`, + })), + }, + }); + } + return { nodes: [] }; + }); + + const result = await fetchDepPRBodies(octokit, ids); + + // 6 nodes salvaged via node.id -> remainingIds has 4 items (<= MIN_SPLIT_BATCH_SIZE = 6), so no split retry + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(result.bodies.size).toBe(6); + expect(result.bodies.get(500)).toBe("salvaged-PR_0"); + expect(result.bodies.get(505)).toBe("salvaged-PR_5"); + expect([...result.failedIds]).toEqual(["PR_6", "PR_7", "PR_8", "PR_9"]); + } finally { + warnSpy.mockRestore(); + } + }); }); describe("fetchDepPRBodies — hung request timeout", () => { - it("times out a never-resolving batch, warns, reports to Sentry, and notifies the user", async () => { + it("times out a never-resolving batch, warns, reports to Sentry on each failure, and notifies with silentToast only after 3 consecutive failures (dismissing on recovery)", async () => { vi.useFakeTimers(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const octokit = makeOctokit(() => new Promise(() => {})); vi.mocked(getClient).mockReturnValue(octokit); - const resultPromise = fetchDepPRBodies(octokit, ["N_1"]); + // Failure 1: records failedIds & Sentry, but does NOT push notification (< 3 threshold) + const p1 = fetchDepPRBodies(octokit, ["N_1"]); await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); - const result = await resultPromise; + const result1 = await p1; - expect(result.bodies).toBeInstanceOf(Map); - expect(result.bodies.size).toBe(0); - expect(result.failedIds.has("N_1")).toBe(true); - expect(warnSpy).toHaveBeenCalled(); + expect(result1.bodies).toBeInstanceOf(Map); + expect(result1.bodies.size).toBe(0); + expect(result1.failedIds.has("N_1")).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith( expect.any(Error), { tags: { source: "depPRBodies" } } ); + expect(pushNotification).not.toHaveBeenCalled(); + + // Failure 2: still below threshold (2 < 3) + const p2 = fetchDepPRBodies(octokit, ["N_1"]); + await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); + await p2; + expect(pushNotification).not.toHaveBeenCalled(); + + // Failure 3: reaches BODY_FETCH_NOTIFICATION_THRESHOLD (3) -> pushes silent drawer warning + const p3 = fetchDepPRBodies(octokit, ["N_1"]); + await vi.advanceTimersByTimeAsync(GRAPHQL_BODY_FETCH_TIMEOUT_MS); + await p3; + expect(pushNotification).toHaveBeenCalledTimes(1); expect(pushNotification).toHaveBeenCalledWith( "depPRBodies", expect.stringContaining("could not be determined"), - "warning" + "warning", + true, + undefined, + { silentToast: true } ); + + // Subsequent recovery resets failure counter and dismisses notification by source + const healthyOctokit = makeOctokit(async () => ({ + nodes: [{ id: "N_1", databaseId: 1, body: "recovered body" }], + })); + vi.mocked(getClient).mockReturnValue(healthyOctokit); + const recovered = await fetchDepPRBodies(healthyOctokit, ["N_1"]); + expect(recovered.failedIds.size).toBe(0); + expect(dismissNotificationBySource).toHaveBeenCalledWith("depPRBodies"); + + // And resetBodyFetchFailures("depPRBodies") also dismisses directly when invoked + vi.mocked(dismissNotificationBySource).mockClear(); + resetBodyFetchFailures("depPRBodies"); + expect(dismissNotificationBySource).toHaveBeenCalledWith("depPRBodies"); } finally { warnSpy.mockRestore(); vi.useRealTimers(); diff --git a/tests/services/api-optimization.test.ts b/tests/services/api-optimization.test.ts index 50afdf74..2c7b5e90 100644 --- a/tests/services/api-optimization.test.ts +++ b/tests/services/api-optimization.test.ts @@ -6,6 +6,7 @@ import { fetchPREnrichment, fallbackToPreviousEnrichment, pickEnrichmentFields, + resetPREnrichmentCache, type RepoRef, } from "../../src/app/services/api"; import { clearCache } from "../../src/app/stores/cache"; @@ -76,7 +77,7 @@ function makeHeavyPRNode(databaseId: number, _nodeId?: string) { changedFiles: 5, comments: { totalCount: 3 }, reviewThreads: { totalCount: 2 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, autoMergeRequest: null as { mergeMethod: string } | null, viewerLatestReview: null as { state: string } | null, viewerLatestReviewRequest: null as { id: string } | null, @@ -156,6 +157,7 @@ function makeOctokit(graphqlImpl: (query: string, variables?: unknown) => Promis beforeEach(async () => { await clearCache(); + resetPREnrichmentCache(); vi.resetAllMocks(); }); @@ -371,10 +373,10 @@ describe("onLightData callback (progressive rendering)", () => { octokit as unknown as OctokitLike, repos, "testuser", - (data) => { + { onLightData: (data) => { callOrder.push("onLightData"); lightDataReceived = data; - }, + } }, ); // onLightData fires after light query but before heavy backfill @@ -531,6 +533,7 @@ describe("scaling behavior", () => { for (const { repos: repoCount, combinedCalls } of repoCountsAndExpected) { it(`${repoCount} repos: combined makes ${combinedCalls} GraphQL calls`, async () => { + resetPREnrichmentCache(); const repos = makeRepos(repoCount); let nodeId = 0; @@ -573,11 +576,7 @@ describe("fetchPREnrichment mergeStateStatus UNSTABLE override", () => { changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { - nodes: rollupState - ? [{ commit: { statusCheckRollup: { state: rollupState } } }] - : [], - }, + statusCheckRollup: rollupState ? { state: rollupState } : null, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, })), @@ -700,6 +699,146 @@ describe("fallbackToPreviousEnrichment", () => { // Only the PR still in `next` remains — the closed PR (id 2) is not resurrected. expect(result.map((pr) => pr.id)).toEqual([1]); }); + + it("clears checkStatus to null, clears headSha to '', and sets checkStatusStale: true when updatedAt or headSha differs on unenriched PRs", () => { + const previous = [ + makePullRequest({ + id: 1, + enriched: true, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "prevsha1", + checkStatus: "failure", + additions: 42, + }), + makePullRequest({ + id: 2, + enriched: true, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "prevsha2", + checkStatus: "success", + additions: 15, + }), + makePullRequest({ + id: 3, + enriched: true, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "prevsha3", + checkStatus: "success", + additions: 8, + }), + ]; + const next = [ + // PR 1: updatedAt changed across failed backfill + makePullRequest({ + id: 1, + enriched: false, + updatedAt: "2026-04-20T10:05:00Z", + headSha: "", + checkStatus: null, + additions: 0, + }), + // PR 2: headSha differs even though updatedAt is identical + makePullRequest({ + id: 2, + enriched: false, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "newsha2", + checkStatus: null, + additions: 0, + }), + // PR 3: updatedAt and headSha match (next has empty headSha from light query) + makePullRequest({ + id: 3, + enriched: false, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "", + checkStatus: null, + additions: 0, + }), + ]; + + const result = fallbackToPreviousEnrichment(previous, next); + + expect(result[0].enriched).toBe(true); + expect(result[0].additions).toBe(42); + expect(result[0].checkStatus).toBeNull(); + expect(result[0].headSha).toBe(""); + expect(result[0].checkStatusStale).toBe(true); + + expect(result[1].enriched).toBe(true); + expect(result[1].additions).toBe(15); + expect(result[1].checkStatus).toBeNull(); + expect(result[1].headSha).toBe(""); + expect(result[1].checkStatusStale).toBe(true); + + expect(result[2].enriched).toBe(true); + expect(result[2].additions).toBe(8); + expect(result[2].checkStatus).toBe("success"); + expect(result[2].headSha).toBe("prevsha3"); + expect(result[2].checkStatusStale).toBeUndefined(); + }); + + it("clears checkStatus and keeps checkStatusStale: true for an unenriched PR whose prior enrichment was itself check-stale at the same revision", () => { + const previous = [ + makePullRequest({ + id: 1, + enriched: true, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "prevsha1", + checkStatus: "success", + checkStatusStale: true, + additions: 42, + }), + ]; + const next = [ + makePullRequest({ + id: 1, + enriched: false, + updatedAt: "2026-04-20T10:00:00Z", + headSha: "", + checkStatus: null, + additions: 0, + }), + ]; + + const [result] = fallbackToPreviousEnrichment(previous, next); + + expect(result.enriched).toBe(true); + expect(result.additions).toBe(42); + expect(result.headSha).toBe(""); + expect(result.checkStatus).toBeNull(); + expect(result.checkStatusStale).toBe(true); + }); + + it("falls back to updatedAt for check-stale enriched PRs when either headSha is empty", () => { + const previous = [ + makePullRequest({ id: 1, enriched: true, headSha: "", updatedAt: "2026-04-20T10:00:00Z", checkStatus: "failure" }), + makePullRequest({ id: 2, enriched: true, headSha: "", updatedAt: "2026-04-20T10:00:00Z", checkStatus: "success" }), + makePullRequest({ id: 3, enriched: true, headSha: "prevsha3", updatedAt: "2026-04-20T10:00:00Z", checkStatus: "success" }), + ]; + const next = [ + // Both headShas empty, same updatedAt -> same revision + makePullRequest({ id: 1, enriched: true, headSha: "", updatedAt: "2026-04-20T10:00:00Z", checkStatus: null, checkStatusStale: true }), + // Both headShas empty, updatedAt moved -> revision changed + makePullRequest({ id: 2, enriched: true, headSha: "", updatedAt: "2026-04-20T10:05:00Z", checkStatus: null, checkStatusStale: true }), + // Only next headSha empty, same updatedAt -> same revision + makePullRequest({ id: 3, enriched: true, headSha: "", updatedAt: "2026-04-20T10:00:00Z", checkStatus: null, checkStatusStale: true }), + ]; + + const result = fallbackToPreviousEnrichment(previous, next); + + expect(result[0].headSha).toBe(""); + expect(result[0].checkStatus).toBe("failure"); + expect(result[0].checkStatusStale).toBe(false); + + expect(result[1].headSha).toBe(""); + expect(result[1].checkStatus).toBeNull(); + expect(result[1].checkStatusStale).toBe(true); + + expect(result[2].headSha).toBe(""); + expect(result[2].checkStatus).toBe("success"); + expect(result[2].checkStatusStale).toBe(false); + }); }); describe("pickEnrichmentFields", () => { @@ -840,8 +979,7 @@ describe("Phase 1 prReviewReq -> Phase 2 mergeEnrichment team review request pre octokit as unknown as OctokitLike, repos, "TestUser", - undefined, - [{ login: "teammate", avatarUrl: "https://github.com/teammate.png", name: "Teammate", type: "user" }], + { trackedUsers: [{ login: "teammate", avatarUrl: "https://github.com/teammate.png", name: "Teammate", type: "user" }] }, ); expect(result.pullRequests).toHaveLength(1); const pr = result.pullRequests[0]; diff --git a/tests/services/api-users.test.ts b/tests/services/api-users.test.ts index 9f2edd8f..038b27b0 100644 --- a/tests/services/api-users.test.ts +++ b/tests/services/api-users.test.ts @@ -1,6 +1,6 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { validateGitHubUser, discoverUpstreamRepos, fetchIssuesAndPullRequests } from "../../src/app/services/api"; +import { validateGitHubUser, discoverUpstreamRepos, fetchIssuesAndPullRequests, resetPREnrichmentCache } from "../../src/app/services/api"; import type { TrackedUser } from "../../src/app/stores/config"; vi.mock("../../src/app/lib/errors", () => ({ @@ -86,13 +86,17 @@ function makeSearchPage(repoNames: string[], hasNextPage = false) { changedFiles: 0, reviewThreads: { totalCount: 0 }, latestReviews: { totalCount: 0, nodes: [] }, - commits: { nodes: [] }, + statusCheckRollup: null, })), }, rateLimit: { remaining: 4990, resetAt: "2024-01-01T01:00:00Z" }, }; } +beforeEach(() => { + resetPREnrichmentCache(); +}); + // ── validateGitHubUser ──────────────────────────────────────────────────────── describe("validateGitHubUser", () => { @@ -513,7 +517,7 @@ function makeBackfillResponse(databaseIds: number[] = []) { changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, })), rateLimit: { remaining: 4980, resetAt: "2024-01-01T01:00:00Z" }, }; @@ -561,7 +565,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + octokit as never, [repo], "mainuser", { trackedUsers: [makeTrackedUser("trackeduser")] } ); expect(result.issues).toHaveLength(1); @@ -597,7 +601,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + octokit as never, [repo], "mainuser", { trackedUsers: [makeTrackedUser("trackeduser")] } ); expect(result.issues).toHaveLength(2); @@ -640,8 +644,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, - [makeTrackedUser("usera"), makeTrackedUser("userb")] + octokit as never, [repo], "mainuser", { trackedUsers: [makeTrackedUser("usera"), makeTrackedUser("userb")] } ); const shared = result.issues.find((i) => i.id === sharedId); @@ -669,7 +672,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, [] + octokit as never, [repo], "mainuser", { trackedUsers: [] } ); expect(result.issues).toHaveLength(1); @@ -697,7 +700,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + octokit as never, [repo], "mainuser", { trackedUsers: [makeTrackedUser("trackeduser")] } ); // Main user's issue is still returned @@ -729,7 +732,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "mainuser", undefined, [makeTrackedUser("trackeduser")] + octokit as never, [repo], "mainuser", { trackedUsers: [makeTrackedUser("trackeduser")] } ); expect(result.pullRequests).toHaveLength(1); @@ -750,8 +753,7 @@ describe("multi-user search", () => { octokit as never, [], // empty repos — tracked user searches are also repo-scoped "mainuser", - undefined, - [makeTrackedUser("trackeduser")] + { trackedUsers: [makeTrackedUser("trackeduser")] } ); expect(result.issues).toEqual([]); @@ -773,7 +775,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [repo], "MainUser", undefined, [makeTrackedUser("TrackedUser")] + octokit as never, [repo], "MainUser", { trackedUsers: [makeTrackedUser("TrackedUser")] } ); expect(result.issues[0].surfacedBy).toEqual(["mainuser", "trackeduser"]); @@ -796,8 +798,7 @@ describe("multi-user search", () => { octokit as never, [repo], "mainuser", - (data) => { lightDataSurfacedBy = data.issues[0]?.surfacedBy; }, - [] + { onLightData: (data) => { lightDataSurfacedBy = data.issues[0]?.surfacedBy; }, trackedUsers: [] } ); // surfacedBy must be set when onLightData fires @@ -811,7 +812,7 @@ describe("multi-user search", () => { ); const result = await fetchIssuesAndPullRequests( - octokit as never, [], "mainuser", undefined, [] + octokit as never, [], "mainuser", { trackedUsers: [] } ); expect(result.issues).toEqual([]); diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index 31f2a155..cb414854 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -1,5 +1,6 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as Sentry from "@sentry/solid"; import { fetchOrgs, fetchRepos, @@ -10,16 +11,34 @@ import { approvePullRequest, markPullRequestReady, discoverUpstreamRepos, + fetchIssuesAndPullRequests, + fallbackToPreviousEnrichment, + isSameRevision, + hasRevisionChanged, + markCheckStatusStale, + resetPREnrichmentCache, + invalidatePREnrichmentCacheForRepos, + evictPREnrichmentCacheNode, + HEAVY_BACKFILL_BATCH_SIZE, + MIN_SPLIT_BATCH_SIZE, + SPLIT_SUPPRESSION_BATCH_THRESHOLD, + MIN_ENRICHMENT_CACHE_TTL_MS, + setPREnrichmentCacheRefreshInterval, type RepoRef, } from "../../src/app/services/api"; import { APPROVE_PR_MUTATION, MARK_PR_READY_MUTATION } from "../../src/app/services/github"; import { clearCache } from "../../src/app/stores/cache"; import { pushNotification } from "../../src/app/lib/errors"; +import { makePullRequest } from "../helpers/factories"; import orgsFixture from "../fixtures/github-orgs.json"; import reposFixture from "../fixtures/github-repos.json"; import runsFixture from "../fixtures/github-runs.json"; +vi.mock("@sentry/solid", () => ({ + captureException: vi.fn(), +})); + vi.mock("../../src/app/lib/errors", () => ({ pushNotification: vi.fn(), pushError: vi.fn(), @@ -83,6 +102,7 @@ const testRepo: RepoRef = { beforeEach(async () => { await clearCache(); + resetPREnrichmentCache(); vi.resetAllMocks(); }); @@ -830,15 +850,12 @@ describe("validateGitHubUser — VALID_TRACKED_LOGIN and type detection", () => describe("fetchIssuesAndPullRequests — monitoredRepos", () => { it("returns empty result with no repos even when monitoredRepos provided", async () => { - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const octokit = makeOctokit(async () => ({ data: {} }), async () => ({})); const result = await fetchIssuesAndPullRequests( octokit as never, [], "octocat", - undefined, - undefined, - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); expect(result.issues).toEqual([]); expect(result.pullRequests).toEqual([]); @@ -863,15 +880,12 @@ describe("fetchIssuesAndPullRequests — monitoredRepos", () => { } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const monitoredRepo = { owner: "org", name: "monitored", fullName: "org/monitored" }; await fetchIssuesAndPullRequests( octokit as never, [monitoredRepo], "octocat", - undefined, - undefined, - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); // Unfiltered queries should not contain 'involves:' @@ -918,14 +932,12 @@ describe("fetchIssuesAndPullRequests — all repos monitored (edge case)", () => } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const result = await fetchIssuesAndPullRequests( octokit as never, [repo], "octocat", - undefined, - undefined, - [{ owner: "org", name: "repo1", fullName: "org/repo1" }] // all repos monitored + // all repos monitored + { monitoredRepos: [{ owner: "org", name: "repo1", fullName: "org/repo1" }] } ); // Items returned from unfiltered search @@ -982,8 +994,6 @@ describe("fetchIssuesAndPullRequests — cross-feature: monitored repo + bot tra }; it("deduplicates issues when same item appears in both monitored and bot searches", async () => { - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); - const octokit = makeOctokit( async () => ({ data: {}, headers: {} }), async (_query: string, variables: unknown) => { @@ -1020,9 +1030,7 @@ describe("fetchIssuesAndPullRequests — cross-feature: monitored repo + bot tra octokit as never, [normalRepo, monitoredRepo], "octocat", - undefined, - [botUser], - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { trackedUsers: [botUser], monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); // Both issues are present (no dedup since different IDs) @@ -1117,14 +1125,11 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () = } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const result = await fetchIssuesAndPullRequests( octokit as never, [monitoredRepo], "octocat", - undefined, - undefined, - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); // Partial issue data recovered @@ -1155,14 +1160,11 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () = } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const result = await fetchIssuesAndPullRequests( octokit as never, [monitoredRepo], "octocat", - undefined, - undefined, - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); // No results recovered @@ -1219,14 +1221,11 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () = } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const result = await fetchIssuesAndPullRequests( octokit as never, [monitoredRepo], "octocat", - undefined, - undefined, - [{ owner: "org", name: "monitored", fullName: "org/monitored" }] + { monitoredRepos: [{ owner: "org", name: "monitored", fullName: "org/monitored" }] } ); // PR data recovered from partial response @@ -1261,14 +1260,12 @@ describe("fetchIssuesAndPullRequests — all monitored + tracked users skips inv } ); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); await fetchIssuesAndPullRequests( octokit as never, [repo], "octocat", - undefined, - [botUser], - [{ owner: "org", name: "repo1", fullName: "org/repo1" }] // all repos monitored + // all repos monitored + { trackedUsers: [botUser], monitoredRepos: [{ owner: "org", name: "repo1", fullName: "org/repo1" }] } ); // No involves: queries for tracked user (since normalRepos is empty, tracked search is skipped) @@ -1307,14 +1304,12 @@ describe("fetchIssuesAndPullRequests — onLightData suppression when all monito ); const onLightData = vi.fn(); - const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); const result = await fetchIssuesAndPullRequests( octokit as never, [repo], "octocat", - onLightData, - undefined, - [{ owner: "org", name: "repo1", fullName: "org/repo1" }] // all repos monitored + // all repos monitored + { onLightData, monitoredRepos: [{ owner: "org", name: "repo1", fullName: "org/repo1" }] } ); // onLightData NOT called (main user search skipped, unfiltered results come after) @@ -1417,7 +1412,7 @@ describe("fetchPREnrichment — autoMergeMethod and viewer review fields", () => changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, autoMergeRequest: { mergeMethod: "SQUASH" }, viewerLatestReview: { state: "CHANGES_REQUESTED" }, viewerLatestReviewRequest: { id: "RR_kwDOtest101" }, @@ -1435,7 +1430,7 @@ describe("fetchPREnrichment — autoMergeMethod and viewer review fields", () => changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [] }, + statusCheckRollup: null, autoMergeRequest: { mergeMethod: "MERGE" }, viewerLatestReview: { state: "APPROVED" }, viewerLatestReviewRequest: null, @@ -1453,7 +1448,7 @@ describe("fetchPREnrichment — autoMergeMethod and viewer review fields", () => changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [] }, + statusCheckRollup: null, autoMergeRequest: { mergeMethod: "REBASE" }, viewerLatestReview: { state: "UNKNOWN_STATE" }, viewerLatestReviewRequest: null, @@ -1471,7 +1466,7 @@ describe("fetchPREnrichment — autoMergeMethod and viewer review fields", () => changedFiles: 1, comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, - commits: { nodes: [] }, + statusCheckRollup: null, autoMergeRequest: { mergeMethod: "INVALID" }, viewerLatestReview: null, viewerLatestReviewRequest: null, @@ -1655,3 +1650,1331 @@ describe("markPullRequestReady", () => { ).rejects.toThrow("GitHub did not confirm pull request is ready for review"); }); }); + +// ── fetchPREnrichment — 25-node chunking, binary splitting, and light fallback ── + +describe("fetchPREnrichment — 25-node chunking, binary splitting, and light fallback", () => { + beforeEach(() => { + vi.mocked(Sentry.captureException).mockClear(); + resetPREnrichmentCache(); + }); + + function buildHeavyNode(dbId: number, nodeId: string, overrides: Record = {}) { + return { + id: nodeId, + databaseId: dbId, + headRefOid: `sha-${dbId}`, + headRepository: { owner: { login: "octocat" }, nameWithOwner: "octocat/Hello-World" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [{ login: "octocat" }] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 1, nodes: [{ author: { login: "reviewer1" } }] }, + additions: 15, + deletions: 3, + changedFiles: 2, + comments: { totalCount: 1 }, + reviewThreads: { totalCount: 0 }, + statusCheckRollup: { state: "SUCCESS" }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + ...overrides, + }; + } + + it("chunks 60 node IDs into [25, 25, 10] batches using HEAVY_BACKFILL_BATCH_SIZE = 25", async () => { + expect(HEAVY_BACKFILL_BATCH_SIZE).toBe(25); + expect(MIN_SPLIT_BATCH_SIZE).toBe(6); + + const batchSizes: number[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + batchSizes.push(ids.length); + return { + nodes: ids.map((id) => { + const num = Number(id.replace("PR_", "")); + return buildHeavyNode(num, id); + }), + rateLimit: { limit: 5000, remaining: 4990, resetAt: "2026-04-23T12:00:00Z" }, + }; + } + ); + + const nodeIds = Array.from({ length: 60 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(batchSizes).toEqual([25, 25, 10]); + expect(enrichments.size).toBe(60); + expect(errors).toEqual([]); + }); + + it("recursively splits a failing 25-node batch (25 -> 12/13 -> 6/6/6/7) on retryable errors, recovering all 25 enrichments with zero errors or Sentry calls", async () => { + const sentrySpy = vi.mocked(Sentry.captureException); + const callBatchSizes: number[] = []; + const allRetriesFlags: Array = []; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const vars = variables as { ids: string[]; request?: { retries?: number } }; + const ids = vars.ids; + callBatchSizes.push(ids.length); + allRetriesFlags.push(vars.request?.retries); + if (ids.length > MIN_SPLIT_BATCH_SIZE) { + throw Object.assign(new Error("Bad Gateway"), { status: 502 }); + } + return { + nodes: ids.map((id) => { + const num = Number(id.replace("PR_", "")); + return buildHeavyNode(num, id); + }), + }; + } + ); + + const nodeIds = Array.from({ length: 25 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(enrichments.size).toBe(25); + expect(errors).toEqual([]); + expect(sentrySpy).not.toHaveBeenCalled(); + // Both top-level and split sub-batches must pass retries: 0 so a 502/504 splits immediately + // without @octokit/plugin-retry re-running the unsplit 25-node batch first + expect(allRetriesFlags.length).toBeGreaterThan(0); + expect(allRetriesFlags.every((r) => r === 0)).toBe(true); + expect(callBatchSizes).toContain(25); + expect(callBatchSizes).toContain(12); + expect(callBatchSizes).toContain(13); + }); + + it.each([ + [ + "status-500 RequestError with a TypeError cause", + () => Object.assign(new Error("Failed to fetch", { cause: new TypeError("Failed to fetch") }), { status: 500 }), + ], + ["bare TypeError", () => new TypeError("Failed to fetch")], + ])( + "splits a batch that fails with 'Failed to fetch' (CORS-masked gateway 502/504) as a %s", + async (_label, makeError) => { + const heavySizes: number[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + if (query.includes("statusCheckRollup")) { + heavySizes.push(ids.length); + if (ids.length > MIN_SPLIT_BATCH_SIZE) throw makeError(); + } + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + ); + + const nodeIds = Array.from({ length: 25 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(heavySizes).toContain(12); + expect(heavySizes).toContain(13); + expect(enrichments.size).toBe(25); + expect(enrichments.get(1)?.checkStatusStale).toBeFalsy(); + expect(errors).toEqual([]); + } + ); + + it("still splits when the heavy request hit the per-attempt fetch timeout (TimeoutError cause)", async () => { + const callBatchSizes: number[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + callBatchSizes.push(ids.length); + if (ids.length > MIN_SPLIT_BATCH_SIZE) { + throw Object.assign( + new Error("signal timed out", { cause: new DOMException("signal timed out", "TimeoutError") }), + { status: 500 }, + ); + } + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + ); + + const nodeIds = Array.from({ length: 25 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(callBatchSizes).toContain(12); + expect(callBatchSizes).toContain(13); + expect(enrichments.size).toBe(25); + expect(errors).toEqual([]); + }); + + it("stops splitting once SPLIT_SUPPRESSION_BATCH_THRESHOLD batches have failed with no heavy node resolved", async () => { + expect(SPLIT_SUPPRESSION_BATCH_THRESHOLD).toBe(2); + const heavySizes: number[] = []; + const lightSizes: number[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + if (query.includes("statusCheckRollup")) { + heavySizes.push(ids.length); + throw Object.assign(new Error("Bad Gateway"), { status: 502 }); + } + lightSizes.push(ids.length); + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + ); + + const nodeIds = Array.from({ length: 75 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + // Batch 1 fails first and splits once (12/13); batches 2 and 3 reach the threshold and + // go straight to light, as do batch 1's halves. No second-level splits occur. + expect(heavySizes.filter((n) => n === 25)).toHaveLength(3); + expect(heavySizes.filter((n) => n < 12)).toEqual([]); + expect(heavySizes).toHaveLength(5); + expect(lightSizes).toHaveLength(4); + expect(lightSizes.reduce((a, b) => a + b, 0)).toBe(75); + expect(enrichments.size).toBe(75); + expect(errors).toHaveLength(3); + expect(errors.every((e) => e.retryable)).toBe(true); + }); + + it("keeps splitting failing batches once any heavy node has resolved in the same call", async () => { + const heavySizes: number[] = []; + const lightSizes: number[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + if (query.includes("statusCheckRollup")) { + heavySizes.push(ids.length); + // Batch 1 (PR_1..PR_25) succeeds; batches 2 and 3 fail until split down to <= 6 + const inFirstBatch = Number(ids[0].replace("PR_", "")) <= 25; + if (!inFirstBatch && ids.length > MIN_SPLIT_BATCH_SIZE) { + throw Object.assign(new Error("Bad Gateway"), { status: 502 }); + } + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + lightSizes.push(ids.length); + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + ); + + const nodeIds = Array.from({ length: 75 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + // Batch 3 is the second failing batch but still splits because batch 1 resolved heavy nodes + expect(heavySizes.filter((n) => n < 12).length).toBeGreaterThan(0); + expect(lightSizes).toEqual([]); + expect(enrichments.size).toBe(75); + expect(errors).toEqual([]); + }); + + it("does not count light-fallback nodes as resolved heavy nodes when deciding whether to split", async () => { + const batch3HeavySizes: number[] = []; + let lightCalls = 0; + let releaseBatch3!: () => void; + const batch3Released = new Promise((resolve) => { releaseBatch3 = resolve; }); + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const ids = (variables as { ids: string[] }).ids; + if (query.includes("statusCheckRollup")) { + if (ids.includes("PR_51")) { + batch3HeavySizes.push(ids.length); + // Hold batch 3's failure until batches 1-2 have fallen back to light and been ingested + await batch3Released; + } + throw Object.assign(new Error("Bad Gateway"), { status: 502 }); + } + lightCalls++; + // Batch 1 splits once (2 light leaves) and batch 2 goes straight to light: 3 light calls + if (lightCalls === 3) setTimeout(releaseBatch3, 0); + return { nodes: ids.map((id) => buildHeavyNode(Number(id.replace("PR_", "")), id)) }; + } + ); + + const nodeIds = Array.from({ length: 75 }, (_, i) => `PR_${i + 1}`); + const { enrichments } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(lightCalls).toBe(4); + expect(batch3HeavySizes).toEqual([25]); + expect(enrichments.size).toBe(75); + }); + + it("short-circuits immediately without splitting or calling LIGHT_PR_BACKFILL_QUERY when a batch fails with a non-retryable 401/403 status", async () => { + const sentrySpy = vi.mocked(Sentry.captureException); + const octokit = makeOctokit( + async () => ({ data: {} }), + async () => { + throw Object.assign(new Error("Resource not accessible by integration"), { status: 403 }); + } + ); + + const nodeIds = Array.from({ length: 25 }, (_, i) => `PR_${i + 1}`); + const { enrichments, errors } = await fetchPREnrichment(octokit as never, nodeIds); + + expect(octokit.graphql).toHaveBeenCalledTimes(1); + expect(enrichments.size).toBe(0); + expect(sentrySpy).toHaveBeenCalledTimes(1); + expect(errors).toEqual([ + expect.objectContaining({ + repo: "backfill-batch-1/1", + statusCode: 403, + retryable: false, + }), + ]); + }); + + it("falls back to LIGHT_PR_BACKFILL_QUERY when a <= 6 leaf batch fails HEAVY_PR_BACKFILL_QUERY, setting checkStatusStale: true and preserving prior checkStatus in fallbackToPreviousEnrichment", async () => { + const sentrySpy = vi.mocked(Sentry.captureException); + const queriesExecuted: string[] = []; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + queriesExecuted.push(query); + const ids = (variables as { ids: string[] }).ids; + if (query.includes("statusCheckRollup")) { + throw new TypeError("Failed to fetch"); + } + // Light query omits mergeStateStatus and statusCheckRollup + return { + nodes: ids.map((id) => { + const num = Number(id.replace("PR_", "")); + return { + id, + databaseId: num, + headRefOid: `sha-light-${num}`, + headRepository: { owner: { login: "octocat" }, nameWithOwner: "octocat/Hello-World" }, + assignees: { nodes: [{ login: "octocat" }] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 2, nodes: [{ author: { login: "reviewer1" } }] }, + additions: 42, + deletions: 7, + changedFiles: 4, + comments: { totalCount: 5 }, + reviewThreads: { totalCount: 1 }, + autoMergeRequest: null, + viewerLatestReview: { state: "APPROVED" }, + viewerLatestReviewRequest: null, + }; + }), + }; + } + ); + + const { enrichments, errors } = await fetchPREnrichment(octokit as never, ["PR_1", "PR_2"]); + + expect(queriesExecuted).toHaveLength(2); + expect(queriesExecuted[0]).toContain("statusCheckRollup"); + expect(queriesExecuted[1]).not.toContain("statusCheckRollup"); + expect(sentrySpy).toHaveBeenCalledTimes(1); + + const e1 = enrichments.get(1); + expect(e1).toBeDefined(); + expect(e1!.checkStatus).toBeNull(); + expect(e1!.checkStatusStale).toBe(true); + expect(e1!.additions).toBe(42); + expect(e1!.headSha).toBe("sha-light-1"); + expect(errors).toEqual([ + expect.objectContaining({ + repo: "backfill-batch-1/1", + retryable: true, + }), + ]); + + // Verify fallbackToPreviousEnrichment restores prior non-stale checkStatus when available + const prevPR1 = makePullRequest({ + id: 1, + enriched: true, + checkStatus: "success", + additions: 10, + }); + const nextPR1 = makePullRequest({ + id: 1, + enriched: true, + checkStatus: null, + checkStatusStale: true, + additions: 42, + }); + const nextPR2ColdStart = makePullRequest({ + id: 2, + enriched: true, + checkStatus: null, + checkStatusStale: true, + additions: 42, + }); + + const resolved = fallbackToPreviousEnrichment([prevPR1], [nextPR1, nextPR2ColdStart]); + expect(resolved[0].checkStatus).toBe("success"); + expect(resolved[0].checkStatusStale).toBe(false); + expect(resolved[0].additions).toBe(42); + + // Cold-start PR with no prior non-stale enrichment retains checkStatusStale: true + expect(resolved[1].checkStatus).toBeNull(); + expect(resolved[1].checkStatusStale).toBe(true); + }); + + it("salvages partial err.data.nodes on heavy/light failures and deduplicates batch errors across split leaves", async () => { + const ids = Array.from({ length: 14 }, (_, i) => `PR_node_${i + 1}`); + const calls: { isLight: boolean; ids: string[] }[] = []; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as { ids: string[] }; + const isLight = !query.includes("statusCheckRollup"); + calls.push({ isLight, ids: vars.ids }); + + if (!isLight && vars.ids.length === 14) { + // Top-level 14-node batch fails with partial data resolving PR_node_1 and PR_node_2, + // leaving 12 unresolved IDs which split into 6 + 6 leaves + throw Object.assign(new Error("Partial heavy timeout"), { + status: 502, + data: { + nodes: [ + { + id: "PR_node_1", + databaseId: 1, + headRefOid: "sha-1", + headRepository: null, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 5, + deletions: 1, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + statusCheckRollup: { state: "SUCCESS" }, + }, + { + id: "PR_node_2", + databaseId: 2, + headRefOid: "sha-2", + headRepository: null, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 6, + deletions: 2, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + statusCheckRollup: { state: "SUCCESS" }, + }, + ], + }, + }); + } + + if (!isLight) { + // Both 6-node leaf sub-batches fail heavy query + throw Object.assign(new Error("Leaf heavy timeout"), { status: 502 }); + } + + // Light fallback rejects with partial data on the first leaf and succeeds on the second + const firstId = vars.ids[0]; + const dbId = Number(firstId.replace("PR_node_", "")); + const lightNode = { + id: firstId, + databaseId: dbId, + headRefOid: `sha-${dbId}`, + headRepository: null, + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 20, + deletions: 4, + changedFiles: 2, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + }; + if (firstId === "PR_node_3") { + throw Object.assign(new Error("Light partial error"), { + status: 502, + data: { nodes: [lightNode] }, + }); + } + return { nodes: [lightNode] }; + } + ); + + const { enrichments, errors } = await fetchPREnrichment(octokit as never, ids); + // PR_node_1 and PR_node_2 salvaged from top-level heavy partial data and excluded from split retries + expect(enrichments.get(1)?.checkStatus).toBe("success"); + expect(enrichments.get(2)?.checkStatus).toBe("success"); + expect(calls[1].ids).not.toContain("PR_node_1"); + expect(calls[1].ids).not.toContain("PR_node_2"); + // PR_node_3 salvaged from lightErr.data.nodes with checkStatusStale: true + expect(enrichments.get(3)?.checkStatusStale).toBe(true); + expect(enrichments.get(9)?.checkStatusStale).toBe(true); + // Only 1 error pushed for the top-level batch despite both 6-node leaves failing + expect(errors).toHaveLength(1); + expect(errors[0].repo).toBe("backfill-batch-1/1"); + }); + + it("parses direct PullRequest.statusCheckRollup.state and handles null nested collections/counts without TypeError", async () => { + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string) => { + expect(query).toContain("statusCheckRollup { state }"); + expect(query).not.toContain("commits(last: 1)"); + return { + nodes: [ + { + id: "PR_null_fields", + databaseId: 301, + headRefOid: "sha-301", + headRepository: { owner: null, nameWithOwner: "fork/repo" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: null }, + reviewRequests: { nodes: null }, + latestReviews: { totalCount: null, nodes: null }, + additions: null, + deletions: null, + changedFiles: null, + comments: null, + reviewThreads: { totalCount: null }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + statusCheckRollup: { state: "FAILURE" }, + }, + { + id: "PR_direct_success", + databaseId: 302, + headRefOid: "sha-302", + headRepository: null, + mergeStateStatus: "CLEAN", + assignees: null, + reviewRequests: { nodes: [null, { requestedReviewer: null }, { requestedReviewer: { login: "Alice" } }] }, + latestReviews: { totalCount: 2, nodes: [null, { author: null }, { author: { login: "Bob" } }] }, + additions: 12, + deletions: 3, + changedFiles: 1, + comments: { totalCount: 4 }, + reviewThreads: null, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + statusCheckRollup: { state: "SUCCESS" }, + }, + ], + }; + } + ); + + const { enrichments, errors } = await fetchPREnrichment(octokit as never, ["PR_null_fields", "PR_direct_success"]); + expect(errors).toEqual([]); + + const e301 = enrichments.get(301); + expect(e301).toBeDefined(); + expect(e301!.checkStatus).toBe("failure"); + expect(e301!.headRepository).toBeNull(); + expect(e301!.assigneeLogins).toEqual([]); + expect(e301!.reviewerLogins).toEqual([]); + expect(e301!.additions).toBe(0); + expect(e301!.deletions).toBe(0); + expect(e301!.changedFiles).toBe(0); + expect(e301!.comments).toBe(0); + expect(e301!.reviewThreads).toBe(0); + expect(e301!.totalReviewCount).toBe(0); + + const e302 = enrichments.get(302); + expect(e302).toBeDefined(); + expect(e302!.checkStatus).toBe("success"); + expect(e302!.reviewerLogins).toEqual(["alice", "bob"]); + expect(e302!.comments).toBe(4); + expect(e302!.reviewThreads).toBe(0); + }); + + it("excludes truncated node indices from err.errors[].path during partial salvage and retries 1..6 remaining IDs with HEAVY_PR_BACKFILL_QUERY", async () => { + const ids = Array.from({ length: 9 }, (_, i) => `PR_${i + 1}`); + const heavyCallIds: string[][] = []; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as { ids: string[] }; + const isHeavy = query.includes("statusCheckRollup"); + if (isHeavy) { + heavyCallIds.push(vars.ids); + } + + if (vars.ids.length === 9) { + // Simulate RESOURCE_LIMITS_EXCEEDED on nodes[8]: nodes 0..7 are intact, + // node 8 has truncated null sub-fields and is referenced in err.errors[].path. + throw Object.assign(new Error("Resource limits exceeded"), { + errors: [ + null, + { path: ["rateLimit"] }, + { path: ["nodes", -1, "commits"] }, + { path: ["nodes", "8", "commits"] }, + { path: ["nodes", 8, "reviewRequests", "nodes"] }, + { path: ["nodes", 8, "commits", "nodes"] }, + ], + data: { + nodes: [ + ...ids.slice(0, 8).map((id, idx) => + buildHeavyNode(idx + 1, id, { + statusCheckRollup: { state: "SUCCESS" }, }) + ), + { + id: "PR_9", + databaseId: 9, + headRefOid: "sha-9", + headRepository: null, + mergeStateStatus: "CLEAN", + assignees: { nodes: null }, + reviewRequests: { nodes: null }, + latestReviews: { totalCount: 0, nodes: null }, + additions: 1, + deletions: 1, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + statusCheckRollup: null, }, + ], + }, + }); + } + + // Follow-up heavy retry for the single remaining ID ["PR_9"] succeeds with real FAILURE status + return { + nodes: [ + buildHeavyNode(9, "PR_9", { + headRefOid: "sha-9-fresh", + statusCheckRollup: { state: "FAILURE" }, }), + ], + }; + } + ); + + const { enrichments, errors } = await fetchPREnrichment(octokit as never, ids); + + // 1st call: all 9 IDs; 2nd call: heavy retry of only ["PR_9"] (since 8 salvaged, leaving 1 <= MIN_SPLIT_BATCH_SIZE) + expect(heavyCallIds).toEqual([ids, ["PR_9"]]); + expect(errors).toEqual([]); + expect(enrichments.size).toBe(9); + expect(enrichments.get(1)?.checkStatus).toBe("success"); + // Node 9 was NOT poisoned with checkStatus: null from the truncated partial node; it got "failure" from heavy retry + expect(enrichments.get(9)?.checkStatus).toBe("failure"); + expect(enrichments.get(9)?.headSha).toBe("sha-9-fresh"); + }); + + it("enforces revision-aware headSha and updatedAt checks in fallbackToPreviousEnrichment", () => { + // 1. Light-backfill PR (enriched: true, checkStatusStale: true) with matching headSha -> preserves prev.checkStatus + const prevMatchingSha = makePullRequest({ + id: 10, + enriched: true, + headSha: "sha-same", + updatedAt: "2026-04-20T10:00:00Z", + checkStatus: "failure", + }); + const nextMatchingSha = makePullRequest({ + id: 10, + enriched: true, + headSha: "sha-same", + updatedAt: "2026-04-20T10:05:00Z", + checkStatus: null, + checkStatusStale: true, + }); + const [resMatchingSha] = fallbackToPreviousEnrichment([prevMatchingSha], [nextMatchingSha]); + expect(resMatchingSha.checkStatus).toBe("failure"); + expect(resMatchingSha.checkStatusStale).toBe(false); + + // 2. Light-backfill PR (enriched: true, checkStatusStale: true) with different headSha (force-push) -> clears checkStatus to null and keeps checkStatusStale: true + const nextNewSha = makePullRequest({ + id: 10, + enriched: true, + headSha: "sha-new-commit", + updatedAt: "2026-04-20T10:05:00Z", + checkStatus: null, + checkStatusStale: true, + }); + const [resNewSha] = fallbackToPreviousEnrichment([prevMatchingSha], [nextNewSha]); + expect(resNewSha.checkStatus).toBeNull(); + expect(resNewSha.checkStatusStale).toBe(true); + + // 3. Unenriched PR (enriched: false) where updatedAt changed -> carries forward heavy counts, clears headSha to "" and checkStatus to null, sets checkStatusStale: true + const prevEnriched = makePullRequest({ + id: 20, + enriched: true, + headSha: "sha-old", + updatedAt: "2026-04-20T10:00:00Z", + checkStatus: "failure", + additions: 55, + }); + const nextUnenrichedUpdated = makePullRequest({ + id: 20, + enriched: false, + headSha: "", + updatedAt: "2026-04-20T11:00:00Z", + checkStatus: null, + additions: 0, + }); + const [resUnenrichedUpdated] = fallbackToPreviousEnrichment([prevEnriched], [nextUnenrichedUpdated]); + expect(resUnenrichedUpdated.enriched).toBe(true); + expect(resUnenrichedUpdated.additions).toBe(55); + expect(resUnenrichedUpdated.headSha).toBe(""); + expect(resUnenrichedUpdated.checkStatus).toBeNull(); + expect(resUnenrichedUpdated.checkStatusStale).toBe(true); + }); +}); + +// ── fetchIssuesAndPullRequests — settled-PR enrichment cache ───────────────── + +describe("fetchIssuesAndPullRequests — settled-PR enrichment cache", () => { + function makeLightPR(overrides: Record = {}) { + return { + id: "PR_kwDOcache42", + databaseId: 42, + number: 42, + title: "Cached PR", + state: "OPEN", + isDraft: false, + url: "https://github.com/octocat/Hello-World/pull/42", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://github.com/images/error/octocat_happy.gif" }, + repository: { nameWithOwner: "octocat/Hello-World" }, + headRefName: "feature-branch", + baseRefName: "main", + reviewDecision: "APPROVED", + labels: { nodes: [] }, + ...overrides, + }; + } + + function makeHeavyPR(overrides: Record = {}) { + return { + id: "PR_kwDOcache42", + databaseId: 42, + headRefOid: "abc123def456", + headRepository: { owner: { login: "octocat" }, nameWithOwner: "octocat/Hello-World" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [{ login: "octocat" }] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 1, nodes: [{ author: { login: "reviewer1" } }] }, + additions: 50, + deletions: 10, + changedFiles: 3, + comments: { totalCount: 2 }, + reviewThreads: { totalCount: 1 }, + statusCheckRollup: { state: "SUCCESS" }, + autoMergeRequest: null, + viewerLatestReview: { state: "APPROVED" }, + viewerLatestReviewRequest: null, + ...overrides, + }; + } + + it("excludes archived repos from every repo-scoped search query (involves, review-requested, monitored)", async () => { + const searchQueries: string[] = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) return { nodes: [makeHeavyPR()] }; + for (const key of ["issueQ", "prInvQ", "prRevQ", "prQ"]) { + if (typeof vars[key] === "string") searchQueries.push(vars[key]); + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prs: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + const monitoredRepo: RepoRef = { owner: "org", name: "monitored", fullName: "org/monitored" }; + + await fetchIssuesAndPullRequests(octokit as never, [testRepo, monitoredRepo], "octocat", { + monitoredRepos: [monitoredRepo], + }); + + // 3 light-search queries (issues, involves, review-requested) + 2 unfiltered (issues, prs) + expect(searchQueries.length).toBeGreaterThanOrEqual(5); + expect(searchQueries.some((q) => q.includes("review-requested:octocat"))).toBe(true); + expect(searchQueries.some((q) => q.includes("repo:org/monitored") && !q.includes("involves:"))).toBe(true); + for (const q of searchQueries) { + expect(q).toContain("archived:false"); + } + }); + + it("serves settled PRs (including settled fork PRs) from cache within the TTL without re-running HEAVY_PR_BACKFILL_QUERY or fork check", async () => { + let heavyCalls = 0; + let forkCalls = 0; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + // Simulate a fork PR where main repo statusCheckRollup is null so fork0 fallback resolves it + return { + nodes: [ + makeHeavyPR({ + headRepository: { owner: { login: "fork-owner" }, nameWithOwner: "fork-owner/Hello-World" }, + statusCheckRollup: null, + }), + ], + }; + } + if (query.includes("fork0:")) { + forkCalls++; + return { + fork0: { + object: { + statusCheckRollup: { state: "SUCCESS" }, + }, + }, + }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + // Poll 1: populates cache after runForkPRFallback resolves checkStatus = "success" + const res1 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(1); + expect(forkCalls).toBe(1); + expect(res1.pullRequests[0].enriched).toBe(true); + expect(res1.pullRequests[0].checkStatus).toBe("success"); + + // Poll 2 within the TTL with identical updatedAt and userLogin: hits cache, skips heavy backfill and fork check + const res2 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(1); + expect(forkCalls).toBe(1); + expect(res2.pullRequests[0].enriched).toBe(true); + expect(res2.pullRequests[0].checkStatus).toBe("success"); + expect(res2.pullRequests[0].additions).toBe(50); + }); + + it("re-fetches when updatedAt changes, TTL expires, force=true is passed, repo/node is invalidated, or resetPREnrichmentCache is called", async () => { + vi.useFakeTimers(); + let currentUpdatedAt = "2024-01-02T00:00:00Z"; + let heavyCalls = 0; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + return { nodes: [makeHeavyPR()] }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { + issueCount: 1, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [makeLightPR({ updatedAt: currentUpdatedAt })], + }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + try { + // Seed cache + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(1); + + // Cache hit + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(1); + + // 1. force = true bypasses cache + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat", { force: true }); + expect(heavyCalls).toBe(2); + + // 2. invalidatePREnrichmentCacheForRepos evicts repo entries + invalidatePREnrichmentCacheForRepos(["OCTOCAT/Hello-World"]); + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(3); + + // 3. evictPREnrichmentCacheNode evicts specific node + evictPREnrichmentCacheNode("PR_kwDOcache42"); + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(4); + + // 4. resetPREnrichmentCache clears all entries + resetPREnrichmentCache(); + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(5); + + // 5. TTL expiration (default 3-minute floor) triggers re-fetch + vi.advanceTimersByTime(MIN_ENRICHMENT_CACHE_TTL_MS + 1); + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(6); + + // 6. updatedAt change triggers re-fetch + currentUpdatedAt = "2024-01-03T00:00:00Z"; + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(7); + + // 7. Different userLogin does not reuse previous user's cache entry + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "other-user"); + expect(heavyCalls).toBe(8); + } finally { + vi.useRealTimers(); + } + }); + + it("never caches PRs with checkStatus === 'pending' or checkStatusStale === true", async () => { + let heavyCalls = 0; + let mode: "pending" | "stale" = "pending"; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + if (mode === "pending") { + heavyCalls++; + return { + nodes: [ + makeHeavyPR({ + statusCheckRollup: { state: "PENDING" }, + }), + ], + }; + } + // mode === "stale": fail heavy query, succeed light fallback + if (query.includes("statusCheckRollup")) { + heavyCalls++; + throw new TypeError("Failed to fetch"); + } + return { + nodes: [ + { + id: "PR_kwDOcache42", + databaseId: 42, + headRefOid: "abc123def456", + headRepository: null, + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 10, + deletions: 2, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + }, + ], + }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + // Pending CI status must not be cached + const pendingRes1 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(pendingRes1.pullRequests[0].checkStatus).toBe("pending"); + expect(heavyCalls).toBe(1); + + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(2); + + // Light fallback (checkStatusStale === true) must not be cached + mode = "stale"; + const staleRes1 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(staleRes1.pullRequests[0].checkStatusStale).toBe(true); + expect(heavyCalls).toBe(3); + + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(4); + }); + + it("marks fork PRs with checkStatusStale === true and skips caching when runForkPRFallback fails", async () => { + let heavyCalls = 0; + let forkCalls = 0; + let failForkFallback = true; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + return { + nodes: [ + makeHeavyPR({ + headRepository: { owner: { login: "contributor" }, nameWithOwner: "contributor/Hello-World" }, + statusCheckRollup: null, + }), + ], + }; + } + if (query.includes("fork0:")) { + forkCalls++; + if (failForkFallback) { + throw new TypeError("Failed to fetch"); + } + return { + fork0: { + object: { + statusCheckRollup: { state: "SUCCESS" }, + }, + }, + }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + // Poll 1: fork fallback fails -> checkStatusStale is set to true and PR is not cached + const res1 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(1); + expect(forkCalls).toBe(1); + expect(res1.pullRequests[0].checkStatus).toBeNull(); + expect(res1.pullRequests[0].checkStatusStale).toBe(true); + + // Poll 2: because Poll 1 did not cache the failed fork check, it re-runs heavy backfill + fork fallback + failForkFallback = false; + const res2 = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(2); + expect(forkCalls).toBe(2); + expect(res2.pullRequests[0].checkStatus).toBe("success"); + expect(res2.pullRequests[0].checkStatusStale).toBeUndefined(); + }); + + it("skips runForkPRFallback when a fork PR falls back to LIGHT_PR_BACKFILL_QUERY (checkStatusStale: true)", async () => { + let forkCalls = 0; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + if (query.includes("statusCheckRollup")) { + throw new TypeError("Failed to fetch"); + } + // Light fallback succeeds for a fork PR + return { + nodes: [ + { + id: "PR_kwDOcache42", + databaseId: 42, + headRefOid: "abc123def456", + headRepository: { owner: { login: "contributor" }, nameWithOwner: "contributor/Hello-World" }, + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 10, + deletions: 2, + changedFiles: 1, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + autoMergeRequest: null, + viewerLatestReview: null, + viewerLatestReviewRequest: null, + }, + ], + }; + } + if (query.includes("fork0:")) { + forkCalls++; + return { + fork0: { + object: { + statusCheckRollup: { state: "SUCCESS" }, + }, + }, + }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + const res = await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(forkCalls).toBe(0); + expect(res.pullRequests[0].checkStatus).toBeNull(); + expect(res.pullRequests[0].checkStatusStale).toBe(true); + }); + + it("serves delta-backfill (monitoredRepos) PRs from cache and evicts closed PRs on clean polls without evicting unpolled repos", async () => { + const monitoredRepo = { owner: "octocat", name: "Monitored", fullName: "octocat/Monitored" }; + let includeMainPr = true; + let failSearch = false; + let heavyCalls = 0; + + const octokit = makeOctokit( + async () => ({ data: {} }), + async (query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + const ids = vars.ids as string[]; + return { + nodes: ids.map((id) => + makeHeavyPR({ + id, + databaseId: id === "PR_kwDOcache42" ? 42 : 99, + }) + ), + }; + } + if (query.includes("prs: search(")) { + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prs: { + issueCount: 1, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + makeLightPR({ + id: "PR_kwDOmon99", + databaseId: 99, + number: 99, + repository: { nameWithOwner: "octocat/Monitored" }, + }), + ], + }, + }; + } + if (failSearch) { + throw Object.assign(new Error("Search 502"), { status: 502 }); + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { + issueCount: includeMainPr ? 1 : 0, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: includeMainPr ? [makeLightPR()] : [], + }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + + // Poll 1: populates cache for both main PR (42 in octocat/Hello-World) and delta monitored PR (99 in octocat/Monitored) + await fetchIssuesAndPullRequests( + octokit as never, + [testRepo, monitoredRepo], + "octocat", + { monitoredRepos: [monitoredRepo] } + ); + expect(heavyCalls).toBe(2); + + // Poll 2: both main PR and monitored delta PR hit _prEnrichmentCache (0 new heavy calls) + await fetchIssuesAndPullRequests( + octokit as never, + [testRepo, monitoredRepo], + "octocat", + { monitoredRepos: [monitoredRepo] } + ); + expect(heavyCalls).toBe(2); + + // Poll 3: targeted poll of ONLY monitoredRepo does NOT evict octocat/Hello-World's cached PR + await fetchIssuesAndPullRequests( + octokit as never, + [monitoredRepo], + "octocat", + { monitoredRepos: [monitoredRepo] } + ); + expect(heavyCalls).toBe(2); + + // Poll 4: search error on octocat/Hello-World (allErrors.length > 0) does NOT evict its cached PR + failSearch = true; + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + failSearch = false; + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(2); + + // Poll 5: clean poll where PR 42 is closed/absent evicts PR_kwDOcache42 from _prEnrichmentCache + includeMainPr = false; + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + includeMainPr = true; + await fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + expect(heavyCalls).toBe(3); + }); + + it("derives the TTL from the refresh interval (interval + 60s, floored at 3 minutes) so settled PRs survive the next scheduled poll", async () => { + vi.useFakeTimers(); + let heavyCalls = 0; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + return { nodes: [makeHeavyPR()] }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + const poll = () => fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + + try { + // Default 300s interval → 360s TTL + setPREnrichmentCacheRefreshInterval(300); + await poll(); + expect(heavyCalls).toBe(1); + + // Next scheduled poll at the maximum jittered interval (300s + 30s) hits the cache + vi.advanceTimersByTime(330_000); + await poll(); + expect(heavyCalls).toBe(1); + + // At interval + 60s the entry has expired + vi.advanceTimersByTime(30_000); + await poll(); + expect(heavyCalls).toBe(2); + + // Short intervals and auto-refresh off (0) fall back to the 3-minute floor + for (const intervalSec of [60, 0]) { + setPREnrichmentCacheRefreshInterval(intervalSec); + resetPREnrichmentCache(); + const before = heavyCalls; + await poll(); + expect(heavyCalls).toBe(before + 1); + vi.advanceTimersByTime(MIN_ENRICHMENT_CACHE_TTL_MS - 1); + await poll(); + expect(heavyCalls).toBe(before + 1); + vi.advanceTimersByTime(1); + await poll(); + expect(heavyCalls).toBe(before + 2); + } + } finally { + setPREnrichmentCacheRefreshInterval(0); + vi.useRealTimers(); + } + }); + + it("skips cache writes when the cache generation changes or the client is swapped while the backfill is in flight", async () => { + const githubModule = await import("../../src/app/services/github"); + let heavyCalls = 0; + let holdHeavy = false; + const releaseHeavy: Array<() => void> = []; + const octokit = makeOctokit( + async () => ({ data: {} }), + async (_query: string, variables?: unknown) => { + const vars = (variables ?? {}) as Record; + if ("ids" in vars) { + heavyCalls++; + if (holdHeavy) { + await new Promise((resolve) => releaseHeavy.push(resolve)); + } + return { nodes: [makeHeavyPR()] }; + } + return { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [makeLightPR()] }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }; + } + ); + const poll = () => fetchIssuesAndPullRequests(octokit as never, [testRepo], "octocat"); + + // Starts a poll, waits until its heavy backfill is pending, runs `interrupt`, then resolves the backfill + const pollWithInterrupt = async (interrupt: () => void) => { + holdHeavy = true; + const callsBefore = heavyCalls; + const pending = poll(); + await vi.waitFor(() => expect(heavyCalls).toBe(callsBefore + 1)); + interrupt(); + holdHeavy = false; + releaseHeavy.shift()!(); + const res = await pending; + expect(res.pullRequests[0].enriched).toBe(true); + }; + + // 1. evictPREnrichmentCacheNode while the backfill is in flight: the result is not cached + await pollWithInterrupt(() => evictPREnrichmentCacheNode("PR_kwDOcache42")); + await poll(); + expect(heavyCalls).toBe(2); + // Control: an uninterrupted poll's result is cached + await poll(); + expect(heavyCalls).toBe(2); + + // 2. invalidatePREnrichmentCacheForRepos while the backfill is in flight + resetPREnrichmentCache(); + await pollWithInterrupt(() => invalidatePREnrichmentCacheForRepos(["octocat/Hello-World"])); + await poll(); + expect(heavyCalls).toBe(4); + + // 3. Client swapped (logout / re-auth) while the backfill is in flight + resetPREnrichmentCache(); + const getClientSpy = vi.spyOn(githubModule, "getClient"); + try { + await pollWithInterrupt(() => { + getClientSpy.mockReturnValue(makeOctokit(async () => ({ data: {} })) as never); + }); + } finally { + getClientSpy.mockRestore(); + } + await poll(); + expect(heavyCalls).toBe(6); + + // 4. getClient() returning the polling octokit permits the write + resetPREnrichmentCache(); + const sameClientSpy = vi.spyOn(githubModule, "getClient").mockReturnValue(octokit as never); + try { + await poll(); + await poll(); + } finally { + sameClientSpy.mockRestore(); + } + expect(heavyCalls).toBe(7); + }); +}); + +describe("revision helpers", () => { + it("isSameRevision compares headSha when both known, else updatedAt", () => { + expect(isSameRevision({ headSha: "a", updatedAt: "t1" }, { headSha: "a", updatedAt: "t2" })).toBe(true); + expect(isSameRevision({ headSha: "a", updatedAt: "t1" }, { headSha: "b", updatedAt: "t1" })).toBe(false); + expect(isSameRevision({ headSha: "", updatedAt: "t1" }, { headSha: "b", updatedAt: "t1" })).toBe(true); + expect(isSameRevision({ headSha: "", updatedAt: "t1" }, { headSha: "b", updatedAt: "t2" })).toBe(false); + }); + + it("hasRevisionChanged flags an updatedAt change or differing known headShas", () => { + expect(hasRevisionChanged({ headSha: "a", updatedAt: "t1" }, { headSha: "a", updatedAt: "t2" })).toBe(true); + expect(hasRevisionChanged({ headSha: "a", updatedAt: "t1" }, { headSha: "b", updatedAt: "t1" })).toBe(true); + expect(hasRevisionChanged({ headSha: "", updatedAt: "t1" }, { headSha: "b", updatedAt: "t1" })).toBe(false); + }); + + it("markCheckStatusStale clears headSha/checkStatus and sets the stale flag in place", () => { + const pr = makePullRequest({ headSha: "abc", checkStatus: "success", checkStatusStale: false }); + expect(markCheckStatusStale(pr)).toBe(pr); + expect(pr).toMatchObject({ headSha: "", checkStatus: null, checkStatusStale: true }); + }); +}); diff --git a/tests/services/events-poll.test.ts b/tests/services/events-poll.test.ts index 1c06b6e4..2af85df8 100644 --- a/tests/services/events-poll.test.ts +++ b/tests/services/events-poll.test.ts @@ -49,6 +49,7 @@ vi.mock("../../src/app/services/events", () => ({ const mockFetchIssuesAndPullRequests = vi.fn(); const mockFetchWorkflowRuns = vi.fn(); +const mockInvalidatePREnrichmentCacheForRepos = vi.fn(); vi.mock("../../src/app/services/api", () => ({ fetchIssuesAndPullRequests: (...args: unknown[]) => mockFetchIssuesAndPullRequests(...args), fetchWorkflowRuns: (...args: unknown[]) => mockFetchWorkflowRuns(...args), @@ -59,6 +60,9 @@ vi.mock("../../src/app/services/api", () => ({ return results; }), resetEmptyActionRepos: vi.fn(), + resetPREnrichmentCache: vi.fn(), + invalidatePREnrichmentCacheForRepos: (...args: unknown[]) => mockInvalidatePREnrichmentCacheForRepos(...args), + setPREnrichmentCacheRefreshInterval: vi.fn(), })); import { fetchHotPRStatus, fetchWorkflowRunById } from "../../src/app/services/api"; @@ -168,11 +172,42 @@ describe("fetchTargetedRepoData", () => { await fetchTargetedRepoData(summaries); + expect(mockInvalidatePREnrichmentCacheForRepos).toHaveBeenCalledWith(["owner/repo-a"]); expect(mockFetchIssuesAndPullRequests).toHaveBeenCalledTimes(1); const calledRepos = mockFetchIssuesAndPullRequests.mock.calls[0][1] as Array<{ owner: string; name: string }>; expect(calledRepos).toContainEqual(expect.objectContaining({ owner: "owner", name: "repo-a" })); }); + it("forwards config.trackedUsers and config.monitoredRepos in the fetchIssuesAndPullRequests options", async () => { + const octokit = makeOctokit(); + mockGetClient.mockReturnValue(octokit); + const { config } = await import("../../src/app/stores/config"); + const trackedUsers = [ + { login: "renovate", avatarUrl: "https://github.com/renovate.png", name: "Renovate", type: "bot" as const }, + ]; + const monitoredRepos = [{ owner: "owner", name: "repo-a", fullName: "owner/repo-a" }]; + (config as { trackedUsers: unknown[]; monitoredRepos: unknown[] }).trackedUsers = trackedUsers; + (config as { trackedUsers: unknown[]; monitoredRepos: unknown[] }).monitoredRepos = monitoredRepos; + + try { + const summaries = new Map([ + ["owner/repo-a", makeRepoSummary({ repoFullName: "owner/repo-a" })], + ]); + + await fetchTargetedRepoData(summaries); + + expect(mockFetchIssuesAndPullRequests).toHaveBeenCalledWith( + octokit, + [{ owner: "owner", name: "repo-a", fullName: "owner/repo-a" }], + "", + { trackedUsers, monitoredRepos }, + ); + } finally { + (config as { trackedUsers: unknown[]; monitoredRepos: unknown[] }).trackedUsers = []; + (config as { trackedUsers: unknown[]; monitoredRepos: unknown[] }).monitoredRepos = []; + } + }); + it("calls fetchWorkflowRuns only for repos with hasWorkflowActivity=true", async () => { mockGetClient.mockReturnValue(makeOctokit()); const summaries = new Map([ @@ -302,6 +337,21 @@ describe("seedHotSetsFromTargeted", () => { expect(fetchHotPRStatus).not.toHaveBeenCalled(); }); + it("adds enriched PRs with checkStatus=null and checkStatusStale=true to hot set", async () => { + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 2, checkStatus: null, checkStatusStale: true, enriched: true, nodeId: "PR_stale" }), + ], + }); + + await fetchHotData(); + + expect(fetchHotPRStatus).toHaveBeenCalledTimes(1); + const calledNodeIds = vi.mocked(fetchHotPRStatus).mock.calls[0][1]; + expect(calledNodeIds).toContain("PR_stale"); + }); + it("does NOT add PRs that are not enriched", async () => { seedHotSetsFromTargeted({ ...emptyData, diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index 3f421b45..1af52f9f 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -1,8 +1,9 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot } from "solid-js"; -import { createGitHubClient, cachedRequest, getClient, initClientWatcher, getGraphqlRateLimit, updateGraphqlRateLimit, updateRateLimitFromHeaders, getCoreRateLimit, fetchRateLimitDetails, onApiRequest, APPROVE_PR_MUTATION, MARK_PR_READY_MUTATION, GITHUB_NODE_ID_RE, GIT_SHA_RE, type ApiRequestInfo } from "../../src/app/services/github"; +import { createGitHubClient, cachedRequest, getClient, initClientWatcher, getGraphqlRateLimit, updateGraphqlRateLimit, updateRateLimitFromHeaders, getCoreRateLimit, fetchRateLimitDetails, onApiRequest, APPROVE_PR_MUTATION, MARK_PR_READY_MUTATION, GITHUB_NODE_ID_RE, GIT_SHA_RE, REQUEST_TIMEOUT_MS, MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC, type ApiRequestInfo } from "../../src/app/services/github"; import { clearCache } from "../../src/app/stores/cache"; +import { getNotifications, clearNotifications } from "../../src/app/lib/errors"; // ── createGitHubClient ─────────────────────────────────────────────────────── @@ -312,6 +313,202 @@ describe("createGitHubClient", () => { }) ).rejects.toThrow(/Write operation blocked/); }); + + it("attaches per-attempt AbortSignal.timeout(REQUEST_TIMEOUT_MS) and composes caller signal via AbortSignal.any without retrying caller aborts", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const anySpy = vi.spyOn(AbortSignal, "any"); + + const client = createGitHubClient("test-token"); + const receivedSignals: Array = []; + + const mockFetch = vi.fn().mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + receivedSignals.push(init?.signal); + return new Response( + JSON.stringify({ data: { viewer: { login: "octocat" } } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await client.graphql("{ viewer { login } }"); + expect(timeoutSpy).toHaveBeenCalledWith(REQUEST_TIMEOUT_MS); + expect(receivedSignals).toHaveLength(1); + expect(receivedSignals[0]).toBeInstanceOf(AbortSignal); + + // Now pass a caller AbortController signal and abort it while fetch is in flight + const controller = new AbortController(); + mockFetch.mockReset(); + mockFetch.mockImplementation( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const sig = init?.signal; + if (sig?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + sig?.addEventListener( + "abort", + () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }, + { once: true } + ); + queueMicrotask(() => controller.abort()); + }) + ); + + const pending = client.graphql("{ viewer { login } }", { + request: { signal: controller.signal }, + }); + + await expect(pending).rejects.toThrow(/aborted/i); + expect(anySpy).toHaveBeenCalled(); + // Caller-initiated abort must set retries = 0 so plugin-retry does not schedule retries + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + timeoutSpy.mockRestore(); + anySpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("composes caller and per-attempt timeout signals without AbortSignal.any (older browsers)", async () => { + const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any"); + Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true, writable: true }); + const timeoutA = new AbortController(); + const timeoutB = new AbortController(); + let timeoutCalls = 0; + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation(() => (timeoutCalls++ === 0 ? timeoutA.signal : timeoutB.signal)); + + const client = createGitHubClient("test-token"); + const receivedSignals: AbortSignal[] = []; + const mockFetch = vi.fn().mockImplementation( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const sig = init!.signal!; + receivedSignals.push(sig); + sig.addEventListener("abort", () => reject(sig.reason), { once: true }); + }) + ); + vi.stubGlobal("fetch", mockFetch); + + try { + // Caller abort propagates through the fallback-composed signal + const caller = new AbortController(); + const pendingCaller = client.graphql("{ viewer { login } }", { + request: { signal: caller.signal }, + }); + // plugin-throttling's write group spaces GraphQL starts by up to 1s + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 2_500 }); + expect(receivedSignals[0]).not.toBe(caller.signal); + expect(receivedSignals[0]).not.toBe(timeoutA.signal); + caller.abort(new DOMException("The operation was aborted.", "AbortError")); + await expect(pendingCaller).rejects.toThrow(/aborted/i); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Per-attempt timeout propagates while the caller signal stays live + const caller2 = new AbortController(); + const removeSpy = vi.spyOn(caller2.signal, "removeEventListener"); + const pendingTimeout = client.graphql("{ viewer { login } }", { + request: { signal: caller2.signal, retries: 0 }, + }); + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 2_500 }); + timeoutB.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError")); + await expect(pendingTimeout).rejects.toThrow(/timeout/i); + expect(caller2.signal.aborted).toBe(false); + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(timeoutSpy).toHaveBeenCalledWith(REQUEST_TIMEOUT_MS); + } finally { + timeoutSpy.mockRestore(); + if (anyDescriptor) Object.defineProperty(AbortSignal, "any", anyDescriptor); + vi.unstubAllGlobals(); + } + }, 10_000); + + it("returns an already-aborted signal without AbortSignal.any when an input signal is already aborted", async () => { + const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any"); + Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true, writable: true }); + const timeoutController = new AbortController(); + const timeoutReason = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + timeoutController.abort(timeoutReason); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + + const client = createGitHubClient("test-token"); + const caller = new AbortController(); + const removeSpy = vi.spyOn(caller.signal, "removeEventListener"); + let receivedSignal: AbortSignal | undefined; + const mockFetch = vi.fn().mockImplementation((_url: RequestInfo | URL, init?: RequestInit) => { + receivedSignal = init?.signal ?? undefined; + return Promise.reject(receivedSignal?.reason); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await expect( + client.graphql("{ viewer { login } }", { request: { signal: caller.signal, retries: 0 } }) + ).rejects.toThrow(/timeout/i); + expect(receivedSignal?.aborted).toBe(true); + expect(receivedSignal?.reason).toBe(timeoutReason); + // The listener attached to the still-live caller signal is detached immediately + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(caller.signal.aborted).toBe(false); + } finally { + timeoutSpy.mockRestore(); + if (anyDescriptor) Object.defineProperty(AbortSignal, "any", anyDescriptor); + vi.unstubAllGlobals(); + } + }, 10_000); + + it("rejects /graphql network and timeout errors with undefined response and without plugin-throttling headers TypeError", async () => { + const client = createGitHubClient("test-token"); + + try { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("Failed to fetch")) + ); + + let caughtNetworkErr: unknown; + try { + await client.graphql("{ viewer { login } }", { request: { retries: 0 } }); + } catch (err) { + caughtNetworkErr = err; + } + + expect(caughtNetworkErr).toBeInstanceOf(Error); + expect((caughtNetworkErr as Error).message).toMatch(/Failed to fetch/); + expect((caughtNetworkErr as Error).message).not.toMatch( + /Cannot read properties of undefined/ + ); + expect((caughtNetworkErr as { response?: unknown }).response).toBeUndefined(); + + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue( + new DOMException("The operation was aborted due to timeout", "TimeoutError") + ) + ); + + let caughtTimeoutErr: unknown; + try { + await client.graphql("{ viewer { login } }", { request: { retries: 0 } }); + } catch (err) { + caughtTimeoutErr = err; + } + + expect(caughtTimeoutErr).toBeInstanceOf(Error); + expect((caughtTimeoutErr as Error).message).toMatch(/timeout/i); + expect((caughtTimeoutErr as Error).message).not.toMatch( + /Cannot read properties of undefined/ + ); + expect((caughtTimeoutErr as { response?: unknown }).response).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); }); // ── cachedRequest ──────────────────────────────────────────────────────────── @@ -1103,3 +1300,422 @@ describe("fetchRateLimitDetails — signal updates with client override", () => expect(mockClient.request).toHaveBeenCalledTimes(1); }); }); + +// ── createGitHubClient — retryCount quadratic backoff ──────────────────────── +// Placed at the end of the file so fake-timer Date.now() advancement does not +// shift the shared Bottleneck group's _nextRequest ahead of subsequent real-timer tests. + +describe("createGitHubClient — retryCount quadratic backoff", () => { + it("preserves options.request.retryCount across /graphql retries with quadratic backoff (1s then 4s)", async () => { + vi.useFakeTimers(); + const client = createGitHubClient("test-token"); + const mockFetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", mockFetch); + + let promise: Promise | undefined; + try { + // Advance fake clock past any residual Bottleneck write-limiter minTime (1000ms) + // recorded by preceding real-timer /graphql tests. + await vi.advanceTimersByTimeAsync(2_000); + + let caughtErr: unknown; + promise = client + .graphql("{ viewer { login } }", { request: { retries: 2 } }) + .catch((err: unknown) => { + caughtErr = err; + }); + + // Initial attempt runs at t=0 + await vi.advanceTimersByTimeAsync(50); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Retry 1 (retryCount 0 -> 1) runs after 1s quadratic backoff + Bottleneck write spacing + await vi.advanceTimersByTimeAsync(1_500); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify retry 2 has NOT fired 1.5s after retry 1 (would have fired at +1s if retryCount was reset to 0) + await vi.advanceTimersByTimeAsync(1_000); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Advance remaining time for retry 2's 4s quadratic backoff (retryCount 1 -> 2) + await vi.advanceTimersByTimeAsync(3_500); + await promise; + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(caughtErr).toBeInstanceOf(Error); + expect((caughtErr as Error).message).toMatch(/Failed to fetch/); + expect((caughtErr as { response?: unknown }).response).toBeUndefined(); + } finally { + if (promise) { + await vi.advanceTimersByTimeAsync(10_000); + await promise; + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); +}); + +// ── createGitHubClient — preThrottlingPlugin abort & rate-limit retry bounds ─ +// Placed at the end of the file after retryCount quadratic backoff so fake-timer +// clock advancement on Bottleneck's shared limiters does not affect real-timer suites. + +describe("createGitHubClient — preThrottlingPlugin abort handling and rate-limit retry bounds", () => { + // Maintain a monotonically increasing fake-clock cursor across tests in this suite + // so Bottleneck's module-scoped limiters (_nextRequest shifted by earlier fake-timer tests) + // never see Date.now() move backwards when a new test starts. + let fakeTimeCursor = Date.now() + 30_000; + + async function settleWithTimers( + promise: Promise, + advanceMs = 2_000 + ): Promise<{ value?: T; error?: unknown }> { + let settled: { value?: T; error?: unknown } = {}; + const wrapped = (async () => { + try { + settled = { value: await promise }; + } catch (error) { + settled = { error }; + } + })(); + await vi.advanceTimersByTimeAsync(advanceMs); + await wrapped; + return settled; + } + + beforeEach(() => { + clearNotifications(); + }); + + afterEach(() => { + fakeTimeCursor = Math.max(fakeTimeCursor, Date.now()) + 10_000; + clearNotifications(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("exports MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC === 15", () => { + expect(MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC).toBe(15); + }); + + it("rejects immediately without calling fetch when client.graphql is called with an already-aborted AbortSignal", async () => { + vi.useFakeTimers(); + vi.setSystemTime(fakeTimeCursor); + await vi.advanceTimersByTimeAsync(2_000); + + const client = createGitHubClient("test-token"); + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + // 1. Default abort reason (DOMException AbortError) + const controller1 = new AbortController(); + controller1.abort(); + const reqOpts1: { signal: AbortSignal; retries?: number } = { + signal: controller1.signal, + retries: 2, + }; + + const { error: caught1 } = await settleWithTimers( + client.graphql("{ viewer { login } }", { request: reqOpts1 }), + 2_000 + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(caught1).toBeInstanceOf(Error); + expect((caught1 as Error).name).toBe("AbortError"); + expect((caught1 as { response?: unknown }).response).toBeUndefined(); + + // 2. Custom Error abort reason + const controller2 = new AbortController(); + const customReason = new Error("Custom caller timeout"); + controller2.abort(customReason); + const reqOpts2: { signal: AbortSignal; retries?: number } = { + signal: controller2.signal, + retries: 2, + }; + + const { error: caught2 } = await settleWithTimers( + client.graphql("{ viewer { login } }", { request: reqOpts2 }), + 2_000 + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(caught2).toBe(customReason); + expect((caught2 as { response?: unknown }).response).toBeUndefined(); + + // 3. Non-Error primitive abort reason falls back to DOMException AbortError + const controller3 = new AbortController(); + controller3.abort("non-error-reason"); + const reqOpts3: { signal: AbortSignal; retries?: number } = { + signal: controller3.signal, + retries: 2, + }; + + const { error: caught3 } = await settleWithTimers( + client.graphql("{ viewer { login } }", { request: reqOpts3 }), + 2_000 + ); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(caught3).toBeInstanceOf(DOMException); + expect((caught3 as DOMException).name).toBe("AbortError"); + expect((caught3 as { response?: unknown }).response).toBeUndefined(); + }); + + it("aborts a second concurrent client.graphql request queued behind Bottleneck's 1000ms state.write spacing without invoking fetch for the second call", async () => { + vi.useFakeTimers(); + vi.setSystemTime(fakeTimeCursor); + await vi.advanceTimersByTimeAsync(2_000); + + const client = createGitHubClient("test-token"); + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const reqOpts2: { signal: AbortSignal; retries?: number } = { + signal: controller2.signal, + retries: 2, + }; + + const mockFetch = vi.fn().mockImplementation(async () => { + // Abort req2 while req1 is executing fetch and req2 is queued in Bottleneck's 1000ms state.write limiter + controller2.abort(); + return new Response( + JSON.stringify({ data: { viewer: { login: "octocat" } } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + vi.stubGlobal("fetch", mockFetch); + + const req1 = client.graphql("{ viewer { login } }", { + request: { signal: controller1.signal }, + }); + + let caught2: unknown; + const req2 = client + .graphql("{ viewer { login } }", { + request: reqOpts2, + }) + .catch((err: unknown) => { + caught2 = err; + }); + + // Advance past Bottleneck's 1000ms state.write minTime spacing while both promises are pending + await vi.advanceTimersByTimeAsync(2_500); + await Promise.all([req1, req2]); + + // Fetch was only invoked for req1; req2 was aborted in preThrottlingPlugin before fetch + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(caught2).toBeInstanceOf(Error); + expect((caught2 as Error).name).toBe("AbortError"); + expect((caught2 as { response?: unknown }).response).toBeUndefined(); + }); + + it("onRateLimit retries once when retryAfter <= MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC and signal is not aborted, and skips inline retry when retryAfter > 15s or signal is aborted", async () => { + vi.useFakeTimers(); + vi.setSystemTime(fakeTimeCursor); + await vi.advanceTimersByTimeAsync(2_000); + + const client = createGitHubClient("test-token"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // Case 1: retryAfter <= 15s (e.g. reset = now, so retryAfter = 1s) and !signal.aborted -> retries once and succeeds + let callCount1 = 0; + const mockFetch1 = vi.fn().mockImplementation(async () => { + callCount1++; + if (callCount1 === 1) { + const resetNow = Math.floor(Date.now() / 1000); + return new Response( + JSON.stringify({ message: "API rate limit exceeded" }), + { + status: 403, + headers: { + "content-type": "application/json", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetNow), + }, + } + ); + } + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", mockFetch1); + + const { value: res1, error: err1 } = await settleWithTimers( + client.request("GET /user"), + 3_000 + ); + expect(err1).toBeUndefined(); + expect(res1?.status).toBe(200); + expect(mockFetch1).toHaveBeenCalledTimes(2); + expect(getNotifications().some((n) => n.source === "rate-limit" && /Rate limit hit/.test(n.message))).toBe(true); + + clearNotifications(); + + // Case 2: retryAfter > 15s (e.g. reset = now + 60s, so retryAfter = 61s) -> pushes warning but returns false (no inline retry) + const reset60 = Math.floor(Date.now() / 1000) + 60; + const mockFetch2 = vi.fn().mockImplementation(async () => + new Response( + JSON.stringify({ message: "API rate limit exceeded" }), + { + status: 403, + headers: { + "content-type": "application/json", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(reset60), + }, + } + ) + ); + vi.stubGlobal("fetch", mockFetch2); + + const { error: err2 } = await settleWithTimers( + client.request("GET /user"), + 2_000 + ); + expect(err2).toBeInstanceOf(Error); + expect((err2 as Error).message).toMatch(/API rate limit exceeded/); + expect(mockFetch2).toHaveBeenCalledTimes(1); + expect(getNotifications().some((n) => n.source === "rate-limit" && /Rate limit hit — retrying in 61s/.test(n.message))).toBe(true); + + clearNotifications(); + + // Case 3: retryAfter <= 15s, but caller signal aborts before onRateLimit runs -> returns false (no inline retry) + const controller3 = new AbortController(); + const mockFetch3 = vi.fn().mockImplementation(async () => { + controller3.abort(); + const resetNow = Math.floor(Date.now() / 1000); + return new Response( + JSON.stringify({ message: "API rate limit exceeded" }), + { + status: 403, + headers: { + "content-type": "application/json", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetNow), + }, + } + ); + }); + vi.stubGlobal("fetch", mockFetch3); + + const { error: err3 } = await settleWithTimers( + client.request("GET /user", { request: { signal: controller3.signal } }), + 2_000 + ); + expect(err3).toBeInstanceOf(Error); + expect((err3 as Error).message).toMatch(/API rate limit exceeded/); + expect(mockFetch3).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it("onSecondaryRateLimit retries once when retryAfter <= MAX_INLINE_RATE_LIMIT_RETRY_AFTER_SEC and signal is not aborted, and skips inline retry when retryAfter > 15s or signal is aborted", async () => { + vi.useFakeTimers(); + vi.setSystemTime(fakeTimeCursor); + await vi.advanceTimersByTimeAsync(2_000); + + const client = createGitHubClient("test-token"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // Case 1: retryAfter = 5s (<= 15s) and !signal.aborted -> retries once and succeeds + let callCount1 = 0; + const mockFetch1 = vi.fn().mockImplementation(async () => { + callCount1++; + if (callCount1 === 1) { + return new Response( + JSON.stringify({ message: "You have exceeded a secondary rate limit" }), + { + status: 403, + headers: { + "content-type": "application/json", + "retry-after": "5", + }, + } + ); + } + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", mockFetch1); + + const { value: res1, error: err1 } = await settleWithTimers( + client.request("GET /user"), + 6_000 + ); + expect(err1).toBeUndefined(); + expect(res1?.status).toBe(200); + expect(mockFetch1).toHaveBeenCalledTimes(2); + expect( + getNotifications().some( + (n) => n.source === "rate-limit" && /Secondary rate limit — retrying in 5s/.test(n.message) + ) + ).toBe(true); + + clearNotifications(); + + // Case 2: retryAfter = 60s (> 15s) -> pushes warning but returns false (no 60s inline sleep) + const mockFetch2 = vi.fn().mockImplementation(async () => + new Response( + JSON.stringify({ message: "You have exceeded a secondary rate limit" }), + { + status: 403, + headers: { + "content-type": "application/json", + "retry-after": "60", + }, + } + ) + ); + vi.stubGlobal("fetch", mockFetch2); + + const { error: err2 } = await settleWithTimers( + client.request("GET /user"), + 2_000 + ); + expect(err2).toBeInstanceOf(Error); + expect((err2 as Error).message).toMatch(/secondary rate limit/i); + expect(mockFetch2).toHaveBeenCalledTimes(1); + expect( + getNotifications().some( + (n) => n.source === "rate-limit" && /Secondary rate limit — retrying in 60s/.test(n.message) + ) + ).toBe(true); + + clearNotifications(); + + // Case 3: retryAfter = 5s (<= 15s), but caller signal aborts before onSecondaryRateLimit runs -> returns false + const controller3 = new AbortController(); + const mockFetch3 = vi.fn().mockImplementation(async () => { + controller3.abort(); + return new Response( + JSON.stringify({ message: "You have exceeded a secondary rate limit" }), + { + status: 403, + headers: { + "content-type": "application/json", + "retry-after": "5", + }, + } + ); + }); + vi.stubGlobal("fetch", mockFetch3); + + const { error: err3 } = await settleWithTimers( + client.request("GET /user", { request: { signal: controller3.signal } }), + 2_000 + ); + expect(err3).toBeInstanceOf(Error); + expect((err3 as Error).message).toMatch(/secondary rate limit/i); + expect(mockFetch3).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts index 27afb998..03450fb8 100644 --- a/tests/services/hot-poll.test.ts +++ b/tests/services/hot-poll.test.ts @@ -143,7 +143,7 @@ describe("fetchHotPRStatus", () => { state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: "APPROVED", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -164,7 +164,7 @@ describe("fetchHotPRStatus", () => { state: "OPEN", mergeStateStatus: "DIRTY", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -180,7 +180,7 @@ describe("fetchHotPRStatus", () => { state: "OPEN", mergeStateStatus: "UNSTABLE", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -196,7 +196,7 @@ describe("fetchHotPRStatus", () => { state: "OPEN", mergeStateStatus: "UNSTABLE", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -218,7 +218,7 @@ describe("fetchHotPRStatus", () => { autoMergeRequest: { mergeMethod: "REBASE" }, viewerLatestReview: { state: "APPROVED" }, viewerLatestReviewRequest: { id: "RR_kwDOtest46" }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 47, @@ -228,7 +228,7 @@ describe("fetchHotPRStatus", () => { autoMergeRequest: null, viewerLatestReview: null, viewerLatestReviewRequest: null, - commits: { nodes: [] }, + statusCheckRollup: null, }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, @@ -257,7 +257,7 @@ describe("fetchHotPRStatus", () => { isDraft: false, mergeStateStatus: "CLEAN", reviewDecision: "REVIEW_REQUIRED", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, { databaseId: 49, @@ -265,14 +265,14 @@ describe("fetchHotPRStatus", () => { isDraft: true, mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [] }, + statusCheckRollup: null, }, { databaseId: 50, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [] }, + statusCheckRollup: null, }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, @@ -286,6 +286,71 @@ describe("fetchHotPRStatus", () => { expect(results.get(49)!.draft).toBe(true); expect(results.get(50)!.draft).toBeUndefined(); }); + + it("parses direct node.statusCheckRollup.state, propagates node.updatedAt into HotPRStatusUpdate, and maps null/missing node.statusCheckRollup to a null checkStatus", async () => { + const octokit = makeOctokit(undefined, () => Promise.resolve({ + nodes: [ + { + databaseId: 51, + state: "OPEN", + updatedAt: "2026-04-20T12:34:56Z", + mergeStateStatus: "CLEAN", + reviewDecision: "APPROVED", + statusCheckRollup: { state: "SUCCESS" }, + }, + { + databaseId: 52, + state: "OPEN", + mergeStateStatus: "CLEAN", + reviewDecision: null, + statusCheckRollup: null, + }, + { + databaseId: 53, + state: "OPEN", + mergeStateStatus: "CLEAN", + reviewDecision: null, + }, + { + databaseId: 54, + state: "OPEN", + mergeStateStatus: "CLEAN", + reviewDecision: null, + statusCheckRollup: null, + }, + { + databaseId: 55, + state: "OPEN", + mergeStateStatus: "CLEAN", + reviewDecision: null, + statusCheckRollup: { state: "FAILURE" }, + }, + ], + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, + })); + + const { results, hadErrors } = await fetchHotPRStatus(octokit as never, [ + "PR_node51", + "PR_node52", + "PR_node53", + "PR_node54", + "PR_node55", + ]); + expect(hadErrors).toBe(false); + + const calledQuery = octokit.graphql.mock.calls[0][0] as string; + expect(calledQuery).toContain("statusCheckRollup { state }"); + expect(calledQuery).toContain("updatedAt"); + expect(calledQuery).not.toContain("commits(last: 1)"); + + expect(results.get(51)!.checkStatus).toBe("success"); + expect(results.get(51)!.updatedAt).toBe("2026-04-20T12:34:56Z"); + expect(results.get(52)!.checkStatus).toBeNull(); + expect(results.get(52)!.updatedAt).toBeUndefined(); + expect(results.get(53)!.checkStatus).toBeNull(); + expect(results.get(54)!.checkStatus).toBeNull(); + expect(results.get(55)!.checkStatus).toBe("failure"); + }); }); describe("fetchWorkflowRunById", () => { @@ -391,6 +456,27 @@ describe("rebuildHotSets", () => { expect(calledIds).toContain("PR_a"); }); + it("populates hot PRs when checkStatusStale is true even if checkStatus is null", async () => { + const octokit = makeOctokit(undefined, () => Promise.resolve({ + nodes: [], + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, + })); + mockGetClient.mockReturnValue(octokit); + + rebuildHotSets({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 1, checkStatus: null, checkStatusStale: true, enriched: true, nodeId: "PR_stale" }), + makePullRequest({ id: 2, checkStatus: null, checkStatusStale: false, enriched: true, nodeId: "PR_not_stale" }), + ], + }); + + await fetchHotData(); + expect(octokit.graphql).toHaveBeenCalledTimes(1); + const calledIds = (octokit.graphql.mock.calls[0][1] as { ids: string[] }).ids; + expect(calledIds).toEqual(["PR_stale"]); + }); + it("populates hot runs for queued/in_progress, skips completed", async () => { const requestFn = vi.fn(() => Promise.resolve({ data: { id: 1, status: "in_progress", conclusion: null, updated_at: "2026-01-01T00:00:00Z", completed_at: null }, @@ -498,7 +584,7 @@ describe("fetchHotData", () => { state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: "APPROVED", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -530,7 +616,7 @@ describe("fetchHotData", () => { isDraft: false, mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + statusCheckRollup: null, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -1079,7 +1165,7 @@ describe("fetchHotPRStatus null/missing nodes", () => { state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, @@ -1093,8 +1179,8 @@ describe("fetchHotPRStatus null/missing nodes", () => { it("skips nodes with null databaseId", async () => { const octokit = makeOctokit(undefined, () => Promise.resolve({ nodes: [ - { databaseId: null, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [] } }, - { databaseId: 77, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] } }, + { databaseId: null, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: null }, + { databaseId: 77, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "PENDING" } }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -1113,7 +1199,7 @@ describe("fetchHotPRStatus edge cases", () => { state: "OPEN", mergeStateStatus: "BEHIND", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + statusCheckRollup: { state: "SUCCESS" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -1124,7 +1210,9 @@ describe("fetchHotPRStatus edge cases", () => { it("returns partial results and hadErrors when one batch fails", async () => { let callCount = 0; - const octokit = makeOctokit(undefined, () => { + const batchSizes: number[] = []; + const octokit = makeOctokit(undefined, (...args: unknown[]) => { + batchSizes.push((args[1] as { ids: string[] }).ids.length); callCount++; if (callCount === 1) { return Promise.resolve({ @@ -1133,7 +1221,7 @@ describe("fetchHotPRStatus edge cases", () => { state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, }); @@ -1141,15 +1229,65 @@ describe("fetchHotPRStatus edge cases", () => { return Promise.reject(new Error("rate limited")); }); - // Need >100 node IDs to trigger 2 batches + // Hot status batches use HEAVY_BACKFILL_BATCH_SIZE (25), matching the heavy backfill const nodeIds = Array.from({ length: 101 }, (_, i) => `PR_${i}`); const { results, hadErrors } = await fetchHotPRStatus(octokit as never, nodeIds); - // First batch succeeded with 1 result, second batch failed + expect(batchSizes).toEqual([25, 25, 25, 25, 1]); + // First batch succeeded with 1 result, the remaining batches failed expect(results.size).toBe(1); expect(results.get(1)).toBeDefined(); expect(hadErrors).toBe(true); }); + it("uses the fork head commit's rollup for cross-repository PRs whose PR-level rollup is null (fork-only CI)", async () => { + const octokit = makeOctokit(undefined, () => Promise.resolve({ + nodes: [ + // Fork-only CI: PR-level rollup null, fork commit FAILURE + { databaseId: 61, state: "OPEN", mergeStateStatus: "UNKNOWN", reviewDecision: null, statusCheckRollup: null, + isCrossRepository: true, headRef: { target: { statusCheckRollup: { state: "FAILURE" } } } }, + // Base-repo CI present: PR-level rollup wins over the fork commit + { databaseId: 62, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "SUCCESS" }, + isCrossRepository: true, headRef: { target: { statusCheckRollup: { state: "FAILURE" } } } }, + // Same-repo PR: headRef rollup is never consulted + { databaseId: 63, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: null, + isCrossRepository: false, headRef: { target: { statusCheckRollup: { state: "FAILURE" } } } }, + // Fork PR with no CI anywhere, or a deleted head branch + { databaseId: 64, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: null, + isCrossRepository: true, headRef: null }, + ], + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, + })); + + const { results } = await fetchHotPRStatus(octokit as never, ["PR_61", "PR_62", "PR_63", "PR_64"]); + const calledQuery = octokit.graphql.mock.calls[0][0] as string; + expect(calledQuery).toContain("isCrossRepository"); + expect(calledQuery).toContain("headRef { target { ... on Commit { statusCheckRollup { state } } } }"); + expect(results.get(61)!.checkStatus).toBe("failure"); + expect(results.get(62)!.checkStatus).toBe("success"); + expect(results.get(63)!.checkStatus).toBeNull(); + expect(results.get(64)!.checkStatus).toBeNull(); + }); + + it("salvages resolved nodes from a rejected batch's partial data and skips errored node indices", async () => { + const octokit = makeOctokit(undefined, () => + Promise.reject(Object.assign(new Error("Something went wrong while executing your query"), { + data: { + nodes: [ + { databaseId: 11, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "SUCCESS" } }, + { databaseId: 12, state: "OPEN", mergeStateStatus: "UNKNOWN", reviewDecision: null, statusCheckRollup: null }, + ], + rateLimit: { limit: 5000, remaining: 4990, resetAt: "2026-01-01T00:00:00Z" }, + }, + errors: [{ message: "timedout", path: ["nodes", 1, "statusCheckRollup"] }], + }))); + + const { results, hadErrors } = await fetchHotPRStatus(octokit as never, ["PR_ok", "PR_errored"]); + expect(hadErrors).toBe(true); + expect(results.size).toBe(1); + expect(results.get(11)!.checkStatus).toBe("success"); + expect(results.has(12)).toBe(false); + }); + it("throws (does not swallow into hadErrors) when a batch rejects with 401", async () => { const octokit = makeOctokit(undefined, () => Promise.reject(Object.assign(new Error("Bad credentials"), { status: 401 }))); @@ -1256,8 +1394,8 @@ describe("fetchHotData eviction edge cases", () => { // First fetch: PR 1 resolved (success), PR 2 still pending return Promise.resolve({ nodes: [ - { databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] } }, - { databaseId: 2, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] } }, + { databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "SUCCESS" } }, + { databaseId: 2, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "PENDING" } }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, }); @@ -1265,7 +1403,7 @@ describe("fetchHotData eviction edge cases", () => { // Second fetch: only PR 2 should be queried return Promise.resolve({ nodes: [ - { databaseId: 2, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] } }, + { databaseId: 2, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "SUCCESS" } }, ], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, }); @@ -1301,7 +1439,7 @@ describe("fetchHotData eviction edge cases", () => { state: "MERGED", mergeStateStatus: "CLEAN", reviewDecision: "APPROVED", - commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + statusCheckRollup: { state: "PENDING" }, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -1330,7 +1468,7 @@ describe("fetchHotData eviction edge cases", () => { state: "CLOSED", mergeStateStatus: "CLEAN", reviewDecision: null, - commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + statusCheckRollup: null, }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, })); @@ -1383,7 +1521,7 @@ describe("fetchHotData hadErrors", () => { headers: {}, }), () => Promise.resolve({ - nodes: [{ databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] } }], + nodes: [{ databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "PENDING" } }], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" }, }), ); @@ -1537,7 +1675,7 @@ describe("fetchHotPRStatus updateGraphqlRateLimit", () => { it("calls updateGraphqlRateLimit when response includes rateLimit", async () => { const { updateGraphqlRateLimit } = await import("../../src/app/services/github"); const octokit = makeOctokit(undefined, () => Promise.resolve({ - nodes: [{ databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] } }], + nodes: [{ databaseId: 1, state: "OPEN", mergeStateStatus: "CLEAN", reviewDecision: null, statusCheckRollup: { state: "SUCCESS" } }], rateLimit: { limit: 5000, remaining: 4200, resetAt: "2026-01-01T01:00:00Z" }, })); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index 183df0bd..4c1b46d8 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -30,6 +30,9 @@ vi.mock("../../src/app/services/api", () => ({ fetchIssuesAndPullRequests: vi.fn(), fetchWorkflowRuns: vi.fn(), resetEmptyActionRepos: vi.fn(), + resetPREnrichmentCache: vi.fn(), + invalidatePREnrichmentCacheForRepos: vi.fn(), + setPREnrichmentCacheRefreshInterval: vi.fn(), isUnauthorizedError: (err: unknown) => { if (typeof err !== "object" || err === null) return false; const e = err as { status?: unknown; response?: { status?: unknown } }; @@ -145,7 +148,12 @@ describe("fetchAllData — first call", () => { await fetchAllData(); - expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith(mockOctokit, config.selectedRepos, "octocat", undefined, [], []); + expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith(mockOctokit, config.selectedRepos, "octocat", { + onLightData: undefined, + trackedUsers: [], + monitoredRepos: [], + force: false, + }); expect(fetchWorkflowRuns).toHaveBeenCalledWith( mockOctokit, config.selectedRepos, @@ -369,7 +377,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { // 5th argument to fetchIssuesAndPullRequests should be trackedUsers const callArgs = vi.mocked(fetchIssuesAndPullRequests).mock.calls[0]; - expect(callArgs[4]).toEqual(trackedUsers); + expect(callArgs[3]?.trackedUsers).toEqual(trackedUsers); }); it("empty upstreamRepos and trackedUsers produces identical behavior (backward compat)", async () => { @@ -402,9 +410,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { mockOctokit, selectedRepos, "octocat", - undefined, - [], - [] + { onLightData: undefined, trackedUsers: [], monitoredRepos: [], force: false } ); expect(fetchWorkflowRuns).toHaveBeenCalledWith( mockOctokit, @@ -814,4 +820,100 @@ describe("fetchAllData — parallel execution", () => { for (const resolve of resolvers) resolve(undefined); await promise; }); + + it("passes force: true in the fetchIssuesAndPullRequests options and invalidates repo enrichment cache on fetchTargetedRepoData", async () => { + vi.resetModules(); + + const { getClient } = await import("../../src/app/services/github"); + const { + fetchIssuesAndPullRequests, + fetchWorkflowRuns, + invalidatePREnrichmentCacheForRepos, + } = await import("../../src/app/services/api"); + const { config } = await import("../../src/app/stores/config"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData, fetchTargetedRepoData } = await import("../../src/app/services/poll"); + + await fetchAllData(undefined, true); + expect(fetchIssuesAndPullRequests).toHaveBeenCalledWith( + mockOctokit, + config.selectedRepos, + "octocat", + { onLightData: undefined, trackedUsers: [], monitoredRepos: [], force: true } + ); + + const repoSummaries = new Map([ + [ + "octocat/Hello-World", + { + repoFullName: "octocat/Hello-World", + eventTypes: new Set(["PullRequestEvent"]), + hasIssueActivity: false, + hasPRActivity: true, + hasWorkflowActivity: false, + latestEventAt: new Date().toISOString(), + }, + ], + ]); + + await fetchTargetedRepoData(repoSummaries); + expect(invalidatePREnrichmentCacheForRepos).toHaveBeenCalledWith(["octocat/Hello-World"]); + }); + + it("syncs the enrichment cache TTL from config.refreshInterval before fetching issues/PRs in fetchAllData and fetchTargetedRepoData", async () => { + vi.resetModules(); + + const { getClient } = await import("../../src/app/services/github"); + const { + fetchIssuesAndPullRequests, + fetchWorkflowRuns, + setPREnrichmentCacheRefreshInterval, + } = await import("../../src/app/services/api"); + const { config } = await import("../../src/app/stores/config"); + const mutableConfig = config as { refreshInterval?: number }; + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData, fetchTargetedRepoData } = await import("../../src/app/services/poll"); + + try { + mutableConfig.refreshInterval = 600; + await fetchAllData(); + expect(setPREnrichmentCacheRefreshInterval).toHaveBeenCalledWith(600); + expect(vi.mocked(setPREnrichmentCacheRefreshInterval).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fetchIssuesAndPullRequests).mock.invocationCallOrder[0], + ); + + vi.mocked(setPREnrichmentCacheRefreshInterval).mockClear(); + vi.mocked(fetchIssuesAndPullRequests).mockClear(); + mutableConfig.refreshInterval = 120; + await fetchTargetedRepoData( + new Map([ + [ + "octocat/Hello-World", + { + repoFullName: "octocat/Hello-World", + eventTypes: new Set(["PullRequestEvent"]), + hasIssueActivity: false, + hasPRActivity: true, + hasWorkflowActivity: false, + latestEventAt: new Date().toISOString(), + }, + ], + ]), + ); + expect(setPREnrichmentCacheRefreshInterval).toHaveBeenCalledWith(120); + expect(vi.mocked(setPREnrichmentCacheRefreshInterval).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fetchIssuesAndPullRequests).mock.invocationCallOrder[0], + ); + } finally { + delete mutableConfig.refreshInterval; + } + }); }); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 3c5779cb..c304cf3b 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -1,12 +1,22 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot, createSignal } from "solid-js"; -import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll"; +import { + createPollCoordinator, + processBackfillErrors, + resetPollState, + WAKE_GRACE_MS, + ONLINE_RECOVERY_GRACE_MS, + type DashboardData, +} from "../../src/app/services/poll"; +import { PR_ENRICHMENT_NOTIFICATION_SOURCE, PR_ENRICHMENT_DEGRADED_MESSAGE } from "../../src/shared/types"; import * as githubMod from "../../src/app/services/github"; import * as githubStatusMod from "../../src/app/services/github-status"; +import { makePullRequest } from "../helpers/factories"; -// Mock pushError so we can spy on it +// Mock pushError and pushNotification so we can spy on them const mockPushError = vi.fn(); +const mockPushNotification = vi.fn(); const mockDismissNotificationBySource = vi.fn(); const mockStartCycleTracking = vi.fn(); const mockEndCycleTracking = vi.fn(() => new Set()); @@ -19,7 +29,7 @@ vi.mock("../../src/app/lib/errors", () => ({ dismissNotificationBySource: (source: string) => mockDismissNotificationBySource(source), startCycleTracking: () => mockStartCycleTracking(), endCycleTracking: () => mockEndCycleTracking(), - pushNotification: vi.fn(), + pushNotification: (...args: unknown[]) => mockPushNotification(...args), clearNotifications: vi.fn(), resetNotificationState: vi.fn(), addMutedSource: vi.fn(), @@ -95,13 +105,21 @@ function setDocumentVisible(visible: boolean) { describe("createPollCoordinator", () => { beforeEach(() => { vi.useFakeTimers(); - // Start with document visible + resetPollState(); + // Start with document visible and browser online Object.defineProperty(document, "visibilityState", { value: "visible", writable: true, configurable: true, }); - // Reset new mock functions + Object.defineProperty(navigator, "onLine", { + value: true, + writable: true, + configurable: true, + }); + // Reset mock functions + mockPushError.mockClear(); + mockPushNotification.mockClear(); mockDismissNotificationBySource.mockClear(); mockStartCycleTracking.mockClear(); mockEndCycleTracking.mockClear().mockReturnValue(new Set()); @@ -182,12 +200,15 @@ describe("createPollCoordinator", () => { // Advance time past 2 minutes while hidden vi.advanceTimersByTime(130_000); // 2 min 10 sec - // Restore visibility + // Restore visibility — catch-up waits WAKE_GRACE_MS (2_000ms) setDocumentVisible(true); await flushPromises(); + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); + + vi.advanceTimersByTime(WAKE_GRACE_MS); + await flushPromises(); - // Should have triggered at least a catch-up fetch on re-visible - // (background polls may also have fired if interval < hidden duration) + // Should have triggered a catch-up fetch after WAKE_GRACE_MS expect(fetchAll.mock.calls.length).toBeGreaterThanOrEqual(callsAfterInit + 1); dispose(); }); @@ -239,9 +260,13 @@ describe("createPollCoordinator", () => { // No polls while hidden expect(fetchAll.mock.calls.length).toBe(callsAfterInit); - // Restore visibility — catch-up fetch fires + timer resets + // Restore visibility — timer resets immediately, catch-up fires after WAKE_GRACE_MS setDocumentVisible(true); await flushPromises(); + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); + + vi.advanceTimersByTime(WAKE_GRACE_MS); + await flushPromises(); const callsAfterRevisible = fetchAll.mock.calls.length; expect(callsAfterRevisible).toBeGreaterThan(callsAfterInit); @@ -251,7 +276,7 @@ describe("createPollCoordinator", () => { await Promise.resolve(); expect(fetchAll.mock.calls.length).toBe(callsAfterRevisible); - // Advance another 31s (61s from reset) — timer fires + // Advance another 31s (past 60s from reset) — timer fires vi.advanceTimersByTime(31_000); await flushPromises(); expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterRevisible); @@ -642,4 +667,474 @@ describe("createPollCoordinator", () => { dispose(); }); }); + + it("threads force = false on automatic polls and force = true on manualRefresh()", async () => { + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + + expect(fetchAll).toHaveBeenCalledTimes(1); + expect(fetchAll).toHaveBeenLastCalledWith(false); + + coordinator.manualRefresh(); + await flushPromises(); + + expect(fetchAll).toHaveBeenCalledTimes(2); + expect(fetchAll).toHaveBeenLastCalledWith(true); + dispose(); + }); + }); + + it("cancels pending wake grace timer on re-hide (preserving hiddenAt), manualRefresh(), and destroy()", async () => { + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Hide > 2m, become visible, then re-hide within WAKE_GRACE_MS + setDocumentVisible(false); + vi.advanceTimersByTime(130_000); + setDocumentVisible(true); + vi.advanceTimersByTime(500); + setDocumentVisible(false); + + // Advancing past WAKE_GRACE_MS while hidden does not fire catch-up + vi.advanceTimersByTime(WAKE_GRACE_MS + 1_000); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Becoming visible again still remembers the >2m hiddenAt obligation + setDocumentVisible(true); + vi.advanceTimersByTime(WAKE_GRACE_MS); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(2); + + // manualRefresh() during wake grace window cancels pending wake timer + setDocumentVisible(false); + vi.advanceTimersByTime(130_000); + setDocumentVisible(true); + vi.advanceTimersByTime(500); + coordinator.manualRefresh(); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(3); + expect(fetchAll).toHaveBeenLastCalledWith(true); + + // Elapsing the remainder of WAKE_GRACE_MS must NOT fire a duplicate fetch + vi.advanceTimersByTime(WAKE_GRACE_MS + 500); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(3); + + // destroy() during wake grace window cancels pending wake timer + setDocumentVisible(false); + vi.advanceTimersByTime(130_000); + setDocumentVisible(true); + vi.advanceTimersByTime(500); + coordinator.destroy(); + vi.advanceTimersByTime(WAKE_GRACE_MS + 500); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(3); + + dispose(); + }); + }); + + it("skips automatic polls and wake catch-up while navigator.onLine === false and recovers after ONLINE_RECOVERY_GRACE_MS on 'online' event", async () => { + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(60), fetchAll); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Go offline + Object.defineProperty(navigator, "onLine", { + value: false, + writable: true, + configurable: true, + }); + + // Interval tick while offline is skipped + vi.advanceTimersByTime(95_000); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Wake catch-up while offline is also skipped + setDocumentVisible(false); + vi.advanceTimersByTime(130_000); + setDocumentVisible(true); + vi.advanceTimersByTime(WAKE_GRACE_MS); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Restore online state and fire window 'online' event + Object.defineProperty(navigator, "onLine", { + value: true, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event("online")); + + // Does not fire before ONLINE_RECOVERY_GRACE_MS (1_500ms) + vi.advanceTimersByTime(ONLINE_RECOVERY_GRACE_MS - 100); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(2); + + // Manual refresh (force = true) still runs even if navigator.onLine is false + Object.defineProperty(navigator, "onLine", { + value: false, + writable: true, + configurable: true, + }); + coordinator.manualRefresh(); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(3); + + dispose(); + }); + }); + + it("implements 3-Tier backfill-batch-* escalation, retains Tier 1 across search failures only while PRs are degraded, and auto-dismisses on clean poll", async () => { + const enrichedPR = makePullRequest({ id: 1, enriched: true, checkStatus: "success" }); + const unenrichedPR = makePullRequest({ id: 2, enriched: false, checkStatus: null }); + const backfillErr = { + repo: "backfill-batch-1/1", + statusCode: null, + message: "Failed to fetch", + retryable: true, + }; + + let nextResult: DashboardData = { + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [backfillErr], + }; + const fetchAll = vi.fn(async () => nextResult); + + await createRoot(async (dispose) => { + // Poll 1 (failure #1 with all PRs enriched via cache/fallback): Tier 0 silent + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + + expect(mockPushError).not.toHaveBeenCalled(); + expect(mockPushNotification).not.toHaveBeenCalled(); + + // Poll 2 (failure #2 with all PRs enriched): Tier 0 silent + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).not.toHaveBeenCalled(); + expect(mockPushNotification).not.toHaveBeenCalled(); + + // Poll 3 (failure #3 consecutive): escalates to Tier 1 silentToast warning + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).not.toHaveBeenCalled(); + expect(mockPushNotification).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true } + ); + + // Poll 4: light-search total failure (pullRequests: [], errors: [light-batch-1/1]) + // Surfaces 'light-batch-1/1' via pushError; with no degraded PRs on screen the + // 'pr-enrichment' warning is not re-pushed (cycle tracking then dismisses it) + mockPushNotification.mockClear(); + mockPushError.mockClear(); + mockGetNotifications.mockReturnValue([ + { + id: "n-pr", + source: PR_ENRICHMENT_NOTIFICATION_SOURCE, + message: PR_ENRICHMENT_DEGRADED_MESSAGE, + timestamp: Date.now(), + retryable: true, + severity: "warning", + read: false, + silentToast: true, + }, + ]); + mockEndCycleTracking.mockReturnValue( + new Set(["light-batch-1/1", PR_ENRICHMENT_NOTIFICATION_SOURCE]) + ); + nextResult = { + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [{ repo: "light-batch-1/1", statusCode: 500, message: "Server Error", retryable: true }], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).toHaveBeenCalledWith("light-batch-1/1", "Server Error", true); + expect(mockPushNotification).not.toHaveBeenCalled(); + + // Partial light-search failure with only enriched PRs: warning not re-pushed either + mockPushError.mockClear(); + nextResult = { + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [{ repo: "light-batch-2/2", statusCode: 502, message: "Bad Gateway", retryable: true }], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).toHaveBeenCalledWith("light-batch-2/2", "Bad Gateway", true); + expect(mockPushNotification).not.toHaveBeenCalled(); + + // Partial light-search failure that leaves a degraded PR: retains Tier 1 'pr-enrichment' + mockPushNotification.mockClear(); + mockPushError.mockClear(); + nextResult = { + issues: [], + pullRequests: [enrichedPR, unenrichedPR], + workflowRuns: [], + errors: [{ repo: "light-batch-2/2", statusCode: 502, message: "Bad Gateway", retryable: true }], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).toHaveBeenCalledWith("light-batch-2/2", "Bad Gateway", true); + expect(mockPushNotification).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true } + ); + expect(mockDismissNotificationBySource).not.toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE + ); + + // Poll 5: clean poll with enriched PRs resets streak and auto-dismisses 'pr-enrichment' + mockPushNotification.mockClear(); + mockPushError.mockClear(); + mockEndCycleTracking.mockReturnValue(new Set()); + nextResult = { + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushNotification).not.toHaveBeenCalled(); + expect(mockDismissNotificationBySource).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE + ); + + // Poll 6: cold-start failure #1 with an unenriched PR (enriched: false) escalates immediately to Tier 1 + mockGetNotifications.mockReturnValue([]); + mockDismissNotificationBySource.mockClear(); + nextResult = { + issues: [], + pullRequests: [unenrichedPR], + workflowRuns: [], + errors: [backfillErr], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushError).not.toHaveBeenCalled(); + expect(mockPushNotification).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true } + ); + + // Poll 7: reset streak, then verify failure #1 with checkStatusStale: true also escalates immediately to Tier 1 + resetPollState(); + mockPushNotification.mockClear(); + const stalePR = makePullRequest({ + id: 3, + repoFullName: "owner/repo", + enriched: true, + checkStatus: null, + checkStatusStale: true, + }); + nextResult = { + issues: [], + pullRequests: [stalePR], + workflowRuns: [], + errors: [backfillErr], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushNotification).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true } + ); + + // Poll 8: reset streak, then verify failure #1 with a non-retryable backfill error escalates immediately to Tier 1 + resetPollState(); + mockPushNotification.mockClear(); + nextResult = { + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [{ ...backfillErr, statusCode: 403, retryable: false }], + }; + coordinator.manualRefresh(); + await flushPromises(); + expect(mockPushNotification).toHaveBeenCalledWith( + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true } + ); + + dispose(); + }); + }); + + it("defers online recovery fetch when tab is hidden and catches up after WAKE_GRACE_MS on tab focus", async () => { + await createRoot(async (dispose) => { + const fetchAll = vi.fn().mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + const coordinator = createPollCoordinator(() => 300, fetchAll); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Go offline and trigger an interval tick while offline so the 'online' listener registers + Object.defineProperty(navigator, "onLine", { + value: false, + writable: true, + configurable: true, + }); + vi.advanceTimersByTime(360_000); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Hide tab (< 2m) before online recovery fires + setDocumentVisible(false); + Object.defineProperty(navigator, "onLine", { + value: true, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event("online")); + vi.advanceTimersByTime(ONLINE_RECOVERY_GRACE_MS); + await flushPromises(); + // Should NOT fetch while hidden, but should backdate hiddenAt + expect(fetchAll).toHaveBeenCalledTimes(1); + + // Become visible 5 seconds later (< 2m since last hide, but backdated by online recovery) + vi.advanceTimersByTime(5_000); + setDocumentVisible(true); + vi.advanceTimersByTime(WAKE_GRACE_MS); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(2); + + // Go offline again, fire 'online', then flap back to offline before ONLINE_RECOVERY_GRACE_MS elapses + Object.defineProperty(navigator, "onLine", { + value: false, + writable: true, + configurable: true, + }); + vi.advanceTimersByTime(360_000); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new Event("online")); + vi.advanceTimersByTime(ONLINE_RECOVERY_GRACE_MS); + await flushPromises(); + // Still offline -> did not fetch, but re-registered 'online' listener + expect(fetchAll).toHaveBeenCalledTimes(2); + + // Now genuinely come back online -> second 'online' event triggers recovery fetch + Object.defineProperty(navigator, "onLine", { + value: true, + writable: true, + configurable: true, + }); + window.dispatchEvent(new Event("online")); + vi.advanceTimersByTime(ONLINE_RECOVERY_GRACE_MS); + await flushPromises(); + expect(fetchAll).toHaveBeenCalledTimes(3); + + coordinator.destroy(); + dispose(); + }); + }); +}); + +describe("processBackfillErrors", () => { + const backfillErr = { repo: "backfill-batch-1/1", statusCode: null, message: "Failed to fetch", retryable: true }; + const enrichedPR = makePullRequest({ id: 1, enriched: true, checkStatus: "success" }); + const degradedArgs = [ + PR_ENRICHMENT_NOTIFICATION_SOURCE, + PR_ENRICHMENT_DEGRADED_MESSAGE, + "warning", + true, + undefined, + { silentToast: true }, + ] as const; + + beforeEach(() => { + resetPollState(); + mockPushNotification.mockClear(); + mockGetNotifications.mockClear().mockReturnValue([]); + }); + + it("resets the consecutive-failure streak on a clean poll", () => { + processBackfillErrors([backfillErr], [enrichedPR]); + processBackfillErrors([backfillErr], [enrichedPR]); + expect(processBackfillErrors([], [enrichedPR])).toEqual([]); + // Without the reset this would be failure #3 and escalate. + processBackfillErrors([backfillErr], [enrichedPR]); + expect(mockPushNotification).not.toHaveBeenCalled(); + }); + + it("keeps the streak on an unfiltered-batch search error and re-pushes an active warning only while a PR is degraded", () => { + const stalePR = makePullRequest({ id: 2, enriched: true, checkStatus: null, checkStatusStale: true }); + processBackfillErrors([backfillErr], [enrichedPR]); + processBackfillErrors([backfillErr], [enrichedPR]); + expect(mockPushNotification).not.toHaveBeenCalled(); + + mockGetNotifications.mockReturnValue([ + { + id: "n-pr", + source: PR_ENRICHMENT_NOTIFICATION_SOURCE, + message: PR_ENRICHMENT_DEGRADED_MESSAGE, + timestamp: Date.now(), + retryable: true, + severity: "warning", + read: false, + silentToast: true, + }, + ]); + const unfilteredErr = { repo: "unfiltered-batch-1/1", statusCode: 502, message: "Bad Gateway", retryable: true }; + // Every PR fully enriched: the active warning is not kept alive by the search error + expect(processBackfillErrors([unfilteredErr], [enrichedPR])).toEqual([unfilteredErr]); + expect(mockPushNotification).not.toHaveBeenCalled(); + // A degraded PR on screen: the active warning is re-pushed + processBackfillErrors([unfilteredErr], [enrichedPR, stalePR]); + expect(mockPushNotification).toHaveBeenCalledWith(...degradedArgs); + + // Streak was kept at 2, so the next backfill failure is #3 and escalates. + mockPushNotification.mockClear(); + mockGetNotifications.mockReturnValue([]); + processBackfillErrors([backfillErr], [enrichedPR]); + expect(mockPushNotification).toHaveBeenCalledWith(...degradedArgs); + }); }); diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index e11ba5c1..641f69d6 100644 --- a/tests/stores/auth.test.ts +++ b/tests/stores/auth.test.ts @@ -138,12 +138,16 @@ describe("clearAuth", () => { expect(localStorageMock.getItem("github-tracker:auth-token")).toBeNull(); }); - it("removes config and view keys from localStorage", () => { + it("removes config, view, dep-meta, and dashboard-bodies keys from localStorage", () => { localStorageMock.setItem("github-tracker:config", "{}"); localStorageMock.setItem("github-tracker:view", "{}"); + localStorageMock.setItem("github-tracker:dep-meta", '{"1":{}}'); + localStorageMock.setItem("github-tracker:dashboard-bodies", '{"DASH_1":{"updatedAt":"2026-04-01T00:00:00Z","deps":[]}}'); mod.clearAuth(); expect(localStorageMock.getItem("github-tracker:config")).toBeNull(); expect(localStorageMock.getItem("github-tracker:view")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dep-meta")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dashboard-bodies")).toBeNull(); }); it("does not call /api/oauth/logout (OAuth App uses permanent tokens)", () => { @@ -186,14 +190,18 @@ describe("expireToken", () => { expect(mod.user()).toBeNull(); }); - it("removes auth token and dashboard cache from localStorage — config, view preserved", () => { + it("removes auth token, dashboard cache, dep-meta, and dashboard-bodies from localStorage — config, view preserved", () => { localStorageMock.setItem("github-tracker:config", '{"theme":"dark"}'); localStorageMock.setItem("github-tracker:view", '{"lastActiveTab":"actions"}'); localStorageMock.setItem("github-tracker:dashboard", '{"cached":true}'); + localStorageMock.setItem("github-tracker:dep-meta", '{"1":{}}'); + localStorageMock.setItem("github-tracker:dashboard-bodies", '{"DASH_1":{"updatedAt":"2026-04-01T00:00:00Z","deps":[]}}'); mod.setAuth({ access_token: "ghs_abc" }); mod.expireToken(); expect(localStorageMock.getItem("github-tracker:auth-token")).toBeNull(); expect(localStorageMock.getItem("github-tracker:dashboard")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dep-meta")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dashboard-bodies")).toBeNull(); expect(localStorageMock.getItem("github-tracker:config")).toBe('{"theme":"dark"}'); expect(localStorageMock.getItem("github-tracker:view")).toBe('{"lastActiveTab":"actions"}'); }); @@ -1471,25 +1479,35 @@ describe("setAuthFromPat — identity switch", () => { expect(mod.token()).toBe("ghp_token2"); }); - it("clears config and view localStorage keys on a genuine identity switch", () => { + it("clears config, view, dep-meta, and dashboard-bodies localStorage keys on a genuine identity switch", () => { mod.setAuthFromPat("ghp_token1", userA); localStorageMock.setItem("github-tracker:config", '{"theme":"dark"}'); localStorageMock.setItem("github-tracker:view", '{"lastActiveTab":"actions"}'); + localStorageMock.setItem("github-tracker:dep-meta", '{"1":{}}'); + localStorageMock.setItem("github-tracker:dashboard-bodies", '{"DASH_1":{"updatedAt":"2026-04-01T00:00:00Z","deps":[]}}'); mod.setAuthFromPat("ghp_token2", userB); expect(localStorageMock.getItem("github-tracker:config")).toBeNull(); expect(localStorageMock.getItem("github-tracker:view")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dep-meta")).toBeNull(); + expect(localStorageMock.getItem("github-tracker:dashboard-bodies")).toBeNull(); }); - it("preserves config and view localStorage keys when replacing a token for the same identity", () => { + it("preserves config, view, dep-meta, and dashboard-bodies localStorage keys when replacing a token for the same identity", () => { mod.setAuthFromPat("ghp_token1", userA); localStorageMock.setItem("github-tracker:config", '{"theme":"dark"}'); localStorageMock.setItem("github-tracker:view", '{"lastActiveTab":"actions"}'); + localStorageMock.setItem("github-tracker:dep-meta", '{"1":{}}'); + localStorageMock.setItem("github-tracker:dashboard-bodies", '{"DASH_1":{"updatedAt":"2026-04-01T00:00:00Z","deps":[]}}'); mod.setAuthFromPat("ghp_token2", userA); expect(localStorageMock.getItem("github-tracker:config")).toBe('{"theme":"dark"}'); expect(localStorageMock.getItem("github-tracker:view")).toBe('{"lastActiveTab":"actions"}'); + expect(localStorageMock.getItem("github-tracker:dep-meta")).toBe('{"1":{}}'); + expect(localStorageMock.getItem("github-tracker:dashboard-bodies")).toBe( + '{"DASH_1":{"updatedAt":"2026-04-01T00:00:00Z","deps":[]}}' + ); }); });