diff --git a/README.md b/README.md index f0fbe089..5388c389 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ Shimmer animations on items being updated by the hot poll, flash highlights when Star counts appear in repo group headers, fetched as part of the standard data refresh. +### Dependencies Tab + +An auto-detected built-in tab that appears when dependency bot PRs are found in your tracked repos. Dependency PRs are identified via a multi-layer bot detection pipeline (PR author login, branch name prefix, configurable label). Items are grouped by status — **Needs Review** (CI passing, not yet approved), **Waiting** (CI pending or draft), and **Stale** (open more than 14 days) — rather than by repo. Abandoned dependency pills link directly to the Renovate Dashboard issue for bulk resolution. Configurable via **Settings > Dependencies**. + ### Custom Tabs Create named filtered views over the existing Issues, PRs, and Actions data. Each custom tab has a name, a base type (Issues, PRs, or Actions), an optional org/repo scope, and optional filter presets. An "exclusive" toggle hides matching items from the standard tabs so they only appear in the custom tab. Up to 10 custom tabs can be created. Manage them via the "+" button in the tab bar or in **Settings > Custom Tabs**. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 82c14bf3..3f239de9 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -28,6 +28,12 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Workflow Grouping](#workflow-grouping) - [Show PR Runs](#show-pr-runs) - [Filters](#actions-filters) +- [Dependencies Tab](#dependencies-tab) + - [Auto-Detection](#auto-detection) + - [Status Grouping](#status-grouping) + - [Abandoned Dependencies](#abandoned-dependencies) + - [Settings](#dependencies-settings) + - [Filters](#dependencies-filters) - [Multi-User Tracking](#multi-user-tracking) - [Monitor-All Mode](#monitor-all-mode) - [Upstream Repos](#upstream-repos) @@ -259,6 +265,60 @@ By default, runs triggered by pull request events are hidden to reduce noise. To --- +## Dependencies Tab + +The Dependencies tab is a built-in tab that groups dependency bot PRs separately from your regular Pull Requests view. It appears automatically when the app detects dependency bot PRs in your tracked repos and is enabled in **Settings > Dependencies**. + +### Auto-Detection + +The tab uses a multi-layer detection pipeline to identify dependency PRs: + +1. **Known bot logins** — PRs from known dependency bots (dependabot[bot], renovate[bot], snyk-bot, depfu[bot], pyup-bot, scala-steward, mend-renovate-bot) are detected automatically. +2. **Tracked bot users** — any user added to your tracked users list with type "bot" in Settings is also detected. +3. **Branch name prefix** — branches starting with `dependabot/`, `renovate/`, `snyk-fix-`, `snyk-upgrade-`, or `pyup-update-` are flagged as dependency updates. +4. **Title pattern** — PR titles matching common dependency update patterns (e.g., "Bump X from Y to Z", "chore(deps): ...", "[Snyk] ...") are detected. +5. **Label match** — PRs with the `dependencies` label are included. + +Dependency PRs claimed by the Dependencies tab are excluded from the standard Pull Requests tab and any custom tabs with exclusivity enabled. The tab title shows the current count of open dependency PRs. + +### Status Grouping + +Unlike the Pull Requests tab (which groups by repo), the Dependencies tab groups items by their update status: + +| Group | Criteria | +|-------|----------| +| **Needs Review** | CI passing (all checks green), PR not yet approved — these are ready to merge | +| **Waiting** | CI pending, checks still running, or PR is a draft — not yet actionable | +| **Stale** | PR has been open more than 14 days without merging — may need a rebase or manual review | + +Within each group, PRs are sorted by updated date (most recent first). + +### 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. + +### Dependencies Settings + +Go to **Settings > Dependencies** to configure: + +| Setting | Default | Description | +|---------|---------|-------------| +| Enable Dependencies tab | On | Show or hide the tab. When disabled, dependency PRs appear in the standard Pull Requests tab. | +| Rebase label | `rebase` | PRs with this label are shown with a "Rebasing" indicator in the Dependencies tab. Change to match the label name your dependency bot uses to signal rebase-needed status. | + +### Dependencies Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Update type | All / Major / Minor / Patch | All | +| Bot | All / (detected bot logins) | All (shown when multiple bots are active) | + +The update type filter reads the PR title for SemVer version bump signals (e.g., `1.x → 2.x` = Major). PRs with titles that do not contain recognizable version patterns are grouped under the currently active filter if it is set to All. + +--- + ## Multi-User Tracking You can track another GitHub user's issues and PRs alongside your own. Go to **Settings > Tracked Users**, enter a GitHub username, and click **Add**. The app validates the username against the GitHub API before saving. diff --git a/docs/dashboard-screenshot-compact.png b/docs/dashboard-screenshot-compact.png index 07ce7dcb..c4f6ed7b 100644 Binary files a/docs/dashboard-screenshot-compact.png and b/docs/dashboard-screenshot-compact.png differ diff --git a/docs/dashboard-screenshot.png b/docs/dashboard-screenshot.png index 0c2d58b0..6688d43d 100644 Binary files a/docs/dashboard-screenshot.png and b/docs/dashboard-screenshot.png differ diff --git a/docs/dependencies-screenshot.png b/docs/dependencies-screenshot.png new file mode 100644 index 00000000..103b371b Binary files /dev/null and b/docs/dependencies-screenshot.png differ diff --git a/docs/jira-screenshot.png b/docs/jira-screenshot.png index e26185a1..e581bfaf 100644 Binary files a/docs/jira-screenshot.png and b/docs/jira-screenshot.png differ diff --git a/e2e/capture-screenshot.spec.ts b/e2e/capture-screenshot.spec.ts index 7099b706..44bb4d98 100644 --- a/e2e/capture-screenshot.spec.ts +++ b/e2e/capture-screenshot.spec.ts @@ -659,6 +659,444 @@ test("capture dashboard screenshot", async ({ page }) => { await page.screenshot({ path: "docs/dashboard-screenshot-compact.png" }); }); +test("capture dependencies screenshot", async ({ page }) => { + const now = Date.now(); + const recentDate = new Date(now - 2 * 86_400_000).toISOString(); // 2 days ago + const olderDate = new Date(now - 5 * 86_400_000).toISOString(); // 5 days ago + const staleDate = new Date(now - 20 * 86_400_000).toISOString(); // 20 days ago + const veryStaleDate = new Date(now - 35 * 86_400_000).toISOString(); // 35 days ago + + // Dep PR light nodes — detected via bot login, branch prefix, and title pattern + const depLightPRNodes = [ + { + id: "PR_kgDOBdep001", + databaseId: 300001, + number: 501, + title: "Bump express from 4.18.2 to 4.19.0", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/501", + createdAt: "2026-04-01T10:00:00Z", + updatedAt: recentDate, + author: { login: "dependabot[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/29110?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "dependabot/npm_and_yarn/express-4.19.0", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "dependencies", color: "0366d6" }] }, + }, + { + id: "PR_kgDOBdep002", + databaseId: 300002, + number: 502, + title: "Bump webpack from 5.89.0 to 5.91.0", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/502", + createdAt: "2026-04-02T09:00:00Z", + updatedAt: recentDate, + author: { login: "dependabot[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/29110?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "dependabot/npm_and_yarn/webpack-5.91.0", + baseRefName: "main", + reviewDecision: "REVIEW_REQUIRED", + labels: { nodes: [{ name: "dependencies", color: "0366d6" }] }, + }, + { + id: "PR_kgDOBdep003", + databaseId: 300003, + number: 127, + title: "Update dependency typescript to v5.5.0", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/api-gateway/pull/127", + createdAt: "2026-04-01T14:00:00Z", + updatedAt: olderDate, + author: { login: "renovate[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740?v=4" }, + repository: { nameWithOwner: "acme-corp/api-gateway", stargazerCount: 573 }, + headRefName: "renovate/typescript-5.x", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "renovate", color: "1a7f37" }] }, + }, + { + id: "PR_kgDOBdep004", + databaseId: 300004, + number: 128, + title: "chore(deps): update react monorepo to v18.3.1", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/api-gateway/pull/128", + createdAt: "2026-03-28T11:00:00Z", + updatedAt: olderDate, + author: { login: "renovate[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740?v=4" }, + repository: { nameWithOwner: "acme-corp/api-gateway", stargazerCount: 573 }, + headRefName: "renovate/react-monorepo", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "renovate", color: "1a7f37" }] }, + }, + { + id: "PR_kgDOBdep005", + databaseId: 300005, + number: 503, + title: "Bump axios from 1.6.0 to 1.7.2", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/503", + createdAt: "2026-03-25T08:00:00Z", + updatedAt: olderDate, + author: { login: "dependabot[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/29110?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "dependabot/npm_and_yarn/axios-1.7.2", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "dependencies", color: "0366d6" }, { name: "rebase", color: "fbca04" }] }, + }, + { + id: "PR_kgDOBdep006", + databaseId: 300006, + number: 45, + title: "Update dependency @types/node to v20.12.0", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/design-system/pull/45", + createdAt: "2026-03-10T10:00:00Z", + updatedAt: staleDate, + author: { login: "renovate[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740?v=4" }, + repository: { nameWithOwner: "acme-corp/design-system", stargazerCount: 228 }, + headRefName: "renovate/types-node-20.x", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "renovate", color: "1a7f37" }] }, + }, + { + id: "PR_kgDOBdep007", + databaseId: 300007, + number: 504, + title: "Bump eslint from 8.56.0 to 9.0.0", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/504", + createdAt: "2026-02-20T09:00:00Z", + updatedAt: veryStaleDate, + author: { login: "dependabot[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/29110?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "dependabot/npm_and_yarn/eslint-9.0.0", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "dependencies", color: "0366d6" }] }, + }, + ]; + + // Heavy PR nodes for dep PRs — control check status to determine status grouping + const depHeavyPRNodes = [ + { + // express → mergeable (CI success, not approved) + databaseId: 300001, + headRefOid: "aaa111aaa111aaa111aaa111aaa111aaa111aaa1", + headRepository: { owner: { login: "dependabot[bot]" }, nameWithOwner: "acme-corp/web-platform" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 12, deletions: 8, changedFiles: 2, + comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + // webpack → mergeable (CI success, review required but not approved) + databaseId: 300002, + headRefOid: "bbb222bbb222bbb222bbb222bbb222bbb222bbb2", + headRepository: { owner: { login: "dependabot[bot]" }, nameWithOwner: "acme-corp/web-platform" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 45, deletions: 12, changedFiles: 3, + comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + // typescript → needs-action (CI failure) + databaseId: 300003, + headRefOid: "ccc333ccc333ccc333ccc333ccc333ccc333ccc3", + headRepository: { owner: { login: "renovate[bot]" }, nameWithOwner: "acme-corp/api-gateway" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 3, deletions: 3, changedFiles: 1, + comments: { totalCount: 2 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + { + // react monorepo → needs-action (CI pending) + databaseId: 300004, + headRefOid: "ddd444ddd444ddd444ddd444ddd444ddd444ddd4", + headRepository: { owner: { login: "renovate[bot]" }, nameWithOwner: "acme-corp/api-gateway" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 89, deletions: 34, changedFiles: 8, + comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + }, + { + // axios → pending-rebase (has "rebase" label, CI success) + databaseId: 300005, + headRefOid: "eee555eee555eee555eee555eee555eee555eee5", + headRepository: { owner: { login: "dependabot[bot]" }, nameWithOwner: "acme-corp/web-platform" }, + mergeStateStatus: "BEHIND", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 18, deletions: 6, changedFiles: 2, + comments: { totalCount: 1 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + // @types/node → stale (updatedAt 20 days ago) + databaseId: 300006, + headRefOid: "fff666fff666fff666fff666fff666fff666fff6", + headRepository: { owner: { login: "renovate[bot]" }, nameWithOwner: "acme-corp/design-system" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 2, deletions: 2, changedFiles: 1, + comments: { totalCount: 0 }, reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + }, + { + // eslint → stale (updatedAt 35 days ago, major bump) + databaseId: 300007, + headRefOid: "ggg777ggg777ggg777ggg777ggg777ggg777ggg7", + headRepository: { owner: { login: "dependabot[bot]" }, nameWithOwner: "acme-corp/web-platform" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 312, deletions: 145, changedFiles: 18, + comments: { totalCount: 5 }, reviewThreads: { totalCount: 1 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + ]; + + // A few regular PRs for realistic tab bar counts + const regularLightPRNodes = [ + { + id: "PR_kgDOBreg001", + databaseId: 100001, + number: 247, + title: "feat: migrate authentication to passkey support", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/247", + createdAt: "2026-03-28T09:15:00Z", + updatedAt: recentDate, + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "feat/passkey-auth", + baseRefName: "main", + reviewDecision: "APPROVED", + labels: { nodes: [{ name: "feature", color: "0075ca" }] }, + }, + { + id: "PR_kgDOBreg002", + databaseId: 100002, + number: 312, + title: "fix: resolve N+1 query in user profile endpoint", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/api-gateway/pull/312", + createdAt: "2026-04-01T14:20:00Z", + updatedAt: recentDate, + author: { login: "msmith", avatarUrl: "https://avatars.githubusercontent.com/u/67890?v=4" }, + repository: { nameWithOwner: "acme-corp/api-gateway", stargazerCount: 573 }, + headRefName: "fix/n-plus-one-profile", + baseRefName: "main", + reviewDecision: "CHANGES_REQUESTED", + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + }, + ]; + + const regularHeavyPRNodes = [ + { + databaseId: 100001, + headRefOid: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + headRepository: { owner: { login: "jdoe" }, nameWithOwner: "jdoe/web-platform-fork" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, reviewRequests: { nodes: [] }, + 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" } } }] }, + }, + { + databaseId: 100002, + headRefOid: "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + headRepository: { owner: { login: "msmith" }, nameWithOwner: "acme-corp/api-gateway" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [{ login: "jdoe" }] }, reviewRequests: { nodes: [] }, + 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" } } }] }, + }, + ]; + + const depIssueNodes = [ + { + databaseId: 200001, + number: 1023, + title: "OAuth login fails on Safari when third-party cookies are blocked", + state: "OPEN", + url: "https://github.com/acme-corp/web-platform/issues/1023", + createdAt: "2026-03-29T10:00:00Z", + updatedAt: recentDate, + author: { login: "bwilson", avatarUrl: "https://avatars.githubusercontent.com/u/44444?v=4" }, + assignees: { nodes: [{ login: "jdoe" }] }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + comments: { totalCount: 14 }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + }, + ]; + + // Seed localStorage + await page.addInitScript(() => { + localStorage.removeItem("github-tracker:view"); + localStorage.setItem("github-tracker:auth-token", "fake-screenshot-token"); + localStorage.setItem( + "github-tracker:config", + JSON.stringify({ + onboardingComplete: true, + selectedOrgs: ["acme-corp"], + selectedRepos: [ + { owner: "acme-corp", name: "web-platform", fullName: "acme-corp/web-platform" }, + { owner: "acme-corp", name: "api-gateway", fullName: "acme-corp/api-gateway" }, + { owner: "acme-corp", name: "design-system", fullName: "acme-corp/design-system" }, + ], + trackedUsers: [ + { + login: "jdoe", + avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4", + name: "Jane Doe", + type: "user", + }, + ], + theme: "dark", + dependencies: { enabled: true, rebaseLabel: "rebase" }, + }) + ); + // Seed dep version metadata for richer display + localStorage.setItem( + "github-tracker:dep-meta", + JSON.stringify({ + "300003": { packageName: "typescript", from: "5.4.5", to: "5.5.0", updateType: "minor" }, + "300004": { packageName: "react", from: "18.2.0", to: "18.3.1", updateType: "minor" }, + "300006": { packageName: "@types/node", from: "20.11.0", to: "20.12.0", updateType: "minor" }, + }) + ); + }); + + // API mocks — catch-all first, specific routes override + await page.route("https://api.github.com/**", (route) => route.abort()); + + await page.route("https://api.github.com/notifications*", (route) => + route.fulfill({ status: 200, json: [] }) + ); + + await page.route("https://api.github.com/repos/*/*/actions/runs*", (route) => + route.fulfill({ status: 200, json: { total_count: 0, workflow_runs: [] } }) + ); + + await page.route("https://api.github.com/graphql", async (route) => { + const body = route.request().postDataJSON() as { variables?: Record } | null; + const variables = body?.variables ?? {}; + + // Heavy backfill + if ("ids" in variables) { + return route.fulfill({ + status: 200, + json: { + data: { + nodes: [...depHeavyPRNodes, ...regularHeavyPRNodes], + rateLimit: { limit: 5000, remaining: 4900, resetAt: RESET_AT }, + }, + }, + }); + } + + // Light combined search + if ("issueQ" in variables || "prInvQ" in variables || "prRevQ" in variables) { + const allPRs = [...depLightPRNodes, ...regularLightPRNodes]; + return route.fulfill({ + status: 200, + json: { + data: { + issues: { + issueCount: depIssueNodes.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: depIssueNodes, + }, + prInvolves: { + issueCount: allPRs.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: allPRs, + }, + prReviewReq: { + issueCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + rateLimit: { limit: 5000, remaining: 4950, resetAt: RESET_AT }, + }, + }, + }); + } + + return route.fulfill({ + status: 200, + json: { + data: { + rateLimit: { limit: 5000, remaining: 4900, resetAt: RESET_AT }, + }, + }, + }); + }); + + await page.route("https://api.github.com/user", (route) => + route.fulfill({ + status: 200, + json: { + login: "jdoe", + name: "Jane Doe", + avatar_url: "https://avatars.githubusercontent.com/u/12345?v=4", + id: 12345, + }, + }) + ); + + // Navigate and capture + await page.goto("/dashboard"); + await page.getByRole("tablist").waitFor(); + + // Switch to Dependencies tab + await page.getByRole("tab", { name: /dependencies/i }).click(); + await page.getByRole("tab", { name: /dependencies/i, selected: true }).waitFor(); + + // Wait for dep PRs to render (status group header text) + await page.getByText("Mergeable").waitFor(); + + // Expand all status groups for a comprehensive screenshot + await page.getByRole("button", { name: /expand all/i }).click(); + await page.getByText("express").first().waitFor(); + + await page.screenshot({ path: "docs/dependencies-screenshot.png" }); +}); + test("capture jira screenshot", async ({ page }) => { await page.addInitScript(() => { localStorage.removeItem("github-tracker:view"); diff --git a/e2e/dependencies.spec.ts b/e2e/dependencies.spec.ts new file mode 100644 index 00000000..421407fb --- /dev/null +++ b/e2e/dependencies.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from "@playwright/test"; +import { setupAuth } from "./helpers"; + +const recentDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); +const staleDate = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(); + +function makeDepPR(overrides: Record = {}) { + return { + __typename: "PullRequest", + id: "PR_dep_1", + databaseId: 9001, + number: 100, + title: "Bump lodash from 4.17.20 to 4.17.21", + state: "OPEN", + isDraft: false, + url: "https://github.com/testorg/testrepo/pull/100", + createdAt: recentDate, + updatedAt: recentDate, + author: { login: "renovate[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740" }, + repository: { nameWithOwner: "testorg/testrepo", stargazerCount: 10 }, + headRefName: "renovate/lodash-4.x", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [] }, + ...overrides, + }; +} + +function graphqlWithDepPRs(prs: Record[]) { + return { + data: { + issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + prInvolves: { + issueCount: prs.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: prs, + }, + prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: { limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" }, + }, + }; +} + +// ── Dependencies tab visibility ───────────────────────────────────────────── + +test("dependencies tab auto-appears when dep bot PRs exist", async ({ page }) => { + await setupAuth(page); + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ status: 200, json: graphqlWithDepPRs([makeDepPR()]) }) + ); + await page.goto("/dashboard"); + await page.getByRole("tablist").waitFor(); + + await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 }); +}); + +test("dependencies tab is absent when no dep PRs exist", async ({ page }) => { + await setupAuth(page); + await page.goto("/dashboard"); + + await expect(page.getByRole("tab", { name: /dependencies/i })).toHaveCount(0); +}); + +test("settings toggle hides the dependencies tab", async ({ page }) => { + await setupAuth(page, { dependencies: { enabled: false, rebaseLabel: "rebase" } }); + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ status: 200, json: graphqlWithDepPRs([makeDepPR()]) }) + ); + await page.goto("/dashboard"); + + await expect(page.getByRole("tab", { name: /dependencies/i })).toHaveCount(0); +}); + +// ── Status groups ─────────────────────────────────────────────────────────── + +test("status groups render correctly", async ({ page }) => { + // Light PRs lack enrichment data, so recent ones land in "Needs Action" and old ones in "Stale" + const waitingPR = makeDepPR({ + id: "PR_wait_1", + databaseId: 9002, + number: 101, + title: "Bump axios from 0.27.2 to 1.0.0", + }); + const stalePR = makeDepPR({ + id: "PR_stale_1", + databaseId: 9003, + number: 102, + title: "Bump react from 17.0.0 to 18.0.0", + updatedAt: staleDate, + createdAt: staleDate, + }); + + await setupAuth(page); + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ status: 200, json: graphqlWithDepPRs([waitingPR, stalePR]) }) + ); + await page.goto("/dashboard"); + + // Switch to Dependencies tab + await page.getByRole("tab", { name: /dependencies/i }).click(); + + // Both status groups should appear + await expect(page.getByText("Needs Action")).toBeVisible(); + await expect(page.getByText("Stale")).toBeVisible(); +}); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 32c7e4ff..caea9250 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -10,6 +10,10 @@ import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, updateJiraConfig, type TrackedUser } from "../../stores/config"; import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; +import DependenciesTab from "./DependenciesTab"; +import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection"; +import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; +import { fetchDashboardIssueBodies, fetchDepPRBodies } from "../../services/api"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -24,7 +28,7 @@ import { fetchAllData, type DashboardData, } from "../../services/poll"; -import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, clearJiraAuth } from "../../stores/auth"; +import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, DEP_META_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, clearJiraAuth } from "../../stores/auth"; import { JiraApiError } from "../../services/jira-client"; import { createJiraClient, mergeCustomFields, jiraJqlForScope } from "../../lib/jira-utils"; import type { JiraIssue } from "../../../shared/jira-types"; @@ -137,6 +141,39 @@ const [jiraLoading, setJiraLoading] = createSignal(false); const [jiraKeyMap, setJiraKeyMap] = createSignal>(new Map()); let _jiraFetching = false; +// Dependency dashboard state — module-level for same reasons as jira state above +const [abandonedDepsMap, setAbandonedDepsMap] = createSignal>(new Map()); +const [dashboardIssueUrls, setDashboardIssueUrls] = createSignal>(new Map()); +function loadDepMetaCache(): Map { + try { + const raw = localStorage.getItem?.(DEP_META_STORAGE_KEY); + if (!raw) return new Map(); + const parsed = JSON.parse(raw) as Record; + const map = new Map(); + for (const [k, v] of Object.entries(parsed)) { + const id = Number(k); + if (!isNaN(id) && v && typeof v === "object") map.set(id, v as VersionInfo); + } + return map; + } catch { + return new Map(); + } +} + +function persistDepMeta(meta: ReadonlyMap): void { + try { + const obj: Record = {}; + for (const [k, v] of meta) obj[k] = v; + localStorage.setItem(DEP_META_STORAGE_KEY, JSON.stringify(obj)); + } catch { + // Non-critical — storage may be full + } +} + +const [depMeta, setDepMeta] = createSignal>(loadDepMetaCache()); +let _fetchingDashboardBodies = false; +let _fetchingDepBodies = false; + // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { resetDashboardData(); @@ -145,6 +182,12 @@ onAuthCleared(() => { setJiraLoading(false); setJiraKeyMap(new Map()); _jiraFetching = false; + setAbandonedDepsMap(new Map()); + setDashboardIssueUrls(new Map()); + setDepMeta(new Map()); + localStorage.removeItem?.(DEP_META_STORAGE_KEY); + _fetchingDashboardBodies = false; + resetAbandonedPatternCache(); const coord = _coordinator(); if (coord) { coord.destroy(); @@ -535,6 +578,7 @@ export default function DashboardPage() { if (tab === "tracked" && !config.enableTracking) return "issues"; if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues"; if (!config.enableActions && isActionsBasedTab(tab, config.customTabs)) return "issues"; + if (tab === "dependencies" && !config.dependencies.enabled) return "issues"; // Validate custom tab still exists; fall back to "issues" if stale if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; @@ -580,6 +624,13 @@ export default function DashboardPage() { } }); + // Redirect away from Dependencies tab when it becomes invisible + createEffect(() => { + if (activeTab() === "dependencies" && !enableDependencies()) { + handleTabChange("issues"); + } + }); + // Clear stale Jira data when auth is cleared (e.g., 401 during token refresh) createEffect(() => { if (!isJiraAuthenticated()) { @@ -797,6 +848,18 @@ export default function DashboardPage() { ]; }); + // Dep PR detection — placed above exclusiveOwnership so pre-exclusivity claims run first + const trackedBotLogins = createMemo(() => + expandBotLogins(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) + ); + const dependencyPullRequests = createMemo(() => { + if (!config.dependencies.enabled) return []; + return dashboardData.pullRequests.filter((pr) => pr.state === "OPEN" && isDependencyPr(pr, trackedBotLogins())); + }); + const dependencyPrIds = createMemo(() => + new Set(dependencyPullRequests().map((pr) => pr.id)) + ); + // Eagerly compute scoped data for exclusive custom tabs (needed by exclusiveOwnership). // Non-exclusive tabs only compute when they are the active tab. const customTabData = createMemo(() => { @@ -822,6 +885,11 @@ export default function DashboardPage() { const issueOwner = new Map(); const prOwner = new Map(); const runOwner = new Map(); + if (config.dependencies.enabled) { + for (const id of dependencyPrIds()) { + prOwner.set(id, "dependencies"); + } + } for (const tab of config.customTabs) { if (!tab.exclusive) continue; const data = customTabData()[tab.id]; @@ -843,6 +911,10 @@ export default function DashboardPage() { return owner === viewingTabId; // only visible on its owning tab } + const enableDependencies = createMemo(() => + config.dependencies.enabled && dependencyPullRequests().length > 0 + ); + // Visible data for built-in tabs — filters out exclusively-owned items const visibleIssues = createMemo(() => { const map = exclusiveOwnership().issues; @@ -899,7 +971,7 @@ export default function DashboardPage() { customCounts[tab.id] = data.issues.filter((i) => { if (i.state !== "OPEN") return false; if (!isItemVisibleOnTab(ownership.issues, i.id, tab.id)) return false; - if (!isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: null })) return false; + if (!isIssueVisible(i, { ignoredIds: ignoredIssues, globalFilter: null })) return false; if (f.scope === "involves_me" && !isUserInvolved(i, login, monitoredSet)) return false; if (f.role === "author" && i.userLogin.toLowerCase() !== login) return false; if (f.role === "assignee" && !i.assigneeLogins?.some((a) => a.toLowerCase() === login)) return false; @@ -976,7 +1048,7 @@ export default function DashboardPage() { return { issues: visibleIssues().filter((i) => - isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: builtinFilter }) + isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard && !config.dependencies.enabled, globalFilter: builtinFilter }) ).length, pullRequests: visiblePullRequests().filter((p) => isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: builtinFilter }) @@ -993,6 +1065,7 @@ export default function DashboardPage() { return true; }).length }; })() : {}), + ...(enableDependencies() ? { dependencies: dependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), ...customCounts, }; }); @@ -1073,6 +1146,85 @@ export default function DashboardPage() { } }); + // Renovate Dashboard issue body fetch: fires after each full refresh cycle + createEffect(on( + () => _coordinator()?.lastRefreshAt(), + () => { + if (!config.dependencies.enabled) return; + if (_fetchingDashboardBodies) return; + const octokit = getClient(); + if (!octokit) return; + _fetchingDashboardBodies = true; + void (async () => { + try { + const dashboardIssues = findDashboardIssues(dashboardData.issues, trackedBotLogins()); + const depRepos = new Set(dependencyPullRequests().map((pr) => pr.repoFullName)); + const relevant = dashboardIssues.filter((di) => depRepos.has(di.repoFullName)); + if (relevant.length === 0) return; + + const nodeIds = relevant.map((di) => di.nodeId); + const bodyMap = await fetchDashboardIssueBodies(octokit, nodeIds); + + const newAbandonedMap = new Map(); + const newUrlMap = new Map(); + for (const di of relevant) { + const body = bodyMap.get(di.nodeId); + if (body != null) { + newAbandonedMap.set(di.repoFullName, parseAbandonedSection(body)); + } + newUrlMap.set(di.repoFullName, di.htmlUrl); + } + // Don't replace valid data with empty results from a failed fetch + if (bodyMap.size === 0 && relevant.length > 0) return; + setAbandonedDepsMap(newAbandonedMap); + setDashboardIssueUrls(newUrlMap); + } finally { + _fetchingDashboardBodies = false; + } + })(); + }, + { defer: true } + )); + + // 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; + if (_fetchingDepBodies) return; + const octokit = getClient(); + if (!octokit) return; + + const meta = depMeta(); + const depPrs = dependencyPullRequests(); + const toFetch = depPrs.filter((pr) => !meta.has(pr.id) && needsBodyFallback(pr)); + if (toFetch.length === 0) return; + + _fetchingDepBodies = true; + void (async () => { + try { + const nodeIds = toFetch.map((pr) => pr.nodeId!); + const bodyMap = await fetchDepPRBodies(octokit, nodeIds); + if (bodyMap.size === 0) return; + + const merged = new Map(meta); + for (const [id, body] of bodyMap) { + const parsed = parseRenovateBody(body); + if (parsed) merged.set(id, parsed); + } + // Prune entries for PRs no longer in the dependency set + const depPrIds = new Set(depPrs.map((pr) => pr.id)); + for (const k of [...merged.keys()]) { + if (!depPrIds.has(k)) merged.delete(k); + } + setDepMeta(merged); + setTimeout(() => persistDepMeta(merged), 0); + } finally { + _fetchingDepBodies = false; + } + })(); + }); + // Push dashboard data into the MCP relay snapshot on each full refresh. // Tracks lastRefreshedAt (always updated alongside data arrays in pollFetch). // Hot poll updates are intentionally excluded — relay reflects full-refresh data only. @@ -1116,6 +1268,7 @@ export default function DashboardPage() { enableTracking={config.enableTracking} enableActions={config.enableActions} enableJira={!!config.jira?.enabled} + enableDependencies={enableDependencies()} customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name }))} onAddTab={() => setShowCustomTabModal(true)} onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }} @@ -1129,7 +1282,7 @@ export default function DashboardPage() { sortValue={viewState.globalSort.field} sortDirection={viewState.globalSort.direction} onSortChange={(field, dir) => setSortPreference(field, dir)} - hideOrgRepo={!isBuiltinTab(activeTab())} + hideOrgRepo={!isBuiltinTab(activeTab()) || activeTab() === "dependencies"} /> @@ -1160,6 +1313,22 @@ export default function DashboardPage() { configRepoNames={configRepoNames()} refreshTick={refreshTick()} jiraKeyMap={jiraKeyMap} + depPrIds={dependencyPrIds()} + /> + + + _coordinator()?.manualRefresh()} /> diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx new file mode 100644 index 00000000..75ac0206 --- /dev/null +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -0,0 +1,518 @@ +import { createMemo, createSignal, For, Show } from "solid-js"; +import { config, updateConfig } from "../../stores/config"; +import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, trackItem, untrackItem, DependencyFiltersSchema, setDependencyExpandedGroups } from "../../stores/view"; +import IgnoreBadge from "./IgnoreBadge"; +import { isSafeGitHubUrl } from "../../lib/url"; +import type { PullRequest } from "../../services/api"; +import type { AbandonedDependency } from "../../lib/dependency-dashboard"; +import { + classifyDepStatus, + extractVersionInfo, + ALL_DEP_STATUSES, + isKnownDepBot, + DEP_TOOL_LABEL_NAMES, + type DepStatus, + type VersionInfo, +} from "../../lib/dependency-detection"; +import { matchAbandonedToPr } from "../../lib/dependency-dashboard"; +import type { FilterChipGroupDef } from "../shared/filterTypes"; +import FilterToolbar from "../shared/FilterToolbar"; +import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; +import RepoGroupHeader from "../shared/RepoGroupHeader"; +import StatusDot from "../shared/StatusDot"; +import SizeBadge from "../shared/SizeBadge"; +import ReviewBadge from "../shared/ReviewBadge"; +import ItemRow from "./ItemRow"; +import SkeletonRows from "../shared/SkeletonRows"; + +const DEP_FILTER_DEFAULTS = DependencyFiltersSchema.parse({}); + +const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { + label: "Update type", + field: "updateType", + options: [ + { value: "maintenance", label: "Maintenance" }, + { value: "pin", label: "Pin" }, + { value: "patch", label: "Patch" }, + { value: "minor", label: "Minor" }, + { value: "major", label: "Major" }, + { value: "other", label: "Other" }, + ], +}; + +const STATUS_META: Record = { + "mergeable": { label: "Mergeable" }, + "pending-rebase": { label: "Pending Rebase" }, + "needs-action": { label: "Needs Action" }, + "stale": { label: "Stale" }, +}; + +type DepCategory = "major" | "minor" | "patch" | "pin" | "maintenance" | "other"; + +function mapUpdateType(ut: NonNullable): DepCategory { + if (ut === "digest") return "patch"; + return ut; +} + +const CATEGORY_SORT_ORDER: Record = { + maintenance: 0, + pin: 1, + patch: 2, + minor: 3, + major: 4, + other: 5, +}; + +const CATEGORY_BADGE_CLASS: Partial> = { + major: "badge-error", + minor: "badge-warning", + patch: "badge-success", + pin: "badge-success", +}; + +function depCategory(pr: PullRequest, versionInfo: VersionInfo | null): DepCategory { + if (versionInfo?.updateType) return mapUpdateType(versionInfo.updateType); + + const titleLower = pr.title.toLowerCase(); + if (/pin\s+dep/.test(titleLower)) return "pin"; + if (/lock\s*file\s+maintenance/.test(titleLower)) return "maintenance"; + + const fallback: DepCategory = versionInfo ? "other" : "maintenance"; + for (const l of pr.labels) { + const name = l.name.toLowerCase(); + if (name === "major") return "major"; + if (name === "minor") return "minor"; + if (name === "patch") return "patch"; + } + return fallback; +} + +interface ClassifiedPR { + pr: PullRequest; + status: DepStatus; + versionInfo: VersionInfo | null; + category: DepCategory; + abandoned: boolean; + abandonedDep: AbandonedDependency | null; +} + +interface DependenciesTabProps { + pullRequests: PullRequest[]; + depMeta?: ReadonlyMap; + loading?: boolean; + abandonedDepsMap: Map; + dashboardIssueUrls: Map; + hotPollingPRIds?: ReadonlySet; + refreshTick?: number; + rebaseLabel: string; + userLogin: string; + trackedBotLogins: ReadonlySet; + onRefresh?: () => void; +} + +export default function DependenciesTab(props: DependenciesTabProps) { + const expandedGroups = createMemo(() => + new Set(viewState.dependencyExpandedGroups) + ); + + function toggleGroup(status: DepStatus) { + const current = viewState.dependencyExpandedGroups; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + setDependencyExpandedGroups(next); + } + + function expandAllGroups() { + setDependencyExpandedGroups([...ALL_DEP_STATUSES]); + } + + function collapseAllGroups() { + setDependencyExpandedGroups([]); + } + + const activeFilters = createMemo(() => ({ + ...DEP_FILTER_DEFAULTS, + ...(viewState.tabFilters.dependencies ?? {}), + })); + + const botOptions = createMemo(() => { + const logins = [...new Set(props.pullRequests.map((pr) => pr.userLogin))].sort(); + return { + label: "Bot", + field: "bot", + options: logins.map((l) => ({ value: l, label: l })), + }; + }); + + const filterGroups = createMemo(() => [UPDATE_TYPE_OPTIONS, botOptions()]); + + const ignoredIds = createMemo( + () => new Set(viewState.ignoredItems.filter((i) => i.type === "pullRequest").map((i) => i.id)) + ); + + const ignoredDepPRs = createMemo(() => { + const depIds = new Set(props.pullRequests.map((p) => p.id)); + return viewState.ignoredItems.filter((i) => i.type === "pullRequest" && depIds.has(i.id)); + }); + + const trackedPrIds = createMemo(() => + config.enableTracking + ? new Set(viewState.trackedItems.filter((t) => t.type === "pullRequest").map((t) => t.id)) + : new Set() + ); + + const classifiedPRs = createMemo(() => { + const filters = activeFilters(); + const ignored = ignoredIds(); + const meta = props.depMeta; + return props.pullRequests + .map((pr) => { + const titleInfo = extractVersionInfo(pr.title); + let versionInfo = titleInfo; + const cached = meta?.get(pr.id); + if (cached && (!titleInfo?.updateType || !titleInfo?.from)) { + versionInfo = { + packageName: titleInfo?.packageName ?? cached.packageName, + from: cached.from ?? titleInfo?.from, + to: cached.to ?? titleInfo?.to, + updateType: cached.updateType ?? titleInfo?.updateType, + }; + } + const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; + const abandonedDep = matchAbandonedToPr(pr, abandonedDeps); + const abandoned = abandonedDep !== null || /\s-\s*abandoned$/i.test(pr.title); + return { + pr, + status: classifyDepStatus(pr, props.rebaseLabel), + versionInfo, + category: depCategory(pr, versionInfo), + abandoned, + abandonedDep, + }; + }) + .filter(({ pr, category }) => { + if (ignored.has(pr.id)) return false; + if (filters.bot !== "all" && pr.userLogin !== filters.bot) return false; + if (filters.updateType !== "all" && category !== filters.updateType) return false; + return true; + }); + }); + + const sortedPRs = createMemo(() => { + const { field, direction } = viewState.globalSort; + const items = [...classifiedPRs()]; + const dir = direction === "asc" ? 1 : -1; + + items.sort((a, b) => { + // User-selected sort as primary + let cmp = 0; + switch (field) { + case "repo": cmp = a.pr.repoFullName.localeCompare(b.pr.repoFullName); break; + case "title": cmp = a.pr.title.localeCompare(b.pr.title); break; + case "author": cmp = a.pr.userLogin.localeCompare(b.pr.userLogin); break; + case "comments": cmp = a.pr.comments - b.pr.comments; break; + case "checkStatus": cmp = (a.pr.checkStatus ?? "").localeCompare(b.pr.checkStatus ?? ""); break; + case "reviewDecision": cmp = (a.pr.reviewDecision ?? "").localeCompare(b.pr.reviewDecision ?? ""); break; + case "size": cmp = (a.pr.additions + a.pr.deletions) - (b.pr.additions + b.pr.deletions); break; + case "createdAt": cmp = a.pr.createdAt.localeCompare(b.pr.createdAt); break; + case "updatedAt": + default: + cmp = a.pr.updatedAt.localeCompare(b.pr.updatedAt); + break; + } + if (cmp !== 0) return cmp * dir; + + // Category as secondary (safest → least safe within each group) + const catCmp = CATEGORY_SORT_ORDER[a.category] - CATEGORY_SORT_ORDER[b.category]; + if (catCmp !== 0) return catCmp; + + return a.pr.repoFullName.localeCompare(b.pr.repoFullName); + }); + + return items; + }); + + const statusGroups = createMemo(() => { + const groups: Record = { + "mergeable": [], + "pending-rebase": [], + "needs-action": [], + "stale": [], + }; + for (const item of sortedPRs()) { + groups[item.status].push(item); + } + return groups; + }); + + // Unknown bot detection — inline banner with "Track" action + const [dismissedBots, setDismissedBots] = createSignal(new Set()); + + const unknownBots = createMemo(() => { + const known = new Set([ + ...props.trackedBotLogins, + ...config.trackedUsers.map((u) => u.login.toLowerCase()), + ]); + const userLower = props.userLogin.toLowerCase(); + const dismissed = dismissedBots(); + const seen = new Map(); + + for (const pr of props.pullRequests) { + const login = pr.userLogin.toLowerCase(); + if (login === userLower) continue; + if (isKnownDepBot(login)) continue; + if (known.has(login)) continue; + if (dismissed.has(login)) continue; + if (seen.has(login)) continue; + seen.set(login, { login: pr.userLogin, avatarUrl: pr.userAvatarUrl }); + } + return [...seen.values()]; + }); + + function handleTrackBot(login: string, avatarUrl: string) { + const normalized = login.replace(/\[bot\]$/i, ""); + const existing = config.trackedUsers.map((u) => u.login.toLowerCase()); + if (existing.includes(normalized.toLowerCase())) return; + updateConfig({ + trackedUsers: [...config.trackedUsers, { login: normalized, avatarUrl, name: null, type: "bot" as const }], + }); + props.onRefresh?.(); + } + + function handleDismissBot(login: string) { + setDismissedBots((prev) => new Set([...prev, login.toLowerCase()])); + } + + function handleIgnore(pr: PullRequest) { + ignoreItem({ id: pr.id, type: "pullRequest", repo: pr.repoFullName, title: pr.title, ignoredAt: Date.now() }); + if (config.enableTracking) untrackItem(pr.id, "pullRequest"); + } + + function handleTrack(pr: PullRequest) { + if (trackedPrIds().has(pr.id)) { + untrackItem(pr.id, "pullRequest"); + } else { + trackItem({ id: pr.id, number: pr.number, type: "pullRequest", source: "github", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); + } + } + + return ( +
+
+
+ setTabFilter("dependencies", f as "updateType" | "bot", v)} + onResetAll={() => resetAllTabFilters("dependencies")} + /> +
+
+ + +
+
+ + + {(bot) => ( +
+ + {bot.login} + + + Dependency PRs from {bot.login} — track this bot for full coverage? + + + +
+ )} +
+ + + + + + +
+ +

No open dependency update PRs

+

Your dependencies are up to date!

+
+
+ + 0}> +
+

No dependency PRs match your current filters

+
+
+ + 0}> +
+ + {(status) => ( + toggleGroup(status)} + dashboardIssueUrls={props.dashboardIssueUrls} + hotPollingPRIds={props.hotPollingPRIds} + refreshTick={props.refreshTick} + trackedPrIds={trackedPrIds()} + enableTracking={config.enableTracking} + onIgnore={handleIgnore} + onTrack={handleTrack} + /> + )} + +
+
+
+ ); +} + +interface StatusGroupProps { + status: DepStatus; + label: string; + items: ClassifiedPR[]; + expanded: boolean; + onToggle: () => void; + dashboardIssueUrls: Map; + hotPollingPRIds?: ReadonlySet; + refreshTick?: number; + trackedPrIds: Set; + enableTracking: boolean; + onIgnore: (pr: PullRequest) => void; + onTrack: (pr: PullRequest) => void; +} + +function stripCommitPrefix(title: string): string { + const stripped = title.replace(/^(?:chore|fix|build)\(deps[^)]*\):\s*/i, "").replace(/\s*-\s*abandoned$/i, ""); + return stripped.charAt(0).toUpperCase() + stripped.slice(1); +} + +function displayTitle(pr: PullRequest, versionInfo: VersionInfo | null): string { + if (versionInfo?.packageName) { + if (versionInfo.from && versionInfo.to) return `${versionInfo.packageName}: ${versionInfo.from} → ${versionInfo.to}`; + if (versionInfo.to) return `${versionInfo.packageName} → ${versionInfo.to}`; + return versionInfo.packageName; + } + return stripCommitPrefix(pr.title); +} + +function filteredLabels(labels: { name: string; color: string }[]): { name: string; color: string }[] { + return labels.filter((l) => !DEP_TOOL_LABEL_NAMES.has(l.name.toLowerCase())); +} + +function StatusGroup(props: StatusGroupProps) { + return ( + 0}> +
+ + +
+ + {({ pr, versionInfo, category, abandoned, abandonedDep }) => { + const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); + const title = () => displayTitle(pr, versionInfo); + return ( +
+ props.onIgnore(pr)} + onTrack={props.enableTracking ? () => props.onTrack(pr) : undefined} + isTracked={props.enableTracking ? props.trackedPrIds.has(pr.id) : undefined} + isPolling={props.hotPollingPRIds?.has(pr.id)} + hideAuthor + hideNumber + subtleRepo + titlePrefix={ + + + {category} + + + } + > +
+ + + + + + + + + 0 || pr.deletions > 0)}> + + + + + Draft + + + + Abandoned + } + > + e.stopPropagation()} + > + Abandoned + + + +
+
+
+ ); + }} +
+
+
+
+ ); +} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 918d5c65..dee194ee 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -124,7 +124,7 @@ export default function IssuesTab(props: IssuesTabProps) { let items = props.issues.filter((issue) => { if (issue.state !== "OPEN") return false; - if (!isIssueVisible(issue, { ignoredIds, hideDepDashboard: viewState.hideDepDashboard, globalFilter })) return false; + if (!isIssueVisible(issue, { ignoredIds, hideDepDashboard: !props.customTabId && viewState.hideDepDashboard && !config.dependencies.enabled, globalFilter })) return false; const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); @@ -277,18 +277,20 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); }} /> - - - + + + + +
{props.repo} @@ -122,8 +129,11 @@ export default function ItemRow(props: ItemRowProps) { > {repoShortName()} @@ -134,7 +144,14 @@ export default function ItemRow(props: ItemRowProps) { {/* ── COMPACT LAYOUT: everything on one line ── */} {/* Number */} - #{props.number} + + #{props.number} + + + {/* Title prefix (e.g., category badge) — fixed width for column alignment */} + +
{props.titlePrefix}
+
{/* Title — truncated, fills available space */} @@ -177,8 +194,10 @@ export default function ItemRow(props: ItemRowProps) { {/* Author + time — compact, inline */} - {props.author} - {" · "} + + {props.author} + {" · "} +