From 32d7200234513b26ebd8f653569dc013a023c4fa Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 10:51:02 -0400 Subject: [PATCH 01/48] feat(deps): adds dependency detection module and config schema --- src/app/lib/dependency-detection.ts | 128 ++++++++++ src/app/stores/config.ts | 6 +- src/shared/schemas.ts | 9 +- tests/lib/dependency-detection.test.ts | 321 +++++++++++++++++++++++++ 4 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 src/app/lib/dependency-detection.ts create mode 100644 tests/lib/dependency-detection.test.ts diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts new file mode 100644 index 00000000..9394f181 --- /dev/null +++ b/src/app/lib/dependency-detection.ts @@ -0,0 +1,128 @@ +import type { PullRequest } from "../../shared/types.js"; + +export const KNOWN_DEP_BOT_LOGINS = new Set([ + "dependabot[bot]", + "renovate[bot]", + "snyk-bot", + "depfu[bot]", + "pyup-bot", + "scala-steward", + "mend-renovate-bot", +]); + +export const DEP_BRANCH_PREFIXES = [ + "dependabot/", + "renovate/", + "snyk-fix-", + "snyk-upgrade-", + "pyup-update-", +]; + +export const DEP_TITLE_PATTERN = /^(Bump |Update dependency |chore\(deps|fix\(deps|build\(deps|\[Snyk\])/i; + +export type DepStatus = "needs-review" | "waiting" | "stale"; + +export function isDependencyPr(pr: PullRequest, trackedBotLogins: Set): boolean { + const login = pr.userLogin.toLowerCase(); + + if (KNOWN_DEP_BOT_LOGINS.has(login)) return true; + if (trackedBotLogins.has(login)) return true; + + const branch = pr.headRef.toLowerCase(); + for (const prefix of DEP_BRANCH_PREFIXES) { + if (branch.startsWith(prefix)) return true; + } + + if (DEP_TITLE_PATTERN.test(pr.title)) return true; + + if (pr.labels.some((l) => l.name.toLowerCase() === "dependencies")) return true; + + return false; +} + +function parseSemver(v: string): [number, number, number] | null { + const cleaned = v.replace(/^v/, ""); + const parts = cleaned.split("."); + if (parts.length < 2) return null; + const nums = parts.slice(0, 3).map(Number); + if (nums.some(isNaN)) return null; + return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0]; +} + +function semverUpdateType(from: string, to: string): "major" | "minor" | "patch" | null { + const f = parseSemver(from); + const t = parseSemver(to); + if (!f || !t) return null; + if (t[0] !== f[0]) return "major"; + if (t[1] !== f[1]) return "minor"; + if (t[2] !== f[2]) return "patch"; + return null; +} + +export function extractVersionInfo( + title: string +): { from?: string; to?: string; updateType?: "major" | "minor" | "patch" } | null { + // Strip trailing annotations like " [security]" + const cleaned = title.replace(/\s*\[[\w\s]+\]\s*$/, "").trim(); + + // Maintenance titles — no version classification + if (/pin dependencies/i.test(cleaned)) return null; + if (/lock file maintenance/i.test(cleaned)) return null; + + // Dependabot "Bump X from A to B" + const bumpMatch = /\bfrom\s+([\w.\-+]+)\s+to\s+([\w.\-+]+)/i.exec(cleaned); + if (bumpMatch) { + const from = bumpMatch[1]!; + const to = bumpMatch[2]!; + const updateType = semverUpdateType(from, to) ?? undefined; + return { from, to, updateType }; + } + + // Renovate group: "update all major dependencies" + if (/update all major/i.test(cleaned)) return { updateType: "major" }; + // Renovate group: "update all non-major dependencies" + if (/update all non-major/i.test(cleaned)) return { updateType: "minor" }; + + // Renovate single-dep: "update dependency X to vY" or "update X action to vY" + const renovateMatch = /\bupdate\b.+\bto\s+(v?[\w.\-+]+)/i.exec(cleaned); + if (renovateMatch) { + const to = renovateMatch[1]!; + // Only treat as version if it looks like a version (starts with digit or v+digit) + if (/^v?\d/.test(to)) return { to }; + } + + return null; +} + +export function isRebasing(pr: PullRequest, rebaseLabel: string): boolean { + const target = rebaseLabel.toLowerCase(); + // SEC-003: plain string equality, never used in regex constructor + return pr.labels.some((l) => l.name.toLowerCase() === target); +} + +const STALE_THRESHOLD_DEFAULT_DAYS = 14; + +export function classifyDepStatus( + pr: PullRequest, + _rebaseLabel: string, + staleThresholdDays: number = STALE_THRESHOLD_DEFAULT_DAYS +): DepStatus { + // needs-review: enriched, not draft, CI passing, not yet approved + if ( + pr.enriched !== false && + !pr.draft && + pr.checkStatus === "success" && + pr.reviewDecision !== "APPROVED" + ) { + return "needs-review"; + } + + // stale: not updated recently (even drafts/CI-pending get stale) + const ageMs = Date.now() - new Date(pr.updatedAt).getTime(); + if (ageMs > staleThresholdDays * 86_400_000) { + return "stale"; + } + + // waiting: CI pending, draft, rebasing, unenriched, approved-but-not-merged + return "waiting"; +} diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index fed62eb1..07e1f890 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -3,7 +3,7 @@ import { createEffect, onCleanup } from "solid-js"; import { pushNotification } from "../lib/errors"; import { viewState, updateViewState } from "./view"; import { ConfigSchema, RepoRefSchema, THEME_OPTIONS, BUILTIN_TAB_IDS, CustomTabSchema } from "../../shared/schemas"; -import type { Config, ThemeId, CustomTab, JiraConfig, JiraCustomField } from "../../shared/schemas"; +import type { Config, ThemeId, CustomTab, JiraConfig, JiraCustomField, DependencyConfig } from "../../shared/schemas"; import { z } from "zod"; // ── Re-exports from shared/schemas (backward compat for existing importers) ─── @@ -106,6 +106,10 @@ export function updateJiraConfig(partial: Partial): void { updateConfig({ jira: { ...config.jira, ...partial } }); } +export function updateDependencyConfig(partial: Partial): void { + updateConfig({ dependencies: { ...config.dependencies, ...partial } }); +} + export function updateJiraCustomFields(fields: JiraCustomField[]): void { updateJiraConfig({ customFields: fields.slice(0, 10) }); } diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 96069d9f..94aec5dc 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -29,7 +29,7 @@ export const TrackedUserSchema = z.object({ export type TrackedUser = z.infer; -export const BUILTIN_TAB_IDS = ["issues", "pullRequests", "actions", "tracked", "jiraAssigned"] as const; +export const BUILTIN_TAB_IDS = ["issues", "pullRequests", "dependencies", "actions", "tracked", "jiraAssigned"] as const; export type BuiltinTabId = (typeof BUILTIN_TAB_IDS)[number]; export const CustomTabBaseType = z.enum(["issues", "pullRequests", "actions"]); @@ -78,6 +78,12 @@ export const JiraConfigSchema = z.object({ export type JiraConfig = z.infer; +export const DependencyConfigSchema = z.object({ + enabled: z.boolean().default(true), + rebaseLabel: z.string().min(1).max(50).default("rebase"), +}); +export type DependencyConfig = z.infer; + export const ConfigSchema = z.object({ selectedOrgs: z.array(z.string()).default([]), selectedRepos: z.array(RepoRefSchema).default([]), @@ -110,6 +116,7 @@ export const ConfigSchema = z.object({ mcpRelayPort: z.number().int().min(1024).max(65535).default(9876), // Explicit defaults (NOT .default({})) — inner field defaults don't apply with .default({}) per BUG-001 jira: JiraConfigSchema.default({ enabled: false, authMethod: "oauth", issueKeyDetection: true, expandIssueDetails: false, customFields: [], customScopes: [] }), + dependencies: DependencyConfigSchema.default({ enabled: true, rebaseLabel: "rebase" }), }); export type Config = z.infer; diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts new file mode 100644 index 00000000..da48d8cc --- /dev/null +++ b/tests/lib/dependency-detection.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect } from "vitest"; +import { + isDependencyPr, + extractVersionInfo, + classifyDepStatus, + isRebasing, + KNOWN_DEP_BOT_LOGINS, + DEP_BRANCH_PREFIXES, + DEP_TITLE_PATTERN, +} from "../../src/app/lib/dependency-detection.js"; +import { makePullRequest } from "../helpers/factories.js"; + +const NO_TRACKED_BOTS = new Set(); + +describe("isDependencyPr", () => { + it("returns true for known dep bot login (exact)", () => { + const pr = makePullRequest({ userLogin: "dependabot[bot]" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for known dep bot login (case-insensitive)", () => { + const pr = makePullRequest({ userLogin: "Renovate[bot]" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for tracked bot login", () => { + const pr = makePullRequest({ userLogin: "my-custom-bot" }); + const tracked = new Set(["my-custom-bot"]); + expect(isDependencyPr(pr, tracked)).toBe(true); + }); + + it("returns true for tracked bot login (case-insensitive)", () => { + const pr = makePullRequest({ userLogin: "My-Custom-Bot" }); + const tracked = new Set(["my-custom-bot"]); + expect(isDependencyPr(pr, tracked)).toBe(true); + }); + + it("returns true for dependabot branch prefix", () => { + const pr = makePullRequest({ headRef: "dependabot/npm_and_yarn/lodash-4.0.0" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for renovate branch prefix", () => { + const pr = makePullRequest({ headRef: "renovate/react-18.x" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for snyk branch prefix", () => { + const pr = makePullRequest({ headRef: "snyk-fix-abc123" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for branch prefix (case-insensitive)", () => { + const pr = makePullRequest({ headRef: "Renovate/something" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for 'Bump' title pattern", () => { + const pr = makePullRequest({ title: "Bump lodash from 4.0.0 to 4.17.21" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for chore(deps title pattern", () => { + const pr = makePullRequest({ title: "chore(deps): update all major dependencies" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for [Snyk] title pattern", () => { + const pr = makePullRequest({ title: "[Snyk] Security patch for lodash" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns true for 'dependencies' label (case-insensitive)", () => { + const pr = makePullRequest({ labels: [{ name: "Dependencies", color: "0075ca" }] }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + + it("returns false for regular PR", () => { + const pr = makePullRequest({ + userLogin: "octocat", + headRef: "feature/my-feature", + title: "Add new feature", + labels: [], + }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(false); + }); + + it("returns false for regular PR even with tracked users of type user", () => { + const pr = makePullRequest({ userLogin: "alice" }); + const tracked = new Set(["bob"]); + expect(isDependencyPr(pr, tracked)).toBe(false); + }); +}); + +describe("extractVersionInfo", () => { + it("extracts major update from Dependabot bump title", () => { + const result = extractVersionInfo("Bump lodash from 3.0.0 to 4.0.0"); + expect(result).toEqual({ from: "3.0.0", to: "4.0.0", updateType: "major" }); + }); + + it("extracts minor update from Dependabot bump title", () => { + const result = extractVersionInfo("Bump lodash from 4.0.0 to 4.1.0"); + expect(result).toEqual({ from: "4.0.0", to: "4.1.0", updateType: "minor" }); + }); + + it("extracts patch update from Dependabot bump title", () => { + const result = extractVersionInfo("Bump lodash from 4.17.20 to 4.17.21"); + expect(result).toEqual({ from: "4.17.20", to: "4.17.21", updateType: "patch" }); + }); + + it("returns major for Renovate 'update all major dependencies'", () => { + const result = extractVersionInfo("chore(deps): update all major dependencies"); + expect(result).toEqual({ updateType: "major" }); + }); + + it("returns minor for Renovate 'update all non-major dependencies'", () => { + const result = extractVersionInfo("chore(deps): update all non-major dependencies"); + expect(result).toEqual({ updateType: "minor" }); + }); + + it("returns minor for fix(deps) non-major variant", () => { + const result = extractVersionInfo("fix(deps): update all non-major dependencies"); + expect(result).toEqual({ updateType: "minor" }); + }); + + it("extracts to-version for Renovate single-dep title", () => { + const result = extractVersionInfo("chore(deps): update dependency pytest to v9"); + expect(result).toMatchObject({ to: "v9" }); + expect(result?.updateType).toBeUndefined(); + }); + + it("extracts to-version for Renovate action title", () => { + const result = extractVersionInfo("chore(deps): update astral-sh/setup-uv action to v8"); + expect(result).toMatchObject({ to: "v8" }); + expect(result?.updateType).toBeUndefined(); + }); + + it("returns null for 'pin dependencies'", () => { + expect(extractVersionInfo("chore(deps): pin dependencies")).toBeNull(); + }); + + it("returns null for 'lock file maintenance'", () => { + expect(extractVersionInfo("chore(deps): lock file maintenance")).toBeNull(); + }); + + it("returns null for 'fix(deps): pin dependencies'", () => { + expect(extractVersionInfo("fix(deps): pin dependencies")).toBeNull(); + }); + + it("strips [security] suffix before parsing", () => { + const result = extractVersionInfo("chore(deps): update dependency pytest to v9 [security]"); + expect(result).toMatchObject({ to: "v9" }); + }); + + it("does not throw for non-semver bump (date-based version)", () => { + // parseSemver of "22.04" → [22, 4, 0] which parses as valid; result is non-null but that's acceptable + expect(() => extractVersionInfo("Bump ubuntu from 22.04 to 24.04")).not.toThrow(); + }); + + it("returns null for unrecognized title format", () => { + expect(extractVersionInfo("Fix a bug in auth flow")).toBeNull(); + }); +}); + +describe("isRebasing", () => { + it("returns true when rebase label matches exactly", () => { + const pr = makePullRequest({ labels: [{ name: "rebase", color: "ffffff" }] }); + expect(isRebasing(pr, "rebase")).toBe(true); + }); + + it("returns true for case-insensitive label match", () => { + const pr = makePullRequest({ labels: [{ name: "Rebase", color: "ffffff" }] }); + expect(isRebasing(pr, "rebase")).toBe(true); + }); + + it("returns true with custom rebase label", () => { + const pr = makePullRequest({ labels: [{ name: "needs-rebase", color: "ffffff" }] }); + expect(isRebasing(pr, "needs-rebase")).toBe(true); + }); + + it("returns false when label does not match", () => { + const pr = makePullRequest({ labels: [{ name: "bug", color: "d73a4a" }] }); + expect(isRebasing(pr, "rebase")).toBe(false); + }); + + it("returns false when no labels", () => { + const pr = makePullRequest({ labels: [] }); + expect(isRebasing(pr, "rebase")).toBe(false); + }); +}); + +describe("classifyDepStatus", () => { + const RECENT = new Date(Date.now() - 7 * 86_400_000).toISOString(); // 7 days ago + const OLD = new Date(Date.now() - 31 * 86_400_000).toISOString(); // 31 days ago + + it("returns needs-review for enriched, non-draft, passing CI, not approved", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: null, + updatedAt: RECENT, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("needs-review"); + }); + + it("returns needs-review even for old PR if CI passing and not approved", () => { + // needs-review wins over stale when actionable + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: null, + updatedAt: OLD, + }); + const result = classifyDepStatus(pr, "rebase", 14); + // needs-review check runs first and wins + expect(result).toBe("needs-review"); + }); + + it("returns stale for old PR that is not needs-review eligible", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "failure", + reviewDecision: null, + updatedAt: OLD, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("stale"); + }); + + it("returns stale for old draft PR", () => { + const pr = makePullRequest({ + draft: true, + updatedAt: OLD, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("stale"); + }); + + it("returns waiting for recent draft PR", () => { + const pr = makePullRequest({ + draft: true, + updatedAt: RECENT, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("waiting"); + }); + + it("returns waiting for recent PR with pending CI", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "pending", + reviewDecision: null, + updatedAt: RECENT, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("waiting"); + }); + + it("returns waiting for unenriched PR (enriched=false, checkStatus is null)", () => { + const pr = makePullRequest({ + enriched: false, + checkStatus: null, + updatedAt: RECENT, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("waiting"); + }); + + it("returns waiting for approved PR (already handled, not needs-review)", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: "APPROVED", + updatedAt: RECENT, + }); + const result = classifyDepStatus(pr, "rebase", 14); + expect(result).toBe("waiting"); + }); + + it("uses default stale threshold of 14 days when not provided", () => { + const thirteenDaysAgo = new Date(Date.now() - 13 * 86_400_000).toISOString(); + const fifteenDaysAgo = new Date(Date.now() - 15 * 86_400_000).toISOString(); + + const recent = makePullRequest({ draft: true, updatedAt: thirteenDaysAgo }); + const old = makePullRequest({ draft: true, updatedAt: fifteenDaysAgo }); + + expect(classifyDepStatus(recent, "rebase")).toBe("waiting"); + expect(classifyDepStatus(old, "rebase")).toBe("stale"); + }); +}); + +describe("KNOWN_DEP_BOT_LOGINS", () => { + it("contains expected bot logins", () => { + expect(KNOWN_DEP_BOT_LOGINS.has("dependabot[bot]")).toBe(true); + expect(KNOWN_DEP_BOT_LOGINS.has("renovate[bot]")).toBe(true); + expect(KNOWN_DEP_BOT_LOGINS.has("snyk-bot")).toBe(true); + }); +}); + +describe("DEP_BRANCH_PREFIXES", () => { + it("includes dependabot/ and renovate/ prefixes", () => { + expect(DEP_BRANCH_PREFIXES).toContain("dependabot/"); + expect(DEP_BRANCH_PREFIXES).toContain("renovate/"); + }); +}); + +describe("DEP_TITLE_PATTERN", () => { + it("matches Bump prefix", () => { + expect(DEP_TITLE_PATTERN.test("Bump lodash from 1.0 to 2.0")).toBe(true); + }); + + it("does not match unrelated title", () => { + expect(DEP_TITLE_PATTERN.test("Fix authentication bug")).toBe(false); + }); +}); From b37dccff693820cda0ebfe204899804bd5dbc5ff Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 10:56:44 -0400 Subject: [PATCH 02/48] feat(deps): adds Renovate Dashboard abandoned-section parser --- src/app/lib/dependency-dashboard.ts | 118 ++++++++++++ src/app/services/api.ts | 52 ++++++ src/shared/types.ts | 1 + tests/helpers/factories.ts | 1 + tests/lib/dependency-dashboard.test.ts | 238 +++++++++++++++++++++++++ 5 files changed, 410 insertions(+) create mode 100644 src/app/lib/dependency-dashboard.ts create mode 100644 tests/lib/dependency-dashboard.test.ts diff --git a/src/app/lib/dependency-dashboard.ts b/src/app/lib/dependency-dashboard.ts new file mode 100644 index 00000000..2cab06f6 --- /dev/null +++ b/src/app/lib/dependency-dashboard.ts @@ -0,0 +1,118 @@ +import type { Issue, PullRequest } from "../../shared/types.js"; +import { KNOWN_DEP_BOT_LOGINS } from "./dependency-detection.js"; + +export interface AbandonedDependency { + datasource: string; + packageName: string; + lastUpdated: string; +} + +export interface DashboardIssueInfo { + issueNumber: number; + htmlUrl: string; + repoFullName: string; + abandonedDeps: AbandonedDependency[]; +} + +/** SEC-002: escapes ALL regex metacharacters. */ +export function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Finds Renovate Dashboard issues from the issue list. + * Filters by title "Dependency Dashboard" (case-insensitive), bot author, open state, + * and defined nodeId (cached pre-migration issues lack nodeId). + */ +export function findDashboardIssues( + issues: Issue[], + botLogins: Set +): { issueNumber: number; nodeId: string; repoFullName: string; htmlUrl: string }[] { + const allBotLogins = new Set([...KNOWN_DEP_BOT_LOGINS, ...botLogins]); + return issues + .filter((issue) => { + if (issue.nodeId == null) return false; + if (issue.state !== "OPEN") return false; + if (issue.title.toLowerCase() !== "dependency dashboard") return false; + if (!allBotLogins.has(issue.userLogin.toLowerCase())) return false; + return true; + }) + .map((issue) => ({ + issueNumber: issue.number, + nodeId: issue.nodeId!, + repoFullName: issue.repoFullName, + htmlUrl: issue.htmlUrl, + })); +} + +/** + * Parses the abandoned dependencies section from a Renovate Dashboard issue body. + * Fault-tolerant: returns [] on any parse failure — never throws. + */ +export function parseAbandonedSection(body: string): AbandonedDependency[] { + try { + if (!body) return []; + + // Find the Abandoned section header (## or ###) + const abandonedMatch = /^#{2,3}\s+Abandoned\b/im.exec(body); + if (!abandonedMatch) return []; + + const sectionStart = abandonedMatch.index; + // Find the next section header (same or higher level) to bound our search + const afterSection = body.slice(sectionStart + abandonedMatch[0].length); + const nextHeaderMatch = /^#{2,3}\s+/m.exec(afterSection); + const sectionBody = nextHeaderMatch + ? afterSection.slice(0, nextHeaderMatch.index) + : afterSection; + + // Find the table — look for rows with pipe-separated values + // Table format: | Datasource | Package | Last Updated | + // Skip the header row and separator row, then parse data rows + const tableRows = sectionBody + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("|") && l.endsWith("|")); + + if (tableRows.length < 3) return []; // need header + separator + at least one data row + + const deps: AbandonedDependency[] = []; + // Skip header (index 0) and separator (index 1) + for (let i = 2; i < tableRows.length; i++) { + const row = tableRows[i]!; + // Split on pipe, trim cells, drop leading/trailing empty from outer pipes + const cells = row + .split("|") + .map((c) => c.trim()) + .filter((_, idx, arr) => idx > 0 && idx < arr.length - 1); + + if (cells.length < 3) continue; + const [datasource, packageName, lastUpdated] = cells as [string, string, string, ...string[]]; + if (!datasource || !packageName || !lastUpdated) continue; + + deps.push({ datasource, packageName, lastUpdated }); + } + + return deps; + } catch { + return []; + } +} + +/** + * Checks if a dep PR's title references an abandoned package name. + * Uses word-boundary regex with escaped package name (SEC-002). + */ +export function matchAbandonedToPr( + pr: PullRequest, + abandonedDeps: AbandonedDependency[] +): AbandonedDependency | null { + for (const dep of abandonedDeps) { + // SEC-002: escape package name before using in regex + // Use (?:^|\W) and (?:\W|$) instead of \b to correctly handle scoped packages like @scope/pkg + // \b fails when package name starts/ends with non-word chars (e.g. @, /) + const escaped = escapeRegex(dep.packageName); + const pattern = new RegExp("(?:^|\\W)" + escaped + "(?:\\W|$)", "i"); + if (pattern.test(pr.title)) return dep; + } + return null; +} diff --git a/src/app/services/api.ts b/src/app/services/api.ts index cf13b193..d35d4432 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -158,6 +158,7 @@ export async function pooledAllSettled( // ── GraphQL search types ───────────────────────────────────────────────────── interface GraphQLIssueNode { + id: string; databaseId: number; number: number; title: string; @@ -201,6 +202,7 @@ interface ForkQueryResponse { const LIGHT_ISSUE_FRAGMENT = ` fragment LightIssueFields on Issue { + id databaseId number title @@ -627,6 +629,7 @@ function processIssueNode( repoFullName: node.repository.nameWithOwner, comments: node.comments.totalCount, starCount: node.repository.stargazerCount, + nodeId: node.id, }); return true; } @@ -1153,6 +1156,55 @@ export async function fetchPREnrichment( return { enrichments, errors }; } +// ── Dashboard issue body fetch ──────────────────────────────────────────────── + +const DASHBOARD_ISSUE_BODIES_QUERY = ` + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on Issue { id body } + } + rateLimit { cost limit remaining resetAt } + } +`; + +interface DashboardIssueBodiesResponse { + nodes: Array<{ id: string; body: string | null } | null>; + rateLimit?: GraphQLRateLimit; +} + +/** Fetches issue bodies for Dashboard issues by node ID (single nodes() batch query). */ +export async function fetchDashboardIssueBodies( + octokit: GitHubOctokit, + issueNodeIds: string[] +): Promise> { + const result = new Map(); + if (issueNodeIds.length === 0) return result; + + const batches = chunkArray(issueNodeIds, NODES_BATCH_SIZE); + await Promise.allSettled(batches.map(async (batch) => { + try { + const response = await octokit.graphql( + DASHBOARD_ISSUE_BODIES_QUERY, + { ids: batch, request: { apiSource: "dashboardBodies" } } + ); + if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); + for (const node of response.nodes) { + if (!node) continue; + result.set(node.id, node.body); + } + } 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); + // Partial failures return null bodies — callers handle missing entries gracefully + } + })); + + return result; +} + /** * Merges phase 2 enrichment data into light PRs. Returns enriched PR array. * Also detects fork PRs for the statusCheckRollup fallback. diff --git a/src/shared/types.ts b/src/shared/types.ts index 027d58ec..06fd5ce0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -36,6 +36,7 @@ export interface Issue { comments: number; starCount?: number; surfacedBy?: string[]; + nodeId?: string; } export interface CheckStatus { diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index f21723f1..3dd43590 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -18,6 +18,7 @@ export function makeIssue(overrides: Partial = {}): Issue { assigneeLogins: [], repoFullName: "owner/repo", comments: 0, + nodeId: "I_test_node_id", ...overrides, }; } diff --git a/tests/lib/dependency-dashboard.test.ts b/tests/lib/dependency-dashboard.test.ts new file mode 100644 index 00000000..66147894 --- /dev/null +++ b/tests/lib/dependency-dashboard.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from "vitest"; +import { + findDashboardIssues, + parseAbandonedSection, + matchAbandonedToPr, + escapeRegex, +} from "../../src/app/lib/dependency-dashboard.js"; +import { makeIssue, makePullRequest } from "../helpers/factories.js"; + +const NO_BOT_LOGINS = new Set(); + +describe("escapeRegex", () => { + it("escapes regex metacharacters", () => { + expect(escapeRegex("@scope/pkg.name")).toBe("@scope/pkg\\.name"); + expect(escapeRegex("a+b*c")).toBe("a\\+b\\*c"); + expect(escapeRegex("(foo|bar)")).toBe("\\(foo\\|bar\\)"); + expect(escapeRegex("a[0]")).toBe("a\\[0\\]"); + expect(escapeRegex("a{1,3}")).toBe("a\\{1,3\\}"); + expect(escapeRegex("a^b$c")).toBe("a\\^b\\$c"); + expect(escapeRegex("a?b")).toBe("a\\?b"); + expect(escapeRegex("a\\b")).toBe("a\\\\b"); + }); + + it("leaves plain strings unchanged", () => { + expect(escapeRegex("lodash")).toBe("lodash"); + expect(escapeRegex("react-dom")).toBe("react-dom"); + }); +}); + +describe("findDashboardIssues", () => { + it("finds open Dependency Dashboard issues from known bot logins", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "OPEN", + userLogin: "renovate[bot]", + nodeId: "I_renovate_dash_1", + htmlUrl: "https://github.com/owner/repo/issues/42", + repoFullName: "owner/repo", + number: 42, + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + issueNumber: 42, + nodeId: "I_renovate_dash_1", + repoFullName: "owner/repo", + htmlUrl: "https://github.com/owner/repo/issues/42", + }); + }); + + it("finds issues from tracked bot logins", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "OPEN", + userLogin: "my-custom-bot", + nodeId: "I_custom_bot_dash", + }); + const result = findDashboardIssues([issue], new Set(["my-custom-bot"])); + expect(result).toHaveLength(1); + }); + + it("is case-insensitive for title matching", () => { + const issue = makeIssue({ + title: "dependency dashboard", + state: "OPEN", + userLogin: "renovate[bot]", + nodeId: "I_case_test", + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(1); + }); + + it("rejects issues without nodeId", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "OPEN", + userLogin: "renovate[bot]", + nodeId: undefined, + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(0); + }); + + it("rejects closed issues", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "CLOSED", + userLogin: "renovate[bot]", + nodeId: "I_closed", + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(0); + }); + + it("rejects non-bot author", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "OPEN", + userLogin: "octocat", + nodeId: "I_human_author", + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(0); + }); + + it("rejects wrong title", () => { + const issue = makeIssue({ + title: "Dependencies", + state: "OPEN", + userLogin: "renovate[bot]", + nodeId: "I_wrong_title", + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(0); + }); + + it("returns empty array for empty input", () => { + expect(findDashboardIssues([], NO_BOT_LOGINS)).toHaveLength(0); + }); +}); + +// A realistic Renovate Dashboard body excerpt with an Abandoned section +const RENOVATE_BODY_WITH_ABANDONED = ` +## Rate-Limited + +Nothing yet. + +## Abandoned + +
+Packages are abandoned + +| Datasource | Package | Last Updated | +|------------|---------|--------------| +| npm | lodash | 2023-01-15 | +| pypi | requests | 2022-11-20 | +| npm | @scope/utils | 2023-03-01 | + +
+ +## Open +`; + +const RENOVATE_BODY_NO_ABANDONED = ` +## Rate-Limited + +Nothing yet. + +## Open + +Some PRs here. +`; + +describe("parseAbandonedSection", () => { + it("parses valid abandoned deps table", () => { + const result = parseAbandonedSection(RENOVATE_BODY_WITH_ABANDONED); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ datasource: "npm", packageName: "lodash", lastUpdated: "2023-01-15" }); + expect(result[1]).toEqual({ datasource: "pypi", packageName: "requests", lastUpdated: "2022-11-20" }); + expect(result[2]).toEqual({ datasource: "npm", packageName: "@scope/utils", lastUpdated: "2023-03-01" }); + }); + + it("returns empty array when no Abandoned section", () => { + expect(parseAbandonedSection(RENOVATE_BODY_NO_ABANDONED)).toEqual([]); + }); + + it("returns empty array for empty body", () => { + expect(parseAbandonedSection("")).toEqual([]); + }); + + it("returns empty array for malformed table (too few rows)", () => { + const body = `## Abandoned\n| Datasource | Package | Last Updated |\n`; + expect(parseAbandonedSection(body)).toEqual([]); + }); + + it("returns empty array for body with no table rows after separator", () => { + const body = `## Abandoned\n| Datasource | Package | Last Updated |\n|---|---|---|\n`; + expect(parseAbandonedSection(body)).toEqual([]); + }); + + it("is fault-tolerant on truncated body", () => { + const truncated = RENOVATE_BODY_WITH_ABANDONED.slice(0, 80); + expect(() => parseAbandonedSection(truncated)).not.toThrow(); + }); + + it("handles ### header variant", () => { + const body = `### Abandoned\n\n| Datasource | Package | Last Updated |\n|---|---|---|\n| npm | lodash | 2023-01-15 |\n`; + const result = parseAbandonedSection(body); + expect(result).toHaveLength(1); + expect(result[0]?.packageName).toBe("lodash"); + }); + + it("returns empty array on completely invalid input", () => { + expect(parseAbandonedSection("not markdown at all")).toEqual([]); + expect(parseAbandonedSection("## Abandoned\nno table here")).toEqual([]); + }); +}); + +describe("matchAbandonedToPr", () => { + const deps = [ + { datasource: "npm", packageName: "lodash", lastUpdated: "2023-01-15" }, + { datasource: "pypi", packageName: "requests", lastUpdated: "2022-11-20" }, + { datasource: "npm", packageName: "@scope/utils", lastUpdated: "2023-03-01" }, + ]; + + it("returns matching dep when PR title contains package name", () => { + const pr = makePullRequest({ title: "Bump lodash from 4.0.0 to 4.17.21" }); + const result = matchAbandonedToPr(pr, deps); + expect(result).toEqual(deps[0]); + }); + + it("is case-insensitive", () => { + const pr = makePullRequest({ title: "Bump Lodash from 4.0.0 to 4.17.21" }); + expect(matchAbandonedToPr(pr, deps)).toEqual(deps[0]); + }); + + it("returns null when no package matches", () => { + const pr = makePullRequest({ title: "Bump axios from 0.27 to 1.0.0" }); + expect(matchAbandonedToPr(pr, deps)).toBeNull(); + }); + + it("returns null for empty abandoned deps list", () => { + const pr = makePullRequest({ title: "Bump lodash from 4.0.0 to 4.17.21" }); + expect(matchAbandonedToPr(pr, [])).toBeNull(); + }); + + it("handles package names with regex metacharacters (@scope/utils)", () => { + const pr = makePullRequest({ title: "chore(deps): update dependency @scope/utils to v2" }); + const result = matchAbandonedToPr(pr, deps); + expect(result).toEqual(deps[2]); + }); + + it("does not throw on adversarial package names with metacharacters", () => { + const adversarial = [{ datasource: "npm", packageName: "a+b*c?d", lastUpdated: "2023-01-01" }]; + const pr = makePullRequest({ title: "Bump something from 1.0 to 2.0" }); + expect(() => matchAbandonedToPr(pr, adversarial)).not.toThrow(); + }); +}); From 3b0f15855b21e65d05412b24cd2c023a4ad17070 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:01:45 -0400 Subject: [PATCH 03/48] feat(deps): add dependencies tab filter schema and TabBar integration --- src/app/components/layout/TabBar.tsx | 9 ++++ src/app/stores/view.ts | 18 +++++++- tests/components/layout/TabBar.test.tsx | 59 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/app/components/layout/TabBar.tsx b/src/app/components/layout/TabBar.tsx index 610f275d..8fa29906 100644 --- a/src/app/components/layout/TabBar.tsx +++ b/src/app/components/layout/TabBar.tsx @@ -13,6 +13,7 @@ interface TabBarProps { enableTracking?: boolean; enableActions?: boolean; enableJira?: boolean; + enableDependencies?: boolean; customTabs?: Array<{ id: string; name: string }>; onAddTab?: () => void; onEditTab?: (id: string) => void; @@ -37,6 +38,14 @@ export default function TabBar(props: TabBarProps) { {props.counts?.pullRequests} + + + Dependencies + + {props.counts?.dependencies} + + + Actions diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index a81fff49..30fddb98 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -46,6 +46,14 @@ export const ActionsFiltersSchema = z.object({ event: z.enum(["all", "push", "pull_request", "schedule", "workflow_dispatch", "other"]).default("all"), }); +export const DependencyFiltersSchema = z.object({ + updateType: z.enum(["all", "major", "minor", "patch"]).default("all"), + bot: z.string().default("all"), +}); + +export type DependencyFilters = z.infer; +export type DependencyFilterField = keyof DependencyFilters; + // "done" intentionally excluded — JQL `statusCategory != Done` never returns Done items export const JiraFiltersSchema = z.object({ scope: z.enum(["assigned", "reported", "watching"]).or(z.string().regex(/^[a-zA-Z0-9_\-]+$/).max(100)).default("assigned"), @@ -93,11 +101,13 @@ export const ViewStateSchema = z.object({ pullRequests: PullRequestFiltersSchema.default({ scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }), actions: ActionsFiltersSchema.default({ conclusion: "all", event: "all" }), jiraAssigned: JiraFiltersSchema.default({ scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }), + dependencies: DependencyFiltersSchema.default({ updateType: "all", bot: "all" }), }).default({ issues: { scope: "involves_me", role: "all", comments: "all", user: "all" }, pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, + dependencies: { updateType: "all", bot: "all" }, }), showPrRuns: z.boolean().default(false), hideDepDashboard: z.boolean().default(true), @@ -199,6 +209,7 @@ export function resetViewState(): void { pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, + dependencies: { updateType: "all", bot: "all" }, }, showPrRuns: false, hideDepDashboard: true, @@ -282,6 +293,7 @@ type TabFilterField = { pullRequests: keyof PullRequestFilters; actions: keyof ActionsFilters; jiraAssigned: keyof JiraFilters; + dependencies: keyof DependencyFilters; }; export function setTabFilter( @@ -297,7 +309,7 @@ export function setTabFilter( } export function resetAllTabFilters( - tab: "issues" | "pullRequests" | "actions" | "jiraAssigned" + tab: "issues" | "pullRequests" | "actions" | "jiraAssigned" | "dependencies" ): void { setViewState( produce((draft) => { @@ -307,8 +319,10 @@ export function resetAllTabFilters( draft.tabFilters.pullRequests = PullRequestFiltersSchema.parse({}); } else if (tab === "jiraAssigned") { draft.tabFilters.jiraAssigned = JiraFiltersSchema.parse({}); - } else { + } else if (tab === "actions") { draft.tabFilters.actions = ActionsFiltersSchema.parse({}); + } else if (tab === "dependencies") { + draft.tabFilters.dependencies = DependencyFiltersSchema.parse({}); } }) ); diff --git a/tests/components/layout/TabBar.test.tsx b/tests/components/layout/TabBar.test.tsx index fd17fc09..99d73608 100644 --- a/tests/components/layout/TabBar.test.tsx +++ b/tests/components/layout/TabBar.test.tsx @@ -310,5 +310,64 @@ describe("TabBar", () => { )); expect(screen.getByRole("tab", { name: /Issues/ })).toBeDefined(); expect(screen.getByRole("tab", { name: /Pull Requests/ })).toBeDefined(); + + // ── Dependencies tab ───────────────────────────────────────────────────────── + + it("does not render Dependencies tab when enableDependencies is false", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.queryByRole("tab", { name: /Dependencies/i })).toBeNull(); + }); + + it("does not render Dependencies tab when enableDependencies is undefined", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.queryByRole("tab", { name: /Dependencies/i })).toBeNull(); + }); + + it("renders Dependencies tab when enableDependencies is true", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + screen.getByRole("tab", { name: /Dependencies/i }); + }); + + it("renders Dependencies tab between Pull Requests and Actions", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + const tabs = screen.getAllByRole("tab"); + const names = tabs.map((t) => t.textContent?.trim() ?? ""); + const prIdx = names.findIndex((n) => /Pull Requests/i.test(n)); + const depIdx = names.findIndex((n) => /Dependencies/i.test(n)); + const actionsIdx = names.findIndex((n) => /^Actions/i.test(n)); + expect(depIdx).toBeGreaterThan(prIdx); + expect(depIdx).toBeLessThan(actionsIdx); + }); + + it("shows dependencies count badge when enableDependencies is true and count provided", () => { + const onTabChange = vi.fn(); + const counts: TabCounts = { dependencies: 8 }; + render(() => ( + + )); + screen.getByText("8"); + }); + + it("calls onTabChange with 'dependencies' when Dependencies tab clicked", async () => { + const user = userEvent.setup(); + const onTabChange = vi.fn(); + render(() => ( + + )); + const depTab = screen.getByRole("tab", { name: /Dependencies/i }); + await user.click(depTab); + expect(onTabChange).toHaveBeenCalledWith("dependencies"); }); }); From d5e89bfd32ea5f7fa3a904817f166b6e42d23c0e Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:08:16 -0400 Subject: [PATCH 04/48] feat(deps): add dependencies settings section --- src/app/components/settings/SettingsPage.tsx | 33 +++++++++++- .../components/settings/SettingsPage.test.tsx | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index fabfc7b0..f494c4ec 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-j import * as Sentry from "@sentry/solid"; import { getRelayStatus } from "../../lib/mcp-relay"; import { useNavigate } from "@solidjs/router"; -import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config"; +import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab, updateDependencyConfig } from "../../stores/config"; import type { Config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated } from "../../stores/auth"; @@ -348,6 +348,7 @@ export default function SettingsPage() { { value: "pullRequests", label: "Pull Requests" }, ...(config.enableActions ? [{ value: "actions", label: "GitHub Actions" }] : []), ...(config.enableTracking ? [{ value: "tracked", label: "Tracked Items" }] : []), + ...(config.dependencies?.enabled ? [{ value: "dependencies", label: "Dependencies" }] : []), ...(config.jira?.enabled ? [{ value: "jiraAssigned", label: "Jira" }] : []), ...config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ value: t.id, label: t.name })), ]); @@ -1130,6 +1131,36 @@ export default function SettingsPage() { + {/* Dependencies */} +
+ + updateDependencyConfig({ enabled: !(config.dependencies?.enabled ?? true) })} + /> + + + updateDependencyConfig({ rebaseLabel: e.currentTarget.value || "rebase" })} + /> + +
+ {/* Data */}
{/* Authentication method */} diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index c8c85651..8e014ca7 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -897,3 +897,57 @@ describe("SettingsPage — monitor toggle wiring", () => { expect(json.monitoredRepos).toEqual([{ owner: "org", name: "repo1", fullName: "org/repo1" }]); }); }); + +// ── Dependencies section ────────────────────────────────────────────────────── + +describe("Dependencies settings section", () => { + it("renders Dependencies section heading", () => { + renderSettings(); + screen.getByRole("heading", { name: "Dependencies" }); + }); + + it("renders Dependencies tab toggle checked when enabled", () => { + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + renderSettings(); + const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); + expect(toggle.checked).toBe(true); + }); + + it("renders Dependencies tab toggle unchecked when disabled", () => { + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + renderSettings(); + const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); + expect(toggle.checked).toBe(false); + }); + + it("toggles dependencies.enabled when checkbox is clicked", () => { + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + renderSettings(); + const toggle = screen.getByRole("checkbox", { name: /Enable dependencies tab/i }); + fireEvent.click(toggle); + expect(config.dependencies.enabled).toBe(false); + }); + + it("renders rebase label input with current value", () => { + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase-please" } }); + renderSettings(); + const input = screen.getByRole("textbox", { name: /Rebase label/i }); + expect(input.value).toBe("rebase-please"); + }); + + it("updates dependencies.rebaseLabel on input change", () => { + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + renderSettings(); + const input = screen.getByRole("textbox", { name: /Rebase label/i }); + fireEvent.input(input, { target: { value: "rebase-please" } }); + expect(config.dependencies.rebaseLabel).toBe("rebase-please"); + }); + + it("rebase label input falls back to 'rebase' when cleared", () => { + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + renderSettings(); + const input = screen.getByRole("textbox", { name: /Rebase label/i }); + fireEvent.input(input, { target: { value: "" } }); + expect(config.dependencies.rebaseLabel).toBe("rebase"); + }); +}); From dcae5d0171e82b9ba70cf012e0bdd2198d831e0b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:18:17 -0400 Subject: [PATCH 05/48] feat(deps): add DependenciesTab with status-based grouping and abandoned pills --- .../components/dashboard/DependenciesTab.tsx | 358 +++++++++++++++++ .../dashboard/DependenciesTab.test.tsx | 374 ++++++++++++++++++ 2 files changed, 732 insertions(+) create mode 100644 src/app/components/dashboard/DependenciesTab.tsx create mode 100644 tests/components/dashboard/DependenciesTab.test.tsx diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx new file mode 100644 index 00000000..90e3294d --- /dev/null +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -0,0 +1,358 @@ +import { createMemo, createSignal, For, Show } from "solid-js"; +import { config } from "../../stores/config"; +import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem } from "../../stores/view"; +import { isSafeGitHubUrl } from "../../lib/url"; +import type { PullRequest } from "../../services/api"; +import type { AbandonedDependency } from "../../lib/dependency-dashboard"; +import { classifyDepStatus, extractVersionInfo, isRebasing, type DepStatus } from "../../lib/dependency-detection"; +import { matchAbandonedToPr } from "../../lib/dependency-dashboard"; +import type { FilterChipGroupDef } from "../shared/filterTypes"; +import FilterToolbar from "../shared/FilterToolbar"; +import ItemRow from "./ItemRow"; +import SkeletonRows from "../shared/SkeletonRows"; + +const DEP_FILTER_DEFAULTS = { updateType: "all" as const, bot: "all" }; + +const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { + label: "Update type", + field: "updateType", + options: [ + { value: "all", label: "All" }, + { value: "major", label: "Major" }, + { value: "minor", label: "Minor" }, + { value: "patch", label: "Patch" }, + ], +}; + +const STALE_THRESHOLD_DAYS = 14; + +interface ClassifiedPR { + pr: PullRequest; + status: DepStatus; + versionInfo: ReturnType; + rebasing: boolean; + abandonedDep: AbandonedDependency | null; +} + +interface DependenciesTabProps { + pullRequests: PullRequest[]; + loading?: boolean; + userLogin: string; + trackedBotLogins: Set; + abandonedDepsMap: Map; + dashboardIssueUrls: Map; + hotPollingPRIds?: ReadonlySet; + refreshTick?: number; + rebaseLabel: string; +} + +export default function DependenciesTab(props: DependenciesTabProps) { + const [expandedGroups, setExpandedGroups] = createSignal>( + new Set(["needs-review"]) + ); + + function toggleGroup(status: DepStatus) { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(status)) next.delete(status); + else next.add(status); + return next; + }); + } + + 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: [ + { value: "all", label: "All" }, + ...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 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(); + return props.pullRequests + .filter((pr) => { + if (pr.state !== "OPEN") return false; + if (ignoredIds().has(pr.id)) return false; + + // Bot filter + if (filters.bot !== "all" && pr.userLogin !== filters.bot) return false; + + // updateType filter — pass through when updateType is null (unknown) + if (filters.updateType !== "all") { + const vi = extractVersionInfo(pr.title); + if (vi !== null && vi.updateType !== undefined && vi.updateType !== filters.updateType) return false; + } + + return true; + }) + .map((pr) => { + const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; + return { + pr, + status: classifyDepStatus(pr, props.rebaseLabel, STALE_THRESHOLD_DAYS), + versionInfo: extractVersionInfo(pr.title), + rebasing: isRebasing(pr, props.rebaseLabel), + abandonedDep: matchAbandonedToPr(pr, abandonedDeps), + }; + }) + .sort((a, b) => (a.pr.updatedAt < b.pr.updatedAt ? 1 : a.pr.updatedAt > b.pr.updatedAt ? -1 : 0)); + }); + + const statusGroups = createMemo(() => { + const groups: Record = { + "needs-review": [], + waiting: [], + stale: [], + }; + for (const item of classifiedPRs()) { + groups[item.status].push(item); + } + return groups; + }); + + 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 ( +
+ {/* Filter toolbar */} +
+ setTabFilter("dependencies", f as "updateType" | "bot", v)} + onResetAll={() => resetAllTabFilters("dependencies")} + /> +
+ + {/* Loading skeleton */} + + + + + {/* Empty state */} + p.state === "OPEN").length === 0}> +
+ +

No open dependency update PRs

+

Your dependencies are up to date!

+
+
+ + {/* No results from filter */} + p.state === "OPEN").length > 0}> +
+

No PRs match your current filters

+
+
+ + {/* Status groups */} + 0}> +
+ toggleGroup("needs-review")} + dashboardIssueUrls={props.dashboardIssueUrls} + hotPollingPRIds={props.hotPollingPRIds} + refreshTick={props.refreshTick} + trackedPrIds={trackedPrIds()} + enableTracking={config.enableTracking} + onIgnore={handleIgnore} + onTrack={handleTrack} + /> + toggleGroup("waiting")} + dashboardIssueUrls={props.dashboardIssueUrls} + hotPollingPRIds={props.hotPollingPRIds} + refreshTick={props.refreshTick} + trackedPrIds={trackedPrIds()} + enableTracking={config.enableTracking} + onIgnore={handleIgnore} + onTrack={handleTrack} + /> + toggleGroup("stale")} + dashboardIssueUrls={props.dashboardIssueUrls} + hotPollingPRIds={props.hotPollingPRIds} + refreshTick={props.refreshTick} + trackedPrIds={trackedPrIds()} + enableTracking={config.enableTracking} + onIgnore={handleIgnore} + onTrack={handleTrack} + /> +
+
+
+ ); +} + +interface StatusGroupProps { + status: DepStatus; + label: string; + badgeClass: 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 StatusGroup(props: StatusGroupProps) { + return ( + 0}> +
+ {/* Group header */} + + + {/* PR rows */} + +
+ + {({ pr, versionInfo, rebasing, abandonedDep }) => { + const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); + 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)} + > +
+ {/* Version badge */} + + {(updateType) => ( + + {updateType()} + + )} + + + {/* Bot name */} + {pr.userLogin} + + {/* Rebase indicator */} + + Rebasing + + + {/* Draft indicator */} + + Draft + + + {/* Abandoned dep pill — SEC-001: URL validated before use as href */} + + Abandoned dep + } + > + e.stopPropagation()} + > + Abandoned dep + + + +
+
+
+ ); + }} +
+
+
+
+
+ ); +} diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx new file mode 100644 index 00000000..e33eaa11 --- /dev/null +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -0,0 +1,374 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, fireEvent } from "@solidjs/testing-library"; + +// ── localStorage mock (must be before imports that read localStorage) ───────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: (url: string) => typeof url === "string" && url.startsWith("https://github.com"), + openGitHubUrl: vi.fn(), +})); + +// ── Imports (after mocks and localStorage setup) ────────────────────────────── + +import { render } from "@solidjs/testing-library"; +import DependenciesTab from "../../../src/app/components/dashboard/DependenciesTab.js"; +import { resetConfig } from "../../../src/app/stores/config.js"; +import { setTabFilter, resetViewState } from "../../../src/app/stores/view.js"; +import { makePullRequest } from "../../helpers/factories.js"; +import type { AbandonedDependency } from "../../../src/app/lib/dependency-dashboard.js"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const EMPTY_MAPS = { + abandonedDepsMap: new Map(), + dashboardIssueUrls: new Map(), +}; + +const BASE_PROPS = { + userLogin: "testuser", + trackedBotLogins: new Set(), + rebaseLabel: "rebase", + ...EMPTY_MAPS, +}; + +function renderTab(overrides: Partial[0]> = {}) { + const props = { ...BASE_PROPS, pullRequests: [], ...overrides }; + return render(() => ); +} + +// A PR that lands in "needs-review" (enriched, not draft, CI passing, not approved) +function makeNeedsReviewPR(overrides: Parameters[0] = {}) { + return makePullRequest({ + userLogin: "renovate[bot]", + headRef: "renovate/lodash-4.x", + title: "chore(deps): update dependency lodash to v5", + checkStatus: "success", + reviewDecision: null, + draft: false, + enriched: true, + state: "OPEN", + repoFullName: "owner/repo", + ...overrides, + }); +} + +// A PR that lands in "waiting" (CI pending, recent so not stale) +function makeWaitingPR(overrides: Parameters[0] = {}) { + const recentDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2h ago + return makePullRequest({ + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + title: "Bump axios from 0.27.2 to 1.0.0", + checkStatus: "pending", + draft: false, + enriched: true, + updatedAt: recentDate, + state: "OPEN", + repoFullName: "owner/repo", + ...overrides, + }); +} + +// A PR that lands in "stale" (updatedAt >14 days ago) +function makeStalePR(overrides: Parameters[0] = {}) { + const oldDate = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(); + return makePullRequest({ + userLogin: "renovate[bot]", + headRef: "renovate/react-18.x", + title: "chore(deps): update dependency react to v18", + checkStatus: "pending", + draft: false, + enriched: true, + updatedAt: oldDate, + state: "OPEN", + repoFullName: "owner/repo", + ...overrides, + }); +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + localStorageMock.clear(); + resetViewState(); + resetConfig(); + vi.clearAllMocks(); +}); + +// ── Empty state ─────────────────────────────────────────────────────────────── + +describe("DependenciesTab — empty state", () => { + it("shows empty state message when no PRs", () => { + renderTab({ pullRequests: [] }); + expect(screen.getByText("No open dependency update PRs")).toBeDefined(); + expect(screen.getByText("Your dependencies are up to date!")).toBeDefined(); + }); + + it("does not show status group headers when empty", () => { + renderTab({ pullRequests: [] }); + expect(screen.queryByText("Needs Review")).toBeNull(); + expect(screen.queryByText("Waiting")).toBeNull(); + expect(screen.queryByText("Stale")).toBeNull(); + }); +}); + +// ── Status groups ───────────────────────────────────────────────────────────── + +describe("DependenciesTab — status groups", () => { + it("renders Needs Review group for enriched passing PRs", () => { + const pr = makeNeedsReviewPR(); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("Needs Review")).toBeDefined(); + }); + + it("renders Waiting group for CI-pending PRs", () => { + const pr = makeWaitingPR(); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("Waiting")).toBeDefined(); + }); + + it("renders Stale group for old PRs", () => { + const pr = makeStalePR(); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("Stale")).toBeDefined(); + }); + + it("shows Needs Review expanded by default — PR title visible", () => { + const pr = makeNeedsReviewPR(); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText(pr.title)).toBeDefined(); + }); + + it("Waiting group collapsed by default — PR title not visible", () => { + const pr = makeWaitingPR(); + renderTab({ pullRequests: [pr] }); + expect(screen.queryByText(pr.title)).toBeNull(); + }); + + it("expands Waiting group when header is clicked", () => { + const pr = makeWaitingPR(); + renderTab({ pullRequests: [pr] }); + const header = screen.getByText("Waiting").closest("button")!; + fireEvent.click(header); + expect(screen.getByText(pr.title)).toBeDefined(); + }); + + it("collapses an expanded group on second click", () => { + const pr = makeNeedsReviewPR(); + renderTab({ pullRequests: [pr] }); + const header = screen.getByText("Needs Review").closest("button")!; + fireEvent.click(header); + expect(screen.queryByText(pr.title)).toBeNull(); + }); + + it("shows count badge in group header", () => { + const pr1 = makeNeedsReviewPR(); + const pr2 = makeNeedsReviewPR({ title: "chore(deps): update dependency react to v18" }); + renderTab({ pullRequests: [pr1, pr2] }); + const header = screen.getByText("Needs Review").closest("button")!; + expect(header.textContent).toContain("2"); + }); +}); + +// ── Stale threshold ─────────────────────────────────────────────────────────── + +describe("DependenciesTab — stale threshold", () => { + it("PR updated 15 days ago is classified stale", () => { + const pr = makeStalePR(); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("Stale")).toBeDefined(); + }); + + it("PR updated 12 hours ago is not stale (goes to waiting)", () => { + const recentDate = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); + const pr = makeWaitingPR({ updatedAt: recentDate }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("Waiting")).toBeDefined(); + expect(screen.queryByText("Stale")).toBeNull(); + }); +}); + +// ── Version badges ───────────────────────────────────────────────────────────── + +describe("DependenciesTab — version badges", () => { + it("shows 'major' badge for major version bump", () => { + const pr = makeNeedsReviewPR({ title: "Bump lodash from 3.10.0 to 4.0.0" }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("major")).toBeDefined(); + }); + + it("shows 'minor' badge for minor version bump", () => { + const pr = makeNeedsReviewPR({ title: "Bump lodash from 4.16.0 to 4.17.0" }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("minor")).toBeDefined(); + }); + + it("shows 'patch' badge for patch version bump", () => { + const pr = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("patch")).toBeDefined(); + }); + + it("shows no version badge for maintenance titles", () => { + const pr = makeNeedsReviewPR({ title: "chore(deps): pin dependencies" }); + renderTab({ pullRequests: [pr] }); + expect(screen.queryByText("major")).toBeNull(); + expect(screen.queryByText("minor")).toBeNull(); + expect(screen.queryByText("patch")).toBeNull(); + }); +}); + +// ── Rebase indicator ────────────────────────────────────────────────────────── + +describe("DependenciesTab — rebase indicator", () => { + it("shows 'Rebasing' when PR has the rebase label", () => { + const pr = makeNeedsReviewPR({ labels: [{ name: "rebase", color: "ededed" }] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + expect(screen.getByText("Rebasing")).toBeDefined(); + }); + + it("does not show 'Rebasing' when label does not match", () => { + const pr = makeNeedsReviewPR({ labels: [] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + expect(screen.queryByText("Rebasing")).toBeNull(); + }); + + it("rebase label check is case-insensitive", () => { + const pr = makeNeedsReviewPR({ labels: [{ name: "Rebase", color: "ededed" }] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + expect(screen.getByText("Rebasing")).toBeDefined(); + }); +}); + +// ── Abandoned dep pill ──────────────────────────────────────────────────────── + +describe("DependenciesTab — abandoned dep pill", () => { + it("shows 'Abandoned dep' pill when PR title matches abandoned package", () => { + const pr = makeNeedsReviewPR({ + title: "chore(deps): update dependency lodash to v5", + repoFullName: "owner/repo", + }); + const abandonedDepsMap = new Map([ + ["owner/repo", [{ datasource: "npm", packageName: "lodash", lastUpdated: "2024-01-01" }]], + ]); + const dashboardIssueUrls = new Map([ + ["owner/repo", "https://github.com/owner/repo/issues/1"], + ]); + renderTab({ pullRequests: [pr], abandonedDepsMap, dashboardIssueUrls }); + expect(screen.getByText("Abandoned dep")).toBeDefined(); + }); + + it("does not show pill when no abandoned dep match", () => { + const pr = makeNeedsReviewPR({ title: "Bump react from 17.0.0 to 18.0.0" }); + const abandonedDepsMap = new Map([ + ["owner/repo", [{ datasource: "npm", packageName: "lodash", lastUpdated: "2024-01-01" }]], + ]); + renderTab({ pullRequests: [pr], abandonedDepsMap }); + expect(screen.queryByText("Abandoned dep")).toBeNull(); + }); + + it("abandoned pill is an anchor when dashboard URL is safe (SEC-001)", () => { + const pr = makeNeedsReviewPR({ + title: "chore(deps): update dependency lodash to v5", + repoFullName: "owner/repo", + }); + const abandonedDepsMap = new Map([ + ["owner/repo", [{ datasource: "npm", packageName: "lodash", lastUpdated: "2024-01-01" }]], + ]); + const dashboardIssueUrls = new Map([ + ["owner/repo", "https://github.com/owner/repo/issues/1"], + ]); + renderTab({ pullRequests: [pr], abandonedDepsMap, dashboardIssueUrls }); + const pill = screen.getByText("Abandoned dep"); + expect(pill.tagName.toLowerCase()).toBe("a"); + expect(pill.getAttribute("href")).toBe("https://github.com/owner/repo/issues/1"); + }); + + it("abandoned pill is a span when URL fails SEC-001 check", () => { + const pr = makeNeedsReviewPR({ + title: "chore(deps): update dependency lodash to v5", + repoFullName: "owner/repo", + }); + const abandonedDepsMap = new Map([ + ["owner/repo", [{ datasource: "npm", packageName: "lodash", lastUpdated: "2024-01-01" }]], + ]); + const dashboardIssueUrls = new Map([ + ["owner/repo", "https://evil.example.com/phish"], + ]); + renderTab({ pullRequests: [pr], abandonedDepsMap, dashboardIssueUrls }); + const pill = screen.getByText("Abandoned dep"); + expect(pill.tagName.toLowerCase()).not.toBe("a"); + }); +}); + +// ── Filters ─────────────────────────────────────────────────────────────────── + +describe("DependenciesTab — updateType filter", () => { + it("shows all PRs by default (updateType=all)", () => { + const major = makeNeedsReviewPR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); + const patch = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + renderTab({ pullRequests: [major, patch] }); + expect(screen.getByText(major.title)).toBeDefined(); + expect(screen.getByText(patch.title)).toBeDefined(); + }); + + it("filters to major only when updateType=major is set", () => { + const major = makeNeedsReviewPR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); + const patch = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + setTabFilter("dependencies", "updateType", "major"); + renderTab({ pullRequests: [major, patch] }); + expect(screen.getByText(major.title)).toBeDefined(); + expect(screen.queryByText(patch.title)).toBeNull(); + }); + + it("maintenance PRs pass through all updateType filters (unknown version type)", () => { + const pin = makeNeedsReviewPR({ title: "chore(deps): pin dependencies" }); + setTabFilter("dependencies", "updateType", "major"); + renderTab({ pullRequests: [pin] }); + expect(screen.getByText(pin.title)).toBeDefined(); + }); +}); + +describe("DependenciesTab — bot filter", () => { + it("filters to specific bot when bot filter is set", () => { + const renovatePR = makeNeedsReviewPR({ userLogin: "renovate[bot]", title: "chore(deps): update lodash" }); + const dependabotPR = makeNeedsReviewPR({ userLogin: "dependabot[bot]", title: "Bump axios from 0.27 to 1.0.0" }); + setTabFilter("dependencies", "bot", "renovate[bot]"); + renderTab({ pullRequests: [renovatePR, dependabotPR] }); + expect(screen.getByText(renovatePR.title)).toBeDefined(); + expect(screen.queryByText(dependabotPR.title)).toBeNull(); + }); +}); + +// ── Closed PRs excluded ─────────────────────────────────────────────────────── + +describe("DependenciesTab — state filtering", () => { + it("does not render closed PRs", () => { + const closed = makePullRequest({ + userLogin: "renovate[bot]", + headRef: "renovate/lodash", + title: "This PR is closed", + state: "CLOSED", + }); + renderTab({ pullRequests: [closed] }); + expect(screen.queryByText(closed.title)).toBeNull(); + }); +}); From 1c1434b978ecfcbc46fc9a89ff02b21e328d5347 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:38:36 -0400 Subject: [PATCH 06/48] feat(deps): integrate pre-exclusivity dep PR detection into DashboardPage --- .../components/dashboard/DashboardPage.tsx | 102 ++++++- src/app/components/dashboard/IssuesTab.tsx | 16 +- .../dashboard/PersonalSummaryStrip.tsx | 1 - src/app/lib/filters.ts | 2 - src/app/stores/view.ts | 2 - tests/components/DashboardPage.test.tsx | 265 ++++++++++++------ tests/components/IssuesTab.test.tsx | 44 --- .../dashboard/PersonalSummaryStrip.test.tsx | 24 +- tests/lib/filters.test.ts | 22 -- tests/stores/view.test.ts | 28 -- 10 files changed, 277 insertions(+), 229 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 32c7e4ff..2bdc224f 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 } from "../../lib/dependency-detection"; +import { findDashboardIssues, parseAbandonedSection, type AbandonedDependency } from "../../lib/dependency-dashboard"; +import { fetchDashboardIssueBodies } from "../../services/api"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -137,6 +141,11 @@ 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()); +let _fetchingDashboardBodies = false; + // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { resetDashboardData(); @@ -145,6 +154,9 @@ onAuthCleared(() => { setJiraLoading(false); setJiraKeyMap(new Map()); _jiraFetching = false; + setAbandonedDepsMap(new Map()); + setDashboardIssueUrls(new Map()); + _fetchingDashboardBodies = false; const coord = _coordinator(); if (coord) { coord.destroy(); @@ -535,6 +547,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 +593,13 @@ export default function DashboardPage() { } }); + // Redirect away from Dependencies tab when it becomes invisible + createEffect(() => { + if (activeTab() === "dependencies" && !enableDependencies()) { + handleTabChange("pullRequests"); + } + }); + // Clear stale Jira data when auth is cleared (e.g., 401 during token refresh) createEffect(() => { if (!isJiraAuthenticated()) { @@ -797,6 +817,21 @@ export default function DashboardPage() { ]; }); + // Dep PR detection — placed above exclusiveOwnership so pre-exclusivity claims run first + const trackedBotLogins = createMemo(() => + new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) + ); + const dependencyPrIds = createMemo(() => + new Set( + dashboardData.pullRequests + .filter((pr) => pr.state === "OPEN" && isDependencyPr(pr, trackedBotLogins())) + .map((pr) => pr.id) + ) + ); + const dependencyPullRequests = createMemo(() => + dashboardData.pullRequests.filter((pr) => dependencyPrIds().has(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 +857,10 @@ export default function DashboardPage() { const issueOwner = new Map(); const prOwner = new Map(); const runOwner = new Map(); + // Pre-exclusivity: dep PRs claimed before custom tab ownership runs + 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 +882,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 +942,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 +1019,7 @@ export default function DashboardPage() { return { issues: visibleIssues().filter((i) => - isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: builtinFilter }) + isIssueVisible(i, { ignoredIds: ignoredIssues, globalFilter: builtinFilter }) ).length, pullRequests: visiblePullRequests().filter((p) => isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: builtinFilter }) @@ -993,6 +1036,7 @@ export default function DashboardPage() { return true; }).length }; })() : {}), + ...(enableDependencies() ? { dependencies: dependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), ...customCounts, }; }); @@ -1073,6 +1117,44 @@ 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); + } + setAbandonedDepsMap(newAbandonedMap); + setDashboardIssueUrls(newUrlMap); + } finally { + _fetchingDashboardBodies = false; + } + })(); + }, + { defer: true } + )); + // 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 +1198,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 +1212,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"} /> @@ -1162,6 +1245,19 @@ export default function DashboardPage() { jiraKeyMap={jiraKeyMap} /> + + + {/* TrackedTab intentionally receives unfiltered dashboardData — it bypasses exclusivity */} { if (issue.state !== "OPEN") return false; - if (!isIssueVisible(issue, { ignoredIds, hideDepDashboard: viewState.hideDepDashboard, globalFilter })) return false; + if (!isIssueVisible(issue, { ignoredIds, globalFilter })) return false; const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); @@ -277,18 +277,6 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); }} /> - - -
a.toLowerCase() === login)) assignedIssues++; } return { assignedIssues }; diff --git a/src/app/lib/filters.ts b/src/app/lib/filters.ts index 789ca73a..e89e3249 100644 --- a/src/app/lib/filters.ts +++ b/src/app/lib/filters.ts @@ -2,7 +2,6 @@ import type { Issue, PullRequest, WorkflowRun } from "../../shared/types"; export interface ItemFilterOpts { ignoredIds: Set; - hideDepDashboard?: boolean; showPrRuns?: boolean; // null = bypass globalFilter (custom tabs have their own scope) globalFilter?: { org: string | null; repo: string | null } | null; @@ -10,7 +9,6 @@ export interface ItemFilterOpts { export function isIssueVisible(issue: Issue, opts: ItemFilterOpts): boolean { if (opts.ignoredIds.has(issue.id)) return false; - if (opts.hideDepDashboard && issue.title === "Dependency Dashboard") return false; if (opts.globalFilter) { const { org, repo } = opts.globalFilter; if (repo && issue.repoFullName !== repo) return false; diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 30fddb98..747d17ec 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -110,7 +110,6 @@ export const ViewStateSchema = z.object({ dependencies: { updateType: "all", bot: "all" }, }), showPrRuns: z.boolean().default(false), - hideDepDashboard: z.boolean().default(true), customTabFilters: z.record( z.string(), z.record(z.string(), z.string()) @@ -212,7 +211,6 @@ export function resetViewState(): void { dependencies: { updateType: "all", bot: "all" }, }, showPrRuns: false, - hideDepDashboard: true, customTabFilters: {}, expandedRepos: { issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} }, lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }, diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 3e2dfbd8..06c9270d 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -242,49 +242,6 @@ describe("DashboardPage — clock tick", () => { }); describe("DashboardPage — tab badge counts", () => { - it("excludes Dependency Dashboard issues from badge count by default", async () => { - vi.mocked(pollService.fetchAllData).mockResolvedValue({ - issues: [ - makeIssue({ id: 1, title: "Real issue" }), - makeIssue({ id: 2, title: "Dependency Dashboard" }), - makeIssue({ id: 3, title: "Dependency Dashboard" }), - ], - pullRequests: [], - workflowRuns: [], - errors: [], - }); - - render(() => ); - await waitFor(() => { - const issuesTab = screen.getByRole("tab", { name: /Issues/ }); - expect(issuesTab.textContent?.replace(/\D+/g, "")).toBe("1"); - }); - }); - - it("updates badge dynamically when hideDepDashboard is toggled off", async () => { - vi.mocked(pollService.fetchAllData).mockResolvedValue({ - issues: [ - makeIssue({ id: 1, title: "Real issue" }), - makeIssue({ id: 2, title: "Dependency Dashboard" }), - ], - pullRequests: [], - workflowRuns: [], - errors: [], - }); - - render(() => ); - // hideDepDashboard defaults to true — badge shows 1 - await waitFor(() => { - expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("1"); - }); - - // Toggle off — badge should update to 2 - viewStore.updateViewState({ hideDepDashboard: false }); - await waitFor(() => { - expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("2"); - }); - }); - it("decrements issue badge on ignore and increments on un-ignore", async () => { vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ @@ -295,8 +252,6 @@ describe("DashboardPage — tab badge counts", () => { workflowRuns: [], errors: [], }); - viewStore.updateViewState({ hideDepDashboard: false }); - render(() => ); await waitFor(() => { expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("2"); @@ -315,30 +270,6 @@ describe("DashboardPage — tab badge counts", () => { }); }); - it("combines hideDepDashboard and ignore exclusions correctly", async () => { - vi.mocked(pollService.fetchAllData).mockResolvedValue({ - issues: [ - makeIssue({ id: 1, title: "Issue A" }), - makeIssue({ id: 2, title: "Dependency Dashboard" }), - makeIssue({ id: 3, title: "Issue C" }), - ], - pullRequests: [], - workflowRuns: [], - errors: [], - }); - // hideDepDashboard defaults true — badge starts at 2 (excludes Dep Dashboard) - render(() => ); - await waitFor(() => { - expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("2"); - }); - - // Ignore one real issue — badge should drop to 1 - viewStore.ignoreItem({ id: 1, type: "issue", repo: "owner/repo", title: "Issue A", ignoredAt: Date.now() }); - await waitFor(() => { - expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("1"); - }); - }); - it("decrements PR badge on ignore", async () => { vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], @@ -484,7 +415,6 @@ describe("DashboardPage — tab badge counts", () => { }); // Set filter BEFORE render to avoid Kobalte Select onChange cascade in happy-dom viewStore.updateViewState({ - hideDepDashboard: false, globalFilter: { org: null, repo: "org/alpha" }, }); @@ -514,7 +444,6 @@ describe("DashboardPage — tab badge counts", () => { errors: [], }); viewStore.updateViewState({ - hideDepDashboard: false, globalFilter: { org: "alpha", repo: null }, }); @@ -1130,8 +1059,6 @@ describe("DashboardPage — exclusive custom tabs", () => { filterPreset: {}, exclusive: true, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 1, title: "Issue A" }), @@ -1192,8 +1119,6 @@ describe("DashboardPage — exclusive custom tabs", () => { filterPreset: {}, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 1, title: "Issue A" }), @@ -1232,8 +1157,6 @@ describe("DashboardPage — exclusive custom tabs", () => { filterPreset: {}, exclusive: true, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 1, title: "Issue A" }), @@ -1330,8 +1253,6 @@ describe("DashboardPage — custom tab scoping", () => { filterPreset: { scope: "all" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 1, title: "In-scope", repoFullName: "myorg/repo-a" }), @@ -1360,8 +1281,6 @@ describe("DashboardPage — custom tab scoping", () => { filterPreset: { scope: "all" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 10, title: "Repo A issue", repoFullName: "myorg/repo-a" }), @@ -1389,8 +1308,6 @@ describe("DashboardPage — custom tab scoping", () => { filterPreset: { scope: "all" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [makeIssue({ id: 20, title: "Lowercase org", repoFullName: "myorg/repo" })], pullRequests: [], @@ -1415,8 +1332,6 @@ describe("DashboardPage — custom tab scoping", () => { filterPreset: { scope: "all" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 40, title: "Matches orgScope", repoFullName: "testorg/any-repo" }), @@ -1446,8 +1361,6 @@ describe("DashboardPage — custom tab scoping", () => { filterPreset: { scope: "all" }, exclusive: true, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ makeIssue({ id: 30, title: "myorg issue", repoFullName: "myorg/repo" }), @@ -1586,7 +1499,6 @@ describe("DashboardPage — tabCounts applies filterPreset", () => { filterPreset: { scope: "all", role: "author" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); // 3 issues: 2 by "octocat" (makeIssue default), 1 by "someone" vi.mocked(pollService.fetchAllData).mockResolvedValue({ @@ -1627,8 +1539,6 @@ describe("DashboardPage — tabCounts applies filterPreset", () => { filterPreset: { scope: "all", user: "_self" }, exclusive: false, }); - viewStore.updateViewState({ hideDepDashboard: false }); - vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [ // surfacedBy includes testuser — should be counted @@ -2055,3 +1965,178 @@ describe("DashboardPage — events poll targeted merge", () => { expect(vi.mocked(pollService.seedHotSetsFromTargeted)).toHaveBeenCalledWith(targetedData); }); }); + +describe("DashboardPage — dependency pre-exclusivity", () => { + it("excludes dep bot PRs from the Pull Requests tab", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + makePullRequest({ id: 2, title: "Normal feature PR", userLogin: "developer" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const prTab = screen.getByRole("tab", { name: /Pull Requests/ }); + expect(prTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + }); + + it("shows the Dependencies tab when dep bot PRs exist", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /Dependencies/ })).toBeTruthy(); + }); + }); + + it("does not show the Dependencies tab when config.dependencies.enabled is false", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + ], + workflowRuns: [], + errors: [], + }); + + configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + + render(() => ); + await waitFor(() => { + expect(screen.queryByRole("tab", { name: /Dependencies/ })).toBeNull(); + }); + }); + + it("does not show the Dependencies tab when no dep PRs exist", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Normal feature PR", userLogin: "developer" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + expect(screen.queryByRole("tab", { name: /Dependencies/ })).toBeNull(); + }); + }); + + it("Dependencies tab count reflects dep PR count (excluding ignored)", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + makePullRequest({ id: 2, title: "Bump react from 17 to 18", userLogin: "dependabot[bot]" }), + makePullRequest({ id: 3, title: "Normal feature PR", userLogin: "developer" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("2"); + }); + + viewStore.ignoreItem({ id: 1, type: "pullRequest", repo: "owner/repo", title: "Bump lodash", ignoredAt: Date.now() }); + await waitFor(() => { + const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + }); + + it("dep PRs are excluded from exclusive custom tab ownership", async () => { + configStore.addCustomTab({ + id: "custom-prs", + name: "Custom PRs", + baseType: "pullRequests", + exclusive: true, + orgScope: [], + repoScope: [{ owner: "owner", name: "repo", fullName: "owner/repo" }], + filterPreset: {}, + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]", repoFullName: "owner/repo" }), + makePullRequest({ id: 2, title: "Normal feature PR", userLogin: "developer", repoFullName: "owner/repo" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const customTab = screen.getByRole("tab", { name: /Custom PRs/ }); + expect(customTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + }); + + it("excludes dep PRs from visiblePullRequests even when no exclusive custom tabs exist", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + makePullRequest({ id: 2, title: "Normal feature PR", userLogin: "developer" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const prTab = screen.getByRole("tab", { name: /Pull Requests/ }); + expect(prTab.textContent?.replace(/\D+/g, "")).toBe("1"); + const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); + }); + }); + + it("PersonalSummaryStrip PR counts exclude dep bot PRs via pre-exclusivity", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ + id: 1, + title: "Bump lodash from 4.0 to 5.0", + userLogin: "dependabot[bot]", + checkStatus: "success", + reviewDecision: "APPROVED", + draft: false, + }), + makePullRequest({ + id: 2, + title: "My feature PR", + userLogin: "testuser", + checkStatus: "success", + reviewDecision: "APPROVED", + draft: false, + }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const readyToMerge = screen.getByText(/ready to merge/); + expect(readyToMerge.textContent).toMatch(/^1\s/); + }); + }); +}); diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 4bc365db..c9515c7e 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -563,50 +563,6 @@ describe("IssuesTab", () => { screen.getByText("Repo B issue 0"); }); - it("hides Dependency Dashboard issues by default", () => { - const issues = [ - makeIssue({ id: 1, title: "Dependency Dashboard" }), - makeIssue({ id: 2, title: "Normal issue" }), - ]; - setAllExpanded("issues", ["owner/repo"], true); - render(() => ); - expect(screen.queryByText("Dependency Dashboard")).toBeNull(); - screen.getByText("Normal issue"); - }); - - it("shows Dependency Dashboard issues when hideDepDashboard is false", () => { - const issues = [ - makeIssue({ id: 1, title: "Dependency Dashboard" }), - makeIssue({ id: 2, title: "Normal issue" }), - ]; - viewStore.updateViewState({ hideDepDashboard: false }); - setAllExpanded("issues", ["owner/repo"], true); - render(() => ); - screen.getByText("Dependency Dashboard"); - screen.getByText("Normal issue"); - }); - - it("toggles Dependency Dashboard visibility via pill button", async () => { - const user = userEvent.setup(); - const issues = [ - makeIssue({ id: 1, title: "Dependency Dashboard" }), - makeIssue({ id: 2, title: "Normal issue" }), - ]; - setAllExpanded("issues", ["owner/repo"], true); - render(() => ); - - // Hidden by default - expect(screen.queryByText("Dependency Dashboard")).toBeNull(); - - // Click toggle pill to show - await user.click(screen.getByText("Show Dep Dashboard")); - screen.getByText("Dependency Dashboard"); - - // Click again to hide - await user.click(screen.getByText("Show Dep Dashboard")); - expect(screen.queryByText("Dependency Dashboard")).toBeNull(); - }); - it("renders repo header link to GitHub issues", () => { const issues = [makeIssue({ id: 1 })]; setAllExpanded("issues", ["owner/repo"], true); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 2e2b5236..01c0cfce 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -5,7 +5,7 @@ import PersonalSummaryStrip from "../../../src/app/components/dashboard/Personal import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; import type { Issue, PullRequest, WorkflowRun } from "../../../src/app/services/api"; -import { viewState, updateViewState, setAllExpanded, ignoreItem } from "../../../src/app/stores/view"; +import { viewState, setAllExpanded, ignoreItem } from "../../../src/app/stores/view"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -762,25 +762,3 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { expect(blockedButton.textContent).toContain("1"); }); }); - -describe("PersonalSummaryStrip — hideDepDashboard exclusion", () => { - it("excludes Dependency Dashboard issues from assigned count when hideDepDashboard is true", () => { - const issues = [ - makeIssue({ id: 1, title: "Dependency Dashboard", assigneeLogins: ["me"] }), - ]; - // hideDepDashboard defaults to true via resetViewStore - - const { container } = renderStrip({ issues }); - expect(container.innerHTML).toBe(""); - }); - - it("includes Dependency Dashboard issues when hideDepDashboard is false", () => { - updateViewState({ hideDepDashboard: false }); - const issues = [ - makeIssue({ id: 1, title: "Dependency Dashboard", assigneeLogins: ["me"] }), - ]; - - renderStrip({ issues }); - screen.getByText(/assigned/); - }); -}); diff --git a/tests/lib/filters.test.ts b/tests/lib/filters.test.ts index 93b788d3..f30a357b 100644 --- a/tests/lib/filters.test.ts +++ b/tests/lib/filters.test.ts @@ -22,28 +22,6 @@ describe("isIssueVisible", () => { }); }); - describe("hideDepDashboard", () => { - it("hides issue titled 'Dependency Dashboard' when hideDepDashboard is true", () => { - const issue = makeIssue({ title: "Dependency Dashboard" }); - expect(isIssueVisible(issue, { ignoredIds: new Set(), hideDepDashboard: true })).toBe(false); - }); - - it("shows issue titled 'Dependency Dashboard' when hideDepDashboard is false", () => { - const issue = makeIssue({ title: "Dependency Dashboard" }); - expect(isIssueVisible(issue, { ignoredIds: new Set(), hideDepDashboard: false })).toBe(true); - }); - - it("shows issue titled 'Dependency Dashboard' when hideDepDashboard is undefined", () => { - const issue = makeIssue({ title: "Dependency Dashboard" }); - expect(isIssueVisible(issue, { ignoredIds: new Set() })).toBe(true); - }); - - it("does not hide non-Dependency-Dashboard issues when hideDepDashboard is true", () => { - const issue = makeIssue({ title: "Regular bug" }); - expect(isIssueVisible(issue, { ignoredIds: new Set(), hideDepDashboard: true })).toBe(true); - }); - }); - describe("globalFilter — org", () => { it("shows issue when org matches", () => { const issue = makeIssue({ repoFullName: "myorg/repo" }); diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 74fafc2c..353accd6 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -239,7 +239,6 @@ describe("ViewStateSchema", () => { expect(result.globalSort).toEqual({ field: "updatedAt", direction: "desc" }); expect(result.ignoredItems).toEqual([]); expect(result.globalFilter).toEqual({ org: null, repo: null }); - expect(result.hideDepDashboard).toBe(true); }); it("handles missing fields with defaults", () => { @@ -381,27 +380,6 @@ describe("resetViewState", () => { }); }); -describe("hideDepDashboard", () => { - beforeEach(() => resetViewState()); - - it("defaults to true", () => { - expect(viewState.hideDepDashboard).toBe(true); - }); - - it("can be toggled via updateViewState", () => { - updateViewState({ hideDepDashboard: false }); - expect(viewState.hideDepDashboard).toBe(false); - updateViewState({ hideDepDashboard: true }); - expect(viewState.hideDepDashboard).toBe(true); - }); - - it("is not affected by resetAllTabFilters", () => { - updateViewState({ hideDepDashboard: false }); - resetAllTabFilters("issues"); - expect(viewState.hideDepDashboard).toBe(false); - }); -}); - describe("resetAllTabFilters — scope reset", () => { it("resets issues scope from 'all' back to 'involves_me'", () => { setTabFilter("issues", "scope", "all"); @@ -417,12 +395,6 @@ describe("resetAllTabFilters — scope reset", () => { expect(viewState.tabFilters.pullRequests.scope).toBe("involves_me"); }); - it("is reset by resetViewState", () => { - updateViewState({ hideDepDashboard: false }); - resetViewState(); - expect(viewState.hideDepDashboard).toBe(true); - }); - it("resets jiraAssigned filters back to defaults", () => { setTabFilter("jiraAssigned", "statusCategory", "indeterminate"); expect(viewState.tabFilters.jiraAssigned.statusCategory).toBe("indeterminate"); From 1d287a5d145314dbc1261442145daf7e2a5acd91 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:54:41 -0400 Subject: [PATCH 07/48] fix(deps): address review findings from Phase 4 --- .../components/dashboard/DashboardPage.tsx | 14 ++-- .../components/dashboard/DependenciesTab.tsx | 69 ++++++++++--------- src/app/components/settings/SettingsPage.tsx | 4 +- src/app/lib/dependency-dashboard.ts | 23 +++---- src/app/lib/dependency-detection.ts | 3 +- src/app/services/api.ts | 2 +- .../dashboard/DependenciesTab.test.tsx | 8 +-- tests/lib/dependency-detection.test.ts | 20 +++--- 8 files changed, 69 insertions(+), 74 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 2bdc224f..1213d73e 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -596,7 +596,7 @@ export default function DashboardPage() { // Redirect away from Dependencies tab when it becomes invisible createEffect(() => { if (activeTab() === "dependencies" && !enableDependencies()) { - handleTabChange("pullRequests"); + handleTabChange("issues"); } }); @@ -821,15 +821,11 @@ export default function DashboardPage() { const trackedBotLogins = createMemo(() => new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) ); - const dependencyPrIds = createMemo(() => - new Set( - dashboardData.pullRequests - .filter((pr) => pr.state === "OPEN" && isDependencyPr(pr, trackedBotLogins())) - .map((pr) => pr.id) - ) - ); const dependencyPullRequests = createMemo(() => - dashboardData.pullRequests.filter((pr) => dependencyPrIds().has(pr.id)) + 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). diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 90e3294d..55b2393e 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -4,7 +4,7 @@ import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, unt import { isSafeGitHubUrl } from "../../lib/url"; import type { PullRequest } from "../../services/api"; import type { AbandonedDependency } from "../../lib/dependency-dashboard"; -import { classifyDepStatus, extractVersionInfo, isRebasing, type DepStatus } from "../../lib/dependency-detection"; +import { classifyDepStatus, extractVersionInfo, isRebasing, STALE_THRESHOLD_DEFAULT_DAYS, type DepStatus } from "../../lib/dependency-detection"; import { matchAbandonedToPr } from "../../lib/dependency-dashboard"; import type { FilterChipGroupDef } from "../shared/filterTypes"; import FilterToolbar from "../shared/FilterToolbar"; @@ -24,8 +24,6 @@ const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { ], }; -const STALE_THRESHOLD_DAYS = 14; - interface ClassifiedPR { pr: PullRequest; status: DepStatus; @@ -48,7 +46,7 @@ interface DependenciesTabProps { export default function DependenciesTab(props: DependenciesTabProps) { const [expandedGroups, setExpandedGroups] = createSignal>( - new Set(["needs-review"]) + new Set(["needs-review", "waiting", "stale"]) ); function toggleGroup(status: DepStatus) { @@ -66,7 +64,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { })); const botOptions = createMemo(() => { - const logins = [...new Set(props.pullRequests.map((pr) => pr.userLogin))].sort(); + const logins = [...new Set(props.pullRequests.filter((pr) => pr.state === "OPEN").map((pr) => pr.userLogin))].sort(); return { label: "Bot", field: "bot", @@ -91,35 +89,38 @@ export default function DependenciesTab(props: DependenciesTabProps) { const classifiedPRs = createMemo(() => { const filters = activeFilters(); + const ignored = ignoredIds(); return props.pullRequests - .filter((pr) => { + .map((pr) => { + const versionInfo = extractVersionInfo(pr.title); + const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; + return { + pr, + status: classifyDepStatus(pr, STALE_THRESHOLD_DEFAULT_DAYS), + versionInfo, + rebasing: isRebasing(pr, props.rebaseLabel), + abandonedDep: matchAbandonedToPr(pr, abandonedDeps), + }; + }) + .filter(({ pr, versionInfo }) => { if (pr.state !== "OPEN") return false; - if (ignoredIds().has(pr.id)) return false; + if (ignored.has(pr.id)) return false; // Bot filter if (filters.bot !== "all" && pr.userLogin !== filters.bot) return false; // updateType filter — pass through when updateType is null (unknown) if (filters.updateType !== "all") { - const vi = extractVersionInfo(pr.title); - if (vi !== null && vi.updateType !== undefined && vi.updateType !== filters.updateType) return false; + if (versionInfo !== null && versionInfo.updateType !== undefined && versionInfo.updateType !== filters.updateType) return false; } return true; }) - .map((pr) => { - const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; - return { - pr, - status: classifyDepStatus(pr, props.rebaseLabel, STALE_THRESHOLD_DAYS), - versionInfo: extractVersionInfo(pr.title), - rebasing: isRebasing(pr, props.rebaseLabel), - abandonedDep: matchAbandonedToPr(pr, abandonedDeps), - }; - }) .sort((a, b) => (a.pr.updatedAt < b.pr.updatedAt ? 1 : a.pr.updatedAt > b.pr.updatedAt ? -1 : 0)); }); + const openPrCount = createMemo(() => props.pullRequests.filter(p => p.state === "OPEN").length); + const statusGroups = createMemo(() => { const groups: Record = { "needs-review": [], @@ -149,12 +150,14 @@ export default function DependenciesTab(props: DependenciesTabProps) {
{/* Filter toolbar */}
- setTabFilter("dependencies", f as "updateType" | "bot", v)} - onResetAll={() => resetAllTabFilters("dependencies")} - /> +
+ setTabFilter("dependencies", f as "updateType" | "bot", v)} + onResetAll={() => resetAllTabFilters("dependencies")} + /> +
{/* Loading skeleton */} @@ -163,7 +166,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { {/* Empty state */} - p.state === "OPEN").length === 0}> +

No PRs match your current filters

@@ -182,7 +185,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { {/* Status groups */} 0}> -
+
-
+
{({ pr, versionInfo, rebasing, abandonedDep }) => { const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); @@ -312,9 +316,6 @@ function StatusGroup(props: StatusGroupProps) { )} - {/* Bot name */} - {pr.userLogin} - {/* Rebase indicator */} Rebasing @@ -322,7 +323,7 @@ function StatusGroup(props: StatusGroupProps) { {/* Draft indicator */} - Draft + Draft {/* Abandoned dep pill — SEC-001: URL validated before use as href */} @@ -337,7 +338,7 @@ function StatusGroup(props: StatusGroupProps) { href={dashUrl()} target="_blank" rel="noopener noreferrer" - class="badge badge-error badge-outline badge-sm hover:badge-error" + class="badge badge-error badge-outline badge-sm" onClick={(e) => e.stopPropagation()} > Abandoned dep diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index f494c4ec..0a010798 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -1139,7 +1139,7 @@ export default function SettingsPage() { > updateDependencyConfig({ enabled: !(config.dependencies?.enabled ?? true) })} @@ -1151,7 +1151,7 @@ export default function SettingsPage() { > (); + /** * Checks if a dep PR's title references an abandoned package name. * Uses word-boundary regex with escaped package name (SEC-002). @@ -107,11 +102,15 @@ export function matchAbandonedToPr( abandonedDeps: AbandonedDependency[] ): AbandonedDependency | null { for (const dep of abandonedDeps) { - // SEC-002: escape package name before using in regex - // Use (?:^|\W) and (?:\W|$) instead of \b to correctly handle scoped packages like @scope/pkg - // \b fails when package name starts/ends with non-word chars (e.g. @, /) - const escaped = escapeRegex(dep.packageName); - const pattern = new RegExp("(?:^|\\W)" + escaped + "(?:\\W|$)", "i"); + let pattern = _abandonedPatternCache.get(dep.packageName); + if (!pattern) { + // SEC-002: escape package name before using in regex + // Use (?:^|\W) and (?:\W|$) instead of \b to correctly handle scoped packages like @scope/pkg + // \b fails when package name starts/ends with non-word chars (e.g. @, /) + const escaped = escapeRegex(dep.packageName); + pattern = new RegExp("(?:^|\\W)" + escaped + "(?:\\W|$)", "i"); + _abandonedPatternCache.set(dep.packageName, pattern); + } if (pattern.test(pr.title)) return dep; } return null; diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index 9394f181..fc39db8a 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -100,11 +100,10 @@ export function isRebasing(pr: PullRequest, rebaseLabel: string): boolean { return pr.labels.some((l) => l.name.toLowerCase() === target); } -const STALE_THRESHOLD_DEFAULT_DAYS = 14; +export const STALE_THRESHOLD_DEFAULT_DAYS = 14; export function classifyDepStatus( pr: PullRequest, - _rebaseLabel: string, staleThresholdDays: number = STALE_THRESHOLD_DEFAULT_DAYS ): DepStatus { // needs-review: enriched, not draft, CI passing, not yet approved diff --git a/src/app/services/api.ts b/src/app/services/api.ts index d35d4432..78ced310 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1189,7 +1189,7 @@ export async function fetchDashboardIssueBodies( ); if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); for (const node of response.nodes) { - if (!node) continue; + if (!node || !node.id) continue; result.set(node.id, node.body); } } catch (err) { diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index e33eaa11..596a31b4 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -157,18 +157,18 @@ describe("DependenciesTab — status groups", () => { expect(screen.getByText(pr.title)).toBeDefined(); }); - it("Waiting group collapsed by default — PR title not visible", () => { + it("Waiting group expanded by default — PR title visible", () => { const pr = makeWaitingPR(); renderTab({ pullRequests: [pr] }); - expect(screen.queryByText(pr.title)).toBeNull(); + expect(screen.getByText(pr.title)).toBeDefined(); }); - it("expands Waiting group when header is clicked", () => { + it("collapses Waiting group when header is clicked", () => { const pr = makeWaitingPR(); renderTab({ pullRequests: [pr] }); const header = screen.getByText("Waiting").closest("button")!; fireEvent.click(header); - expect(screen.getByText(pr.title)).toBeDefined(); + expect(screen.queryByText(pr.title)).toBeNull(); }); it("collapses an expanded group on second click", () => { diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index da48d8cc..54cc5951 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -201,7 +201,7 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("needs-review"); }); @@ -214,7 +214,7 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: OLD, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); // needs-review check runs first and wins expect(result).toBe("needs-review"); }); @@ -227,7 +227,7 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: OLD, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("stale"); }); @@ -236,7 +236,7 @@ describe("classifyDepStatus", () => { draft: true, updatedAt: OLD, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("stale"); }); @@ -245,7 +245,7 @@ describe("classifyDepStatus", () => { draft: true, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("waiting"); }); @@ -257,7 +257,7 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("waiting"); }); @@ -267,7 +267,7 @@ describe("classifyDepStatus", () => { checkStatus: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("waiting"); }); @@ -279,7 +279,7 @@ describe("classifyDepStatus", () => { reviewDecision: "APPROVED", updatedAt: RECENT, }); - const result = classifyDepStatus(pr, "rebase", 14); + const result = classifyDepStatus(pr, 14); expect(result).toBe("waiting"); }); @@ -290,8 +290,8 @@ describe("classifyDepStatus", () => { const recent = makePullRequest({ draft: true, updatedAt: thirteenDaysAgo }); const old = makePullRequest({ draft: true, updatedAt: fifteenDaysAgo }); - expect(classifyDepStatus(recent, "rebase")).toBe("waiting"); - expect(classifyDepStatus(old, "rebase")).toBe("stale"); + expect(classifyDepStatus(recent)).toBe("waiting"); + expect(classifyDepStatus(old)).toBe("stale"); }); }); From f7745087a8dff558ec75389baf25dbf61add301e Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:56:15 -0400 Subject: [PATCH 08/48] fix(deps): preserve stale abandoned-deps on total API failure --- src/app/components/dashboard/DashboardPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 1213d73e..07c57fa2 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1141,6 +1141,8 @@ export default function DashboardPage() { } 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 { From b9a096bbe5b08850c1edb940b66352c81d517ffd Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 11:57:51 -0400 Subject: [PATCH 09/48] fix(deps): derive DEP_FILTER_DEFAULTS from schema (STRUCT-008) --- src/app/components/dashboard/DependenciesTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 55b2393e..da7285d0 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -1,6 +1,6 @@ import { createMemo, createSignal, For, Show } from "solid-js"; import { config } from "../../stores/config"; -import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem } from "../../stores/view"; +import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem, DependencyFiltersSchema } from "../../stores/view"; import { isSafeGitHubUrl } from "../../lib/url"; import type { PullRequest } from "../../services/api"; import type { AbandonedDependency } from "../../lib/dependency-dashboard"; @@ -11,7 +11,7 @@ import FilterToolbar from "../shared/FilterToolbar"; import ItemRow from "./ItemRow"; import SkeletonRows from "../shared/SkeletonRows"; -const DEP_FILTER_DEFAULTS = { updateType: "all" as const, bot: "all" }; +const DEP_FILTER_DEFAULTS = DependencyFiltersSchema.parse({}); const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { label: "Update type", From 9bdcf55a2d535586f0edbcdbfe2ea0dc1989d6e3 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 12:03:09 -0400 Subject: [PATCH 10/48] docs: update documentation for Dependencies tab feature --- README.md | 4 ++++ docs/USER_GUIDE.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) 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..8077cf5e 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,58 @@ 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. **Author login** — any PR author whose login ends in `[bot]` and whose name contains `renovate`, `dependabot`, `snyk`, or `mend` is treated as a dependency bot. +2. **Branch name prefix** — branches starting with `renovate/`, `deps/`, or `dependabot/` are flagged as dependency updates regardless of author. +3. **Label match** — PRs with a configurable label (default: `dependencies`) are included. The label can be changed in **Settings > Dependencies > Rebase label**. + +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 "Ignored or Blocked" and "Open" sections are shown as pills below the relevant group. Each pill links directly to the Renovate Dashboard issue so you can take bulk action (e.g., re-enable a paused dependency). + +The parser reads the Renovate Dashboard issue body to extract package names and their status. If the Renovate Dashboard issue is hidden via the Issues tab toggle, it is still parsed for abandoned dep data. + +### 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 | `dependencies` | PRs with this label are treated as dependency updates. Change to match your repo's label conventions. | + +### 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. From dcf3e58bfc785363490f08e8c9eeb78c2c062767 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 12:59:25 -0400 Subject: [PATCH 11/48] fix(deps): correct USER_GUIDE docs and clear regex cache on auth reset --- docs/USER_GUIDE.md | 14 ++++++++------ src/app/components/dashboard/DashboardPage.tsx | 3 ++- src/app/lib/dependency-dashboard.ts | 4 ++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8077cf5e..3f239de9 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -273,9 +273,11 @@ The Dependencies tab is a built-in tab that groups dependency bot PRs separately The tab uses a multi-layer detection pipeline to identify dependency PRs: -1. **Author login** — any PR author whose login ends in `[bot]` and whose name contains `renovate`, `dependabot`, `snyk`, or `mend` is treated as a dependency bot. -2. **Branch name prefix** — branches starting with `renovate/`, `deps/`, or `dependabot/` are flagged as dependency updates regardless of author. -3. **Label match** — PRs with a configurable label (default: `dependencies`) are included. The label can be changed in **Settings > Dependencies > Rebase label**. +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. @@ -293,9 +295,9 @@ 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 "Ignored or Blocked" and "Open" sections are shown as pills below the relevant group. Each pill links directly to the Renovate Dashboard issue so you can take bulk action (e.g., re-enable a paused dependency). +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 and their status. If the Renovate Dashboard issue is hidden via the Issues tab toggle, it is still parsed for abandoned dep data. +The parser reads the Renovate Dashboard issue body to extract package names from the abandoned dependencies table. ### Dependencies Settings @@ -304,7 +306,7 @@ 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 | `dependencies` | PRs with this label are treated as dependency updates. Change to match your repo's label conventions. | +| 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 diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 07c57fa2..30c28da4 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -12,7 +12,7 @@ import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, updat import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import DependenciesTab from "./DependenciesTab"; import { isDependencyPr } from "../../lib/dependency-detection"; -import { findDashboardIssues, parseAbandonedSection, type AbandonedDependency } from "../../lib/dependency-dashboard"; +import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; import { fetchDashboardIssueBodies } from "../../services/api"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; @@ -157,6 +157,7 @@ onAuthCleared(() => { setAbandonedDepsMap(new Map()); setDashboardIssueUrls(new Map()); _fetchingDashboardBodies = false; + resetAbandonedPatternCache(); const coord = _coordinator(); if (coord) { coord.destroy(); diff --git a/src/app/lib/dependency-dashboard.ts b/src/app/lib/dependency-dashboard.ts index 5deac3de..083f8495 100644 --- a/src/app/lib/dependency-dashboard.ts +++ b/src/app/lib/dependency-dashboard.ts @@ -93,6 +93,10 @@ export function parseAbandonedSection(body: string): AbandonedDependency[] { const _abandonedPatternCache = new Map(); +export function resetAbandonedPatternCache(): void { + _abandonedPatternCache.clear(); +} + /** * Checks if a dep PR's title references an abandoned package name. * Uses word-boundary regex with escaped package name (SEC-002). From 6fb4704ee4a92fb13e4cc2aa76579c069b6c2a3b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 13:25:10 -0400 Subject: [PATCH 12/48] test(deps): add coverage for fetchDashboardIssueBodies, ignore/track, filter schema, and integration --- tests/components/DashboardPage.test.tsx | 72 +++++ .../dashboard/DependenciesTab.test.tsx | 88 +++++- tests/services/api-dashboard-bodies.test.ts | 250 ++++++++++++++++++ tests/stores/view.test.ts | 87 ++++++ 4 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 tests/services/api-dashboard-bodies.test.ts diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 06c9270d..909f236d 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2140,3 +2140,75 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); }); }); + +// ── Dependencies tab — abandonedDepsMap + dashboardIssueUrls reset on auth clear ─ + +describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { + it("Dependencies tab disappears after auth clear (abandonedDepsMap reset)", async () => { + // The module-level signals abandonedDepsMap and dashboardIssueUrls are reset + // to empty Maps by the onAuthCleared callback alongside resetDashboardData(). + // We verify indirectly: dep PRs are cleared → Dependencies tab vanishes. + const depPR = makePullRequest({ + id: 800, + title: "chore(deps): update dependency lodash to v5", + userLogin: "renovate[bot]", + headRef: "renovate/lodash-5.x", + state: "OPEN", + }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + + // Dependencies tab appears when dep PRs are present + await waitFor(() => { + expect(screen.getByRole("tab", { name: /Dependencies/ })).toBeDefined(); + }); + + // Invoke auth clear callbacks — this calls resetDashboardData() which clears + // all PRs, and also calls setAbandonedDepsMap(new Map()) + setDashboardIssueUrls(new Map()) + expect(authClearCallbacks.length).toBeGreaterThan(0); + for (const cb of authClearCallbacks) cb(); + + // After clear, no dep PRs → enableDependencies() becomes false → tab hidden + await waitFor(() => { + expect(screen.queryByRole("tab", { name: /Dependencies/ })).toBeNull(); + }); + }); + + it("DependenciesTab renders dep PRs when navigating to Dependencies tab", async () => { + const user = userEvent.setup(); + + const depPR = makePullRequest({ + id: 801, + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + state: "OPEN", + enriched: true, + checkStatus: "pending", + draft: false, + }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + + // Click the Dependencies tab + await waitFor(() => screen.getByRole("tab", { name: /Dependencies/ })); + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + + // DependenciesTab renders the PR inside a status group + await waitFor(() => { + expect(screen.getByText(depPR.title)).toBeDefined(); + }); + }); +}); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 596a31b4..d37f5e3c 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -30,8 +30,8 @@ vi.mock("../../../src/app/lib/url", () => ({ import { render } from "@solidjs/testing-library"; import DependenciesTab from "../../../src/app/components/dashboard/DependenciesTab.js"; -import { resetConfig } from "../../../src/app/stores/config.js"; -import { setTabFilter, resetViewState } from "../../../src/app/stores/view.js"; +import { resetConfig, updateConfig } from "../../../src/app/stores/config.js"; +import { setTabFilter, resetViewState, viewState } from "../../../src/app/stores/view.js"; import { makePullRequest } from "../../helpers/factories.js"; import type { AbandonedDependency } from "../../../src/app/lib/dependency-dashboard.js"; @@ -372,3 +372,87 @@ describe("DependenciesTab — state filtering", () => { expect(screen.queryByText(closed.title)).toBeNull(); }); }); + +// ── Ignore button ───────────────────────────────────────────────────────────── + +describe("DependenciesTab — ignore button", () => { + it("clicking the ignore button hides the PR from the list", () => { + const pr = makeNeedsReviewPR({ title: "chore(deps): bump lodash to v5" }); + renderTab({ pullRequests: [pr] }); + + // PR is visible before ignore + expect(screen.getByText(pr.title)).toBeDefined(); + + const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); + fireEvent.click(ignoreBtn); + + // PR should no longer be rendered + expect(screen.queryByText(pr.title)).toBeNull(); + }); + + it("ignore button adds item to ignoredItems in viewState", () => { + const pr = makeNeedsReviewPR({ id: 5001, title: "chore(deps): update react to v19" }); + renderTab({ pullRequests: [pr] }); + + const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); + fireEvent.click(ignoreBtn); + + expect(viewState.ignoredItems.some((i) => i.id === 5001 && i.type === "pullRequest")).toBe(true); + }); + + it("ignored PR is not rendered even when re-renderTab is called", () => { + const pr = makeNeedsReviewPR({ id: 5002, title: "Bump axios from 0.27 to 1.0.0 (ignored)" }); + const { unmount } = renderTab({ pullRequests: [pr] }); + + fireEvent.click(screen.getByRole("button", { name: /^Ignore #/ })); + unmount(); + + // Re-render with same PR data — ignored item should still be filtered out + renderTab({ pullRequests: [pr] }); + expect(screen.queryByText(pr.title)).toBeNull(); + }); +}); + +// ── Track button ────────────────────────────────────────────────────────────── + +describe("DependenciesTab — track button", () => { + it("track button is not rendered when enableTracking is false", () => { + updateConfig({ enableTracking: false }); + const pr = makeNeedsReviewPR({ title: "chore(deps): update lodash to v5" }); + renderTab({ pullRequests: [pr] }); + + expect(screen.queryByRole("button", { name: /^Pin #/ })).toBeNull(); + }); + + it("track button renders when enableTracking is true", () => { + updateConfig({ enableTracking: true }); + const pr = makeNeedsReviewPR({ title: "chore(deps): update lodash to v5" }); + renderTab({ pullRequests: [pr] }); + + expect(screen.getByRole("button", { name: /^Pin #/ })).toBeDefined(); + }); + + it("clicking track button adds the PR to trackedItems", () => { + updateConfig({ enableTracking: true }); + const pr = makeNeedsReviewPR({ id: 6001, title: "Bump react from 17 to 18" }); + renderTab({ pullRequests: [pr] }); + + fireEvent.click(screen.getByRole("button", { name: /^Pin #/ })); + + expect(viewState.trackedItems.some((t) => t.id === 6001 && t.type === "pullRequest")).toBe(true); + }); + + it("clicking track button a second time removes the PR from trackedItems (toggle)", () => { + updateConfig({ enableTracking: true }); + const pr = makeNeedsReviewPR({ id: 6002, title: "Bump typescript from 4 to 5" }); + renderTab({ pullRequests: [pr] }); + + // First click: track (aria-label is "Pin #…") + fireEvent.click(screen.getByRole("button", { name: /^Pin #/ })); + expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(true); + + // Second click: untrack (aria-label switches to "Unpin #…" when tracked) + fireEvent.click(screen.getByRole("button", { name: /^Unpin #/ })); + expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(false); + }); +}); diff --git a/tests/services/api-dashboard-bodies.test.ts b/tests/services/api-dashboard-bodies.test.ts new file mode 100644 index 00000000..e65a71d4 --- /dev/null +++ b/tests/services/api-dashboard-bodies.test.ts @@ -0,0 +1,250 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchDashboardIssueBodies } from "../../src/app/services/api"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +// updateGraphqlRateLimit lives in github.ts — mock the whole module +const mockUpdateGraphqlRateLimit = vi.fn(); +vi.mock("../../src/app/services/github", () => ({ + getClient: vi.fn(() => null), + cachedRequest: vi.fn(), + updateGraphqlRateLimit: (...args: unknown[]) => mockUpdateGraphqlRateLimit(...args), + fetchRateLimitDetails: vi.fn(), + onApiRequest: vi.fn(), + initClientWatcher: vi.fn(), + getCoreRateLimit: vi.fn(() => null), + getGraphqlRateLimit: vi.fn(() => null), +})); + +vi.mock("../../src/app/lib/errors", () => ({ + pushNotification: vi.fn(), + pushError: vi.fn(), + getErrors: vi.fn().mockReturnValue([]), + dismissError: vi.fn(), + getNotifications: vi.fn().mockReturnValue([]), + getUnreadCount: vi.fn().mockReturnValue(0), + markAllAsRead: vi.fn(), + isMuted: vi.fn(() => false), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// NODES_BATCH_SIZE is 100 (internal to api.ts — confirmed from fetchPREnrichment usage) +const NODES_BATCH_SIZE = 100; + +function makeRateLimit() { + return { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-01-01T01:00:00Z" }; +} + +function makeOctokit(graphqlImpl: (query: string, variables: unknown) => Promise) { + return { + graphql: vi.fn(graphqlImpl), + request: vi.fn(), + paginate: { iterator: vi.fn() }, + } as unknown as Parameters[0]; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("fetchDashboardIssueBodies — empty input", () => { + it("returns an empty Map immediately without calling graphql", async () => { + const octokit = makeOctokit(async () => ({})); + const result = await fetchDashboardIssueBodies(octokit, []); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); +}); + +describe("fetchDashboardIssueBodies — single batch", () => { + it("fetches bodies for a list of node IDs and returns a Map keyed by nodeId", async () => { + const nodes = [ + { id: "N_1", body: "Dashboard body text" }, + { id: "N_2", body: null }, + ]; + const octokit = makeOctokit(async () => ({ + nodes, + rateLimit: makeRateLimit(), + })); + + const result = await fetchDashboardIssueBodies(octokit, ["N_1", "N_2"]); + + expect(result.get("N_1")).toBe("Dashboard body text"); + expect(result.get("N_2")).toBeNull(); + expect(result.size).toBe(2); + }); + + it("calls updateGraphqlRateLimit when rateLimit is in response", async () => { + const rl = makeRateLimit(); + const octokit = makeOctokit(async () => ({ + nodes: [{ id: "N_1", body: "body" }], + rateLimit: rl, + })); + + await fetchDashboardIssueBodies(octokit, ["N_1"]); + + expect(mockUpdateGraphqlRateLimit).toHaveBeenCalledWith(rl); + }); + + it("does not call updateGraphqlRateLimit when rateLimit is absent", async () => { + const octokit = makeOctokit(async () => ({ + nodes: [{ id: "N_1", body: "body" }], + // no rateLimit field + })); + + await fetchDashboardIssueBodies(octokit, ["N_1"]); + + expect(mockUpdateGraphqlRateLimit).not.toHaveBeenCalled(); + }); + + it("skips null nodes (items that are not Issues)", async () => { + const octokit = makeOctokit(async () => ({ + nodes: [null, { id: "N_2", body: "valid body" }, null], + })); + + const result = await fetchDashboardIssueBodies(octokit, ["N_1", "N_2", "N_3"]); + + expect(result.size).toBe(1); + expect(result.get("N_2")).toBe("valid body"); + }); +}); + +describe("fetchDashboardIssueBodies — batch splitting", () => { + it("issues two graphql calls when input exceeds NODES_BATCH_SIZE", async () => { + const ids = Array.from({ length: NODES_BATCH_SIZE + 1 }, (_, i) => `N_${i}`); + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds } = variables as { ids: string[] }; + return { + nodes: batchIds.map((id) => ({ id, body: `body-${id}` })), + }; + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + expect(octokit.graphql).toHaveBeenCalledTimes(2); + expect(result.size).toBe(NODES_BATCH_SIZE + 1); + }); + + it("first batch has exactly NODES_BATCH_SIZE items", async () => { + const ids = Array.from({ length: NODES_BATCH_SIZE + 5 }, (_, i) => `N_${i}`); + const batchSizes: number[] = []; + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds } = variables as { ids: string[] }; + batchSizes.push(batchIds.length); + return { nodes: batchIds.map((id) => ({ id, body: null })) }; + }); + + await fetchDashboardIssueBodies(octokit, ids); + + expect(batchSizes[0]).toBe(NODES_BATCH_SIZE); + expect(batchSizes[1]).toBe(5); + }); + + it("collects results from both batches into a single Map", async () => { + const firstBatch = Array.from({ length: NODES_BATCH_SIZE }, (_, i) => `A_${i}`); + const secondBatch = ["B_0", "B_1"]; + const allIds = [...firstBatch, ...secondBatch]; + + const octokit = makeOctokit(async (_query: string, variables: unknown) => { + const { ids: batchIds } = variables as { ids: string[] }; + return { + nodes: batchIds.map((id) => ({ id, body: `body-${id}` })), + }; + }); + + const result = await fetchDashboardIssueBodies(octokit, allIds); + + expect(result.size).toBe(NODES_BATCH_SIZE + 2); + expect(result.get("A_0")).toBe("body-A_0"); + expect(result.get("B_1")).toBe("body-B_1"); + }); +}); + +describe("fetchDashboardIssueBodies — partial error handling", () => { + it("returns successfully fetched nodes even when some are null", async () => { + const octokit = makeOctokit(async () => ({ + nodes: [ + null, + { id: "N_ok", body: "good body" }, + null, + ], + })); + + const result = await fetchDashboardIssueBodies(octokit, ["N_null1", "N_ok", "N_null2"]); + + expect(result.get("N_ok")).toBe("good body"); + expect(result.size).toBe(1); + }); + + it("gracefully ignores nodes missing an id field", async () => { + const octokit = makeOctokit(async () => ({ + nodes: [ + { id: "", body: "should be skipped" }, + { id: "N_valid", body: "kept" }, + ], + })); + + const result = await fetchDashboardIssueBodies(octokit, ["N_empty", "N_valid"]); + + // Node with empty id is skipped (falsy guard: !node.id) + expect(result.get("N_valid")).toBe("kept"); + expect(result.size).toBe(1); + }); +}); + +describe("fetchDashboardIssueBodies — GraphQL error handling", () => { + it("returns empty Map when graphql rejects (no partial data)", async () => { + const octokit = makeOctokit(async () => { + throw new Error("GraphQL network error"); + }); + + const result = await fetchDashboardIssueBodies(octokit, ["N_1"]); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it("calls updateGraphqlRateLimit from partial data on GraphQL error", async () => { + const rl = makeRateLimit(); + const err = Object.assign(new Error("Partial"), { + data: { rateLimit: rl, nodes: [] }, + }); + const octokit = makeOctokit(async () => { throw err; }); + + await fetchDashboardIssueBodies(octokit, ["N_1"]); + + expect(mockUpdateGraphqlRateLimit).toHaveBeenCalledWith(rl); + }); + + it("does not call updateGraphqlRateLimit when error has no partial data", async () => { + const octokit = makeOctokit(async () => { + throw new Error("Hard failure, no data"); + }); + + await fetchDashboardIssueBodies(octokit, ["N_1"]); + + expect(mockUpdateGraphqlRateLimit).not.toHaveBeenCalled(); + }); + + 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"); + return { nodes: batchIds.map((id) => ({ id, body: `body-${id}` })) }; + }); + + const result = await fetchDashboardIssueBodies(octokit, ids); + + // Second batch (1 item) should succeed despite first failing + expect(result.size).toBe(1); + expect(result.get(`N_${NODES_BATCH_SIZE}`)).toBe(`body-N_${NODES_BATCH_SIZE}`); + }); +}); diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 353accd6..a1981a09 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -13,6 +13,7 @@ import { resetAllTabFilters, initViewPersistence, ViewStateSchema, + DependencyFiltersSchema, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, @@ -856,6 +857,92 @@ describe("expandedRepos — dynamic tab keys", () => { }); }); +// ── DependencyFiltersSchema ─────────────────────────────────────────────────── + +describe("DependencyFiltersSchema", () => { + it("parse({}) returns defaults: updateType='all', bot='all'", () => { + const result = DependencyFiltersSchema.parse({}); + expect(result.updateType).toBe("all"); + expect(result.bot).toBe("all"); + }); + + it("accepts valid updateType values", () => { + for (const v of ["all", "major", "minor", "patch"] as const) { + expect(DependencyFiltersSchema.parse({ updateType: v }).updateType).toBe(v); + } + }); + + it("rejects invalid updateType values", () => { + const result = DependencyFiltersSchema.safeParse({ updateType: "invalid" }); + expect(result.success).toBe(false); + }); + + it("accepts any string value for bot", () => { + const result = DependencyFiltersSchema.parse({ bot: "renovate[bot]" }); + expect(result.bot).toBe("renovate[bot]"); + }); + + it("defaults bot to 'all' when not provided", () => { + const result = DependencyFiltersSchema.parse({ updateType: "major" }); + expect(result.bot).toBe("all"); + }); +}); + +describe("setTabFilter / resetAllTabFilters — dependencies", () => { + beforeEach(() => resetViewState()); + + it("setTabFilter('dependencies', 'updateType', 'major') persists in viewState", () => { + setTabFilter("dependencies", "updateType", "major"); + expect(viewState.tabFilters.dependencies.updateType).toBe("major"); + }); + + it("setTabFilter('dependencies', 'bot', 'renovate[bot]') persists in viewState", () => { + setTabFilter("dependencies", "bot", "renovate[bot]"); + expect(viewState.tabFilters.dependencies.bot).toBe("renovate[bot]"); + }); + + it("setTabFilter for one field does not change other dependency filter fields", () => { + setTabFilter("dependencies", "bot", "dependabot[bot]"); + // updateType should remain at its default + expect(viewState.tabFilters.dependencies.updateType).toBe("all"); + }); + + it("resetAllTabFilters('dependencies') resets updateType to 'all'", () => { + setTabFilter("dependencies", "updateType", "patch"); + expect(viewState.tabFilters.dependencies.updateType).toBe("patch"); + resetAllTabFilters("dependencies"); + expect(viewState.tabFilters.dependencies.updateType).toBe("all"); + }); + + it("resetAllTabFilters('dependencies') resets bot to 'all'", () => { + setTabFilter("dependencies", "bot", "renovate[bot]"); + resetAllTabFilters("dependencies"); + expect(viewState.tabFilters.dependencies.bot).toBe("all"); + }); + + it("resetAllTabFilters('dependencies') resets both fields simultaneously", () => { + setTabFilter("dependencies", "updateType", "minor"); + setTabFilter("dependencies", "bot", "dependabot[bot]"); + resetAllTabFilters("dependencies"); + expect(viewState.tabFilters.dependencies.updateType).toBe("all"); + expect(viewState.tabFilters.dependencies.bot).toBe("all"); + }); + + it("ViewStateSchema.parse({}) includes dependencies tabFilter defaults", () => { + const result = ViewStateSchema.parse({}); + expect(result.tabFilters.dependencies.updateType).toBe("all"); + expect(result.tabFilters.dependencies.bot).toBe("all"); + }); + + it("dependencies filter state is not affected by resetAllTabFilters('issues')", () => { + setTabFilter("dependencies", "updateType", "minor"); + setTabFilter("issues", "role", "author"); + resetAllTabFilters("issues"); + // dependencies should be unchanged — only issues was reset + expect(viewState.tabFilters.dependencies.updateType).toBe("minor"); + }); +}); + describe("loadViewState — cap-guard integration", () => { afterEach(() => { localStorageMock.clear(); From d35c498a028a0999c52151119e46fe7156275376 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 13:30:43 -0400 Subject: [PATCH 13/48] refactor(deps): removes redundant filters and slop comments --- .../components/dashboard/DependenciesTab.tsx | 21 ++++--------------- .../dashboard/DependenciesTab.test.tsx | 15 ------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index da7285d0..778c69f5 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -4,7 +4,7 @@ import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, unt import { isSafeGitHubUrl } from "../../lib/url"; import type { PullRequest } from "../../services/api"; import type { AbandonedDependency } from "../../lib/dependency-dashboard"; -import { classifyDepStatus, extractVersionInfo, isRebasing, STALE_THRESHOLD_DEFAULT_DAYS, type DepStatus } from "../../lib/dependency-detection"; +import { classifyDepStatus, extractVersionInfo, isRebasing, type DepStatus } from "../../lib/dependency-detection"; import { matchAbandonedToPr } from "../../lib/dependency-dashboard"; import type { FilterChipGroupDef } from "../shared/filterTypes"; import FilterToolbar from "../shared/FilterToolbar"; @@ -96,14 +96,13 @@ export default function DependenciesTab(props: DependenciesTabProps) { const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; return { pr, - status: classifyDepStatus(pr, STALE_THRESHOLD_DEFAULT_DAYS), + status: classifyDepStatus(pr), versionInfo, rebasing: isRebasing(pr, props.rebaseLabel), abandonedDep: matchAbandonedToPr(pr, abandonedDeps), }; }) .filter(({ pr, versionInfo }) => { - if (pr.state !== "OPEN") return false; if (ignored.has(pr.id)) return false; // Bot filter @@ -119,8 +118,6 @@ export default function DependenciesTab(props: DependenciesTabProps) { .sort((a, b) => (a.pr.updatedAt < b.pr.updatedAt ? 1 : a.pr.updatedAt > b.pr.updatedAt ? -1 : 0)); }); - const openPrCount = createMemo(() => props.pullRequests.filter(p => p.state === "OPEN").length); - const statusGroups = createMemo(() => { const groups: Record = { "needs-review": [], @@ -148,7 +145,6 @@ export default function DependenciesTab(props: DependenciesTabProps) { return (
- {/* Filter toolbar */}
- {/* Loading skeleton */} - {/* Empty state */} - +
- {/* No results from filter */} - 0}> + 0}>

No PRs match your current filters

- {/* Status groups */} 0}>
0}>
- {/* Group header */} - {/* PR rows */}
@@ -303,7 +293,6 @@ function StatusGroup(props: StatusGroupProps) { isPolling={props.hotPollingPRIds?.has(pr.id)} >
- {/* Version badge */} {(updateType) => ( - {/* Rebase indicator */} Rebasing - {/* Draft indicator */} Draft diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index d37f5e3c..0170dfce 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -358,21 +358,6 @@ describe("DependenciesTab — bot filter", () => { }); }); -// ── Closed PRs excluded ─────────────────────────────────────────────────────── - -describe("DependenciesTab — state filtering", () => { - it("does not render closed PRs", () => { - const closed = makePullRequest({ - userLogin: "renovate[bot]", - headRef: "renovate/lodash", - title: "This PR is closed", - state: "CLOSED", - }); - renderTab({ pullRequests: [closed] }); - expect(screen.queryByText(closed.title)).toBeNull(); - }); -}); - // ── Ignore button ───────────────────────────────────────────────────────────── describe("DependenciesTab — ignore button", () => { From d25f4ec7de00f69a5f4a43326e7cc95a8b47ffbe Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 13:41:32 -0400 Subject: [PATCH 14/48] perf(deps): removes redundant state filter in bot options memo --- src/app/components/dashboard/DependenciesTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 778c69f5..5ef6b484 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -64,7 +64,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { })); const botOptions = createMemo(() => { - const logins = [...new Set(props.pullRequests.filter((pr) => pr.state === "OPEN").map((pr) => pr.userLogin))].sort(); + const logins = [...new Set(props.pullRequests.map((pr) => pr.userLogin))].sort(); return { label: "Bot", field: "bot", From d0cd8f7a17a26d3c74ca22d37b17d22a591ae4d5 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 13:50:56 -0400 Subject: [PATCH 15/48] fix(deps): removes dead props, adds dependencies to settings export --- src/app/components/dashboard/DashboardPage.tsx | 2 -- src/app/components/dashboard/DependenciesTab.tsx | 2 -- src/app/components/settings/SettingsPage.tsx | 1 + 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 30c28da4..de9b3cae 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1248,8 +1248,6 @@ export default function DashboardPage() { ; abandonedDepsMap: Map; dashboardIssueUrls: Map; hotPollingPRIds?: ReadonlySet; diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 0a010798..c2542e58 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -190,6 +190,7 @@ export default function SettingsPage() { customFields: config.jira?.customFields ?? [], customScopes: config.jira?.customScopes ?? [], }, + dependencies: config.dependencies, }, null, 2 From 95aa4fd1e856cb3fedde6caf31866b1a1c5e95a7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 13:57:34 -0400 Subject: [PATCH 16/48] fix(deps): defaults only Needs Review group expanded per plan --- src/app/components/dashboard/DependenciesTab.tsx | 2 +- tests/components/DashboardPage.test.tsx | 3 ++- tests/components/dashboard/DependenciesTab.test.tsx | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index e73f7f0d..19a8215b 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -44,7 +44,7 @@ interface DependenciesTabProps { export default function DependenciesTab(props: DependenciesTabProps) { const [expandedGroups, setExpandedGroups] = createSignal>( - new Set(["needs-review", "waiting", "stale"]) + new Set(["needs-review"]) ); function toggleGroup(status: DepStatus) { diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 909f236d..14963f4f 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2190,7 +2190,8 @@ describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clea headRef: "dependabot/npm_and_yarn/axios-1.0.0", state: "OPEN", enriched: true, - checkStatus: "pending", + checkStatus: "success", + reviewDecision: null, draft: false, }); vi.mocked(pollService.fetchAllData).mockResolvedValue({ diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 0170dfce..b589456c 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -157,18 +157,18 @@ describe("DependenciesTab — status groups", () => { expect(screen.getByText(pr.title)).toBeDefined(); }); - it("Waiting group expanded by default — PR title visible", () => { + it("Waiting group collapsed by default — PR title hidden", () => { const pr = makeWaitingPR(); renderTab({ pullRequests: [pr] }); - expect(screen.getByText(pr.title)).toBeDefined(); + expect(screen.queryByText(pr.title)).toBeNull(); }); - it("collapses Waiting group when header is clicked", () => { + it("expands Waiting group when header is clicked", () => { const pr = makeWaitingPR(); renderTab({ pullRequests: [pr] }); const header = screen.getByText("Waiting").closest("button")!; fireEvent.click(header); - expect(screen.queryByText(pr.title)).toBeNull(); + expect(screen.getByText(pr.title)).toBeDefined(); }); it("collapses an expanded group on second click", () => { From 9fda0289fec65ebb1d000c151f78db5a347bff90 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 14:05:15 -0400 Subject: [PATCH 17/48] fix(deps): gates pre-exclusivity on config.dependencies.enabled --- .../components/dashboard/DashboardPage.tsx | 7 ++++--- tests/components/DashboardPage.test.tsx | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index de9b3cae..b1927596 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -854,9 +854,10 @@ export default function DashboardPage() { const issueOwner = new Map(); const prOwner = new Map(); const runOwner = new Map(); - // Pre-exclusivity: dep PRs claimed before custom tab ownership runs - for (const id of dependencyPrIds()) { - prOwner.set(id, "dependencies"); + if (config.dependencies.enabled) { + for (const id of dependencyPrIds()) { + prOwner.set(id, "dependencies"); + } } for (const tab of config.customTabs) { if (!tab.exclusive) continue; diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 14963f4f..8ac626cc 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2035,6 +2035,25 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); }); + it("dep PRs appear on Pull Requests tab when dependencies feature is disabled", async () => { + configStore.updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [ + makePullRequest({ id: 1, title: "Bump lodash from 4.0 to 5.0", userLogin: "dependabot[bot]" }), + makePullRequest({ id: 2, title: "Normal feature PR", userLogin: "developer" }), + ], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { + const prTab = screen.getByRole("tab", { name: /Pull Requests/ }); + expect(prTab.textContent?.replace(/\D+/g, "")).toBe("2"); + }); + }); + it("Dependencies tab count reflects dep PR count (excluding ignored)", async () => { vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], From d3203d672eb396dc19f8b2e76832d667601b9353 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 14:08:43 -0400 Subject: [PATCH 18/48] fix(deps): corrects stale comment in PersonalSummaryStrip --- src/app/components/dashboard/PersonalSummaryStrip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 9c0c7acf..b8d8aea2 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -28,7 +28,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { return ids; }); - // Single-pass over issues to count assigned (excludes ignored + Dep Dashboard) + // Single-pass over issues to count assigned (excludes ignored) const issueCounts = createMemo(() => { const login = props.userLogin.toLowerCase(); if (!login) return { assignedIssues: 0 }; From b5ac7beb4a9f6db315cb967375bfe3d7f11edb2b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 14:33:23 -0400 Subject: [PATCH 19/48] fix(deps): resolves 9 quality-gate gaps - Add e2e/dependencies.spec.ts with 4 tests: tab auto-appears, absent when no dep PRs, hidden when settings disabled, status groups render - Add bot avatar (20px round) before badges in DependenciesTab PR rows - Gate dependencyPullRequests memo with config.dependencies.enabled check - Fix aria-controls violation: replace with CSS hidden class so content div stays in DOM when collapsed - Wire Dependencies settings toggles through saveWithFeedback for saved indicator parity with notification toggles - Add tests: loading skeleton, no-filter-matches empty state, resetAbandonedPatternCache cache clearing, equal-version semver edge case - Update collapse tests to check hidden class instead of DOM absence --- e2e/dependencies.spec.ts | 104 +++++++++++++ .../components/dashboard/DashboardPage.tsx | 7 +- .../components/dashboard/DependenciesTab.tsx | 137 +++++++++--------- src/app/components/settings/SettingsPage.tsx | 4 +- .../dashboard/DependenciesTab.test.tsx | 32 +++- tests/lib/dependency-dashboard.test.ts | 17 +++ tests/lib/dependency-detection.test.ts | 5 + 7 files changed, 231 insertions(+), 75 deletions(-) create mode 100644 e2e/dependencies.spec.ts diff --git a/e2e/dependencies.spec.ts b/e2e/dependencies.spec.ts new file mode 100644 index 00000000..5db6cc68 --- /dev/null +++ b/e2e/dependencies.spec.ts @@ -0,0 +1,104 @@ +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 expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible(); +}); + +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 have enriched=false, so recent ones land in "Waiting" 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("Waiting")).toBeVisible(); + await expect(page.getByText("Stale")).toBeVisible(); +}); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index b1927596..e5ec473f 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -822,9 +822,10 @@ export default function DashboardPage() { const trackedBotLogins = createMemo(() => new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) ); - const dependencyPullRequests = createMemo(() => - dashboardData.pullRequests.filter((pr) => pr.state === "OPEN" && isDependencyPr(pr, trackedBotLogins())) - ); + 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)) ); diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 19a8215b..983e10b9 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -267,77 +267,82 @@ function StatusGroup(props: StatusGroupProps) { {props.items.length} - -
- - {({ pr, versionInfo, rebasing, abandonedDep }) => { - const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); - 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)} - > -
- - {(updateType) => ( - - {updateType()} - - )} - +
+ + {({ pr, versionInfo, rebasing, abandonedDep }) => { + const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); + 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)} + > +
+ + {pr.userLogin} + - - Rebasing - + + {(updateType) => ( + + {updateType()} + + )} + - - Draft - + + Rebasing + - {/* Abandoned dep pill — SEC-001: URL validated before use as href */} - - Abandoned dep - } + + Draft + + + + Abandoned dep + } + > + e.stopPropagation()} > - e.stopPropagation()} - > - Abandoned dep - - + Abandoned dep + -
-
-
- ); - }} -
-
- + +
+
+
+ ); + }} +
+
); diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index c2542e58..c46ef88d 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -1143,7 +1143,7 @@ export default function SettingsPage() { class="toggle toggle-primary" aria-label="Enable dependencies tab" checked={config.dependencies?.enabled ?? true} - onChange={() => updateDependencyConfig({ enabled: !(config.dependencies?.enabled ?? true) })} + onChange={() => saveWithFeedback({ dependencies: { ...config.dependencies, enabled: !(config.dependencies?.enabled ?? true) } })} /> updateDependencyConfig({ rebaseLabel: e.currentTarget.value || "rebase" })} + onInput={(e) => saveWithFeedback({ dependencies: { ...config.dependencies, rebaseLabel: e.currentTarget.value || "rebase" } })} />
diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index b589456c..64c00825 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -128,6 +128,27 @@ describe("DependenciesTab — empty state", () => { expect(screen.queryByText("Waiting")).toBeNull(); expect(screen.queryByText("Stale")).toBeNull(); }); + + it("shows loading skeleton when loading with no PRs", () => { + renderTab({ loading: true, pullRequests: [] }); + expect(screen.getByRole("status", { name: "Loading dependency PRs" })).toBeDefined(); + }); + + it("shows 'no filter matches' when filters exclude all PRs", () => { + const pr = makePullRequest({ + userLogin: "renovate[bot]", + headRef: "renovate/lodash-4.x", + title: "Bump lodash from 4.17.20 to 4.17.21", + state: "OPEN", + enriched: true, + checkStatus: "success", + reviewDecision: null, + draft: false, + }); + setTabFilter("dependencies", "updateType", "major"); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("No PRs match your current filters")).toBeDefined(); + }); }); // ── Status groups ───────────────────────────────────────────────────────────── @@ -157,10 +178,11 @@ describe("DependenciesTab — status groups", () => { expect(screen.getByText(pr.title)).toBeDefined(); }); - it("Waiting group collapsed by default — PR title hidden", () => { + it("Waiting group collapsed by default — content div is hidden", () => { const pr = makeWaitingPR(); renderTab({ pullRequests: [pr] }); - expect(screen.queryByText(pr.title)).toBeNull(); + const contentDiv = document.getElementById("dep-group-waiting")!; + expect(contentDiv.classList.contains("hidden")).toBe(true); }); it("expands Waiting group when header is clicked", () => { @@ -168,7 +190,8 @@ describe("DependenciesTab — status groups", () => { renderTab({ pullRequests: [pr] }); const header = screen.getByText("Waiting").closest("button")!; fireEvent.click(header); - expect(screen.getByText(pr.title)).toBeDefined(); + const contentDiv = document.getElementById("dep-group-waiting")!; + expect(contentDiv.classList.contains("hidden")).toBe(false); }); it("collapses an expanded group on second click", () => { @@ -176,7 +199,8 @@ describe("DependenciesTab — status groups", () => { renderTab({ pullRequests: [pr] }); const header = screen.getByText("Needs Review").closest("button")!; fireEvent.click(header); - expect(screen.queryByText(pr.title)).toBeNull(); + const contentDiv = document.getElementById("dep-group-needs-review")!; + expect(contentDiv.classList.contains("hidden")).toBe(true); }); it("shows count badge in group header", () => { diff --git a/tests/lib/dependency-dashboard.test.ts b/tests/lib/dependency-dashboard.test.ts index 66147894..ae34d3e2 100644 --- a/tests/lib/dependency-dashboard.test.ts +++ b/tests/lib/dependency-dashboard.test.ts @@ -3,6 +3,7 @@ import { findDashboardIssues, parseAbandonedSection, matchAbandonedToPr, + resetAbandonedPatternCache, escapeRegex, } from "../../src/app/lib/dependency-dashboard.js"; import { makeIssue, makePullRequest } from "../helpers/factories.js"; @@ -236,3 +237,19 @@ describe("matchAbandonedToPr", () => { expect(() => matchAbandonedToPr(pr, adversarial)).not.toThrow(); }); }); + +describe("resetAbandonedPatternCache", () => { + it("clears the regex cache without breaking subsequent matches", () => { + const deps = [{ datasource: "npm", packageName: "lodash", lastUpdated: "2023-01-15" }]; + const pr = makePullRequest({ title: "Bump lodash from 4.0.0 to 4.17.21" }); + + // Populate the cache + expect(matchAbandonedToPr(pr, deps)).toEqual(deps[0]); + + // Clear it + resetAbandonedPatternCache(); + + // Matching still works (cache rebuilt on demand, not stale) + expect(matchAbandonedToPr(pr, deps)).toEqual(deps[0]); + }); +}); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 54cc5951..28eaa614 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -160,6 +160,11 @@ describe("extractVersionInfo", () => { it("returns null for unrecognized title format", () => { expect(extractVersionInfo("Fix a bug in auth flow")).toBeNull(); }); + + it("returns null updateType when from and to are identical versions", () => { + const result = extractVersionInfo("Bump lodash from 4.17.21 to 4.17.21"); + expect(result).toEqual({ from: "4.17.21", to: "4.17.21", updateType: undefined }); + }); }); describe("isRebasing", () => { From 05f1d2281bcc991ea7923626e0dc5c8e5f6fb1e9 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 15:57:10 -0400 Subject: [PATCH 20/48] feat(deps): resolves 12 UAT findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename statuses: needs-review→Mergeable, waiting→Needs Action (#9) - Add Pending Rebase status group with first-priority classification (#14) - Remove duplicate All entries from filter popovers (#1) - Restyle status group headers to match RepoGroupHeader patterns (#2) - Add ExpandCollapseButtons for status groups (#3) - Add StatusDot, SizeBadge, ReviewBadge to PR rows (#4) - Persist expand state in viewState.dependencyExpandedGroups (#5) - Structured title display with package name and version arrow (#6) - Wire viewState.globalSort into classifiedPRs with repo tiebreaker (#7) - Remove count badges from status group headers (#10) - Unknown bot detection with one-time info notification (#12) - Filter out dep-tool labels (dependencies, renovate) from PR rows (#13) --- .../components/dashboard/DependenciesTab.tsx | 282 +++++++++++------- src/app/lib/dependency-detection.ts | 88 ++++-- src/app/stores/view.ts | 10 + tests/components/DashboardPage.test.tsx | 4 +- .../dashboard/DependenciesTab.test.tsx | 248 +++++++++------ tests/lib/dependency-detection.test.ts | 141 ++++++--- 6 files changed, 509 insertions(+), 264 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 983e10b9..f41d0fc6 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -1,13 +1,27 @@ -import { createMemo, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, For, on, Show } from "solid-js"; import { config } from "../../stores/config"; -import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem, DependencyFiltersSchema } from "../../stores/view"; +import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem, DependencyFiltersSchema, setDependencyExpandedGroups } from "../../stores/view"; import { isSafeGitHubUrl } from "../../lib/url"; +import { pushNotification } from "../../lib/errors"; import type { PullRequest } from "../../services/api"; import type { AbandonedDependency } from "../../lib/dependency-dashboard"; -import { classifyDepStatus, extractVersionInfo, isRebasing, type DepStatus } from "../../lib/dependency-detection"; +import { + classifyDepStatus, + extractVersionInfo, + ALL_DEP_STATUSES, + KNOWN_DEP_BOT_LOGINS, + 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 ChevronIcon from "../shared/ChevronIcon"; +import StatusDot from "../shared/StatusDot"; +import SizeBadge from "../shared/SizeBadge"; +import ReviewBadge from "../shared/ReviewBadge"; import ItemRow from "./ItemRow"; import SkeletonRows from "../shared/SkeletonRows"; @@ -17,18 +31,23 @@ const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { label: "Update type", field: "updateType", options: [ - { value: "all", label: "All" }, { value: "major", label: "Major" }, { value: "minor", label: "Minor" }, { value: "patch", label: "Patch" }, ], }; +const STATUS_META: Record = { + "mergeable": { label: "Mergeable", badgeClass: "badge-success", defaultExpanded: true }, + "pending-rebase": { label: "Pending Rebase", badgeClass: "badge-ghost", defaultExpanded: false }, + "needs-action": { label: "Needs Action", badgeClass: "badge-warning", defaultExpanded: true }, + "stale": { label: "Stale", badgeClass: "badge-error", defaultExpanded: false }, +}; + interface ClassifiedPR { pr: PullRequest; status: DepStatus; - versionInfo: ReturnType; - rebasing: boolean; + versionInfo: VersionInfo | null; abandonedDep: AbandonedDependency | null; } @@ -42,18 +61,27 @@ interface DependenciesTabProps { rebaseLabel: string; } +const _notifiedBots = new Set(); + export default function DependenciesTab(props: DependenciesTabProps) { - const [expandedGroups, setExpandedGroups] = createSignal>( - new Set(["needs-review"]) + const expandedGroups = createMemo(() => + new Set(viewState.dependencyExpandedGroups) ); function toggleGroup(status: DepStatus) { - setExpandedGroups((prev) => { - const next = new Set(prev); - if (next.has(status)) next.delete(status); - else next.add(status); - return next; - }); + 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(() => ({ @@ -66,10 +94,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { return { label: "Bot", field: "bot", - options: [ - { value: "all", label: "All" }, - ...logins.map((l) => ({ value: l, label: l })), - ], + options: logins.map((l) => ({ value: l, label: l })), }; }); @@ -85,6 +110,10 @@ export default function DependenciesTab(props: DependenciesTabProps) { : new Set() ); + const trackedBotLogins = createMemo(() => + new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) + ); + const classifiedPRs = createMemo(() => { const filters = activeFilters(); const ignored = ignoredIds(); @@ -94,40 +123,84 @@ export default function DependenciesTab(props: DependenciesTabProps) { const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; return { pr, - status: classifyDepStatus(pr), + status: classifyDepStatus(pr, props.rebaseLabel), versionInfo, - rebasing: isRebasing(pr, props.rebaseLabel), abandonedDep: matchAbandonedToPr(pr, abandonedDeps), }; }) .filter(({ pr, versionInfo }) => { if (ignored.has(pr.id)) return false; - - // Bot filter if (filters.bot !== "all" && pr.userLogin !== filters.bot) return false; - - // updateType filter — pass through when updateType is null (unknown) if (filters.updateType !== "all") { if (versionInfo !== null && versionInfo.updateType !== undefined && versionInfo.updateType !== filters.updateType) return false; } - return true; - }) - .sort((a, b) => (a.pr.updatedAt < b.pr.updatedAt ? 1 : a.pr.updatedAt > b.pr.updatedAt ? -1 : 0)); + }); + }); + + const sortedPRs = createMemo(() => { + const { field, direction } = viewState.globalSort; + const items = [...classifiedPRs()]; + const dir = direction === "asc" ? 1 : -1; + + items.sort((a, b) => { + 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; + return a.pr.repoFullName.localeCompare(b.pr.repoFullName); + }); + + return items; }); const statusGroups = createMemo(() => { const groups: Record = { - "needs-review": [], - waiting: [], - stale: [], + "mergeable": [], + "pending-rebase": [], + "needs-action": [], + "stale": [], }; - for (const item of classifiedPRs()) { + for (const item of sortedPRs()) { groups[item.status].push(item); } return groups; }); + // Unknown bot detection — one-time notification per session + createEffect( + on( + () => props.pullRequests, + (prs) => { + const known = trackedBotLogins(); + for (const pr of prs) { + const login = pr.userLogin.toLowerCase(); + if (KNOWN_DEP_BOT_LOGINS.has(login)) continue; + if (known.has(login)) continue; + if (_notifiedBots.has(login)) continue; + _notifiedBots.add(login); + pushNotification( + `unknown-dep-bot:${login}`, + `Found dependency PRs from "${pr.userLogin}". Track this bot in Settings → Tracked Users for full coverage.`, + "info" + ); + } + } + ) + ); + 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"); @@ -152,13 +225,17 @@ export default function DependenciesTab(props: DependenciesTabProps) { onResetAll={() => resetAllTabFilters("dependencies")} /> + - +
- 0}> + 0}>

No PRs match your current filters

- 0}> + 0}>
- toggleGroup("needs-review")} - dashboardIssueUrls={props.dashboardIssueUrls} - hotPollingPRIds={props.hotPollingPRIds} - refreshTick={props.refreshTick} - trackedPrIds={trackedPrIds()} - enableTracking={config.enableTracking} - onIgnore={handleIgnore} - onTrack={handleTrack} - /> - toggleGroup("waiting")} - dashboardIssueUrls={props.dashboardIssueUrls} - hotPollingPRIds={props.hotPollingPRIds} - refreshTick={props.refreshTick} - trackedPrIds={trackedPrIds()} - enableTracking={config.enableTracking} - onIgnore={handleIgnore} - onTrack={handleTrack} - /> - toggleGroup("stale")} - dashboardIssueUrls={props.dashboardIssueUrls} - hotPollingPRIds={props.hotPollingPRIds} - refreshTick={props.refreshTick} - trackedPrIds={trackedPrIds()} - enableTracking={config.enableTracking} - onIgnore={handleIgnore} - onTrack={handleTrack} - /> + + {(status) => ( + toggleGroup(status)} + dashboardIssueUrls={props.dashboardIssueUrls} + hotPollingPRIds={props.hotPollingPRIds} + refreshTick={props.refreshTick} + trackedPrIds={trackedPrIds()} + enableTracking={config.enableTracking} + onIgnore={handleIgnore} + onTrack={handleTrack} + /> + )} +
@@ -230,7 +280,6 @@ export default function DependenciesTab(props: DependenciesTabProps) { interface StatusGroupProps { status: DepStatus; label: string; - badgeClass: string; items: ClassifiedPR[]; expanded: boolean; onToggle: () => void; @@ -243,46 +292,49 @@ interface StatusGroupProps { onTrack: (pr: PullRequest) => void; } +function displayTitle(pr: PullRequest, versionInfo: VersionInfo | null): string { + if (!versionInfo?.packageName) return pr.title; + return versionInfo.packageName; +} + +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}>
- + + {props.label} + +
- {({ pr, versionInfo, rebasing, abandonedDep }) => { + {({ pr, versionInfo, abandonedDep }) => { const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); + const title = () => displayTitle(pr, versionInfo); return (
props.onIgnore(pr)} onTrack={props.enableTracking ? () => props.onTrack(pr) : undefined} @@ -298,6 +350,10 @@ function StatusGroup(props: StatusGroupProps) { /> + + + + {(updateType) => ( - - Rebasing + + + {versionInfo!.from} → {versionInfo!.to} + + + + + + → {versionInfo!.to} + + + + + + + + 0 || pr.deletions > 0)}> + diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index fc39db8a..d4de84b7 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -20,7 +20,19 @@ export const DEP_BRANCH_PREFIXES = [ export const DEP_TITLE_PATTERN = /^(Bump |Update dependency |chore\(deps|fix\(deps|build\(deps|\[Snyk\])/i; -export type DepStatus = "needs-review" | "waiting" | "stale"; +export const DEP_TOOL_LABEL_NAMES = new Set([ + "dependencies", + "renovate", +]); + +export type DepStatus = "mergeable" | "needs-action" | "stale" | "pending-rebase"; + +export const ALL_DEP_STATUSES: readonly DepStatus[] = [ + "mergeable", + "pending-rebase", + "needs-action", + "stale", +]; export function isDependencyPr(pr: PullRequest, trackedBotLogins: Set): boolean { const login = pr.userLogin.toLowerCase(); @@ -59,36 +71,50 @@ function semverUpdateType(from: string, to: string): "major" | "minor" | "patch" return null; } -export function extractVersionInfo( - title: string -): { from?: string; to?: string; updateType?: "major" | "minor" | "patch" } | null { - // Strip trailing annotations like " [security]" +export interface VersionInfo { + packageName?: string; + from?: string; + to?: string; + updateType?: "major" | "minor" | "patch"; +} + +export function extractVersionInfo(title: string): VersionInfo | null { const cleaned = title.replace(/\s*\[[\w\s]+\]\s*$/, "").trim(); - // Maintenance titles — no version classification if (/pin dependencies/i.test(cleaned)) return null; if (/lock file maintenance/i.test(cleaned)) return null; - // Dependabot "Bump X from A to B" - const bumpMatch = /\bfrom\s+([\w.\-+]+)\s+to\s+([\w.\-+]+)/i.exec(cleaned); + // Strip conventional commit prefix: chore(deps): / fix(deps-dev): / build(deps): + let body = cleaned; + const ccPrefix = /^(?:chore|fix|build)\(deps[^)]*\):\s*/i.exec(body); + if (ccPrefix) body = body.slice(ccPrefix[0].length); + + // "Bump X from A to B" or "Bump X from A to B in /dir" + const bumpMatch = /^Bump\s+(.+?)\s+from\s+([\w.\-+]+)\s+to\s+([\w.\-+]+)/i.exec(body); if (bumpMatch) { - const from = bumpMatch[1]!; - const to = bumpMatch[2]!; - const updateType = semverUpdateType(from, to) ?? undefined; - return { from, to, updateType }; + return { packageName: bumpMatch[1]!, from: bumpMatch[2]!, to: bumpMatch[3]!, updateType: semverUpdateType(bumpMatch[2]!, bumpMatch[3]!) ?? undefined }; } - // Renovate group: "update all major dependencies" - if (/update all major/i.test(cleaned)) return { updateType: "major" }; - // Renovate group: "update all non-major dependencies" - if (/update all non-major/i.test(cleaned)) return { updateType: "minor" }; - - // Renovate single-dep: "update dependency X to vY" or "update X action to vY" - const renovateMatch = /\bupdate\b.+\bto\s+(v?[\w.\-+]+)/i.exec(cleaned); - if (renovateMatch) { - const to = renovateMatch[1]!; - // Only treat as version if it looks like a version (starts with digit or v+digit) - if (/^v?\d/.test(to)) return { to }; + // "update all major/non-major dependencies" + if (/update all major/i.test(body)) return { updateType: "major" }; + if (/update all non-major/i.test(body)) return { updateType: "minor" }; + + // "Update dependency X to vY" + const depMatch = /^Update\s+dependency\s+(.+?)\s+to\s+(v?[\w.\-+]+)/i.exec(body); + if (depMatch && /^v?\d/.test(depMatch[2]!)) { + return { packageName: depMatch[1]!, to: depMatch[2]! }; + } + + // "Update X action to vY" + const actionMatch = /^Update\s+(.+?)\s+action\s+to\s+(v?[\w.\-+]+)/i.exec(body); + if (actionMatch && /^v?\d/.test(actionMatch[2]!)) { + return { packageName: actionMatch[1]!, to: actionMatch[2]! }; + } + + // Generic "from A to B" anywhere + const genericMatch = /\bfrom\s+([\w.\-+]+)\s+to\s+([\w.\-+]+)/i.exec(body); + if (genericMatch) { + return { from: genericMatch[1]!, to: genericMatch[2]!, updateType: semverUpdateType(genericMatch[1]!, genericMatch[2]!) ?? undefined }; } return null; @@ -104,24 +130,30 @@ export const STALE_THRESHOLD_DEFAULT_DAYS = 14; export function classifyDepStatus( pr: PullRequest, + rebaseLabel: string = "", staleThresholdDays: number = STALE_THRESHOLD_DEFAULT_DAYS ): DepStatus { - // needs-review: enriched, not draft, CI passing, not yet approved + // 1. Rebase label → pending-rebase + if (rebaseLabel && isRebasing(pr, rebaseLabel)) { + return "pending-rebase"; + } + + // 2. Enriched + CI green + not draft + not approved → mergeable if ( pr.enriched !== false && !pr.draft && pr.checkStatus === "success" && pr.reviewDecision !== "APPROVED" ) { - return "needs-review"; + return "mergeable"; } - // stale: not updated recently (even drafts/CI-pending get stale) + // 3. Stale: not updated recently const ageMs = Date.now() - new Date(pr.updatedAt).getTime(); if (ageMs > staleThresholdDays * 86_400_000) { return "stale"; } - // waiting: CI pending, draft, rebasing, unenriched, approved-but-not-merged - return "waiting"; + // 4. Everything else → needs-action + return "needs-action"; } diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 747d17ec..dea3e8a9 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -125,6 +125,7 @@ export const ViewStateSchema = z.object({ }), lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }), trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]), + dependencyExpandedGroups: z.array(z.string()).default(["mergeable"]), }); export type ViewState = z.infer; @@ -215,6 +216,7 @@ export function resetViewState(): void { expandedRepos: { issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} }, lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }, trackedItems: [], + dependencyExpandedGroups: ["mergeable"], }); }) ); @@ -326,6 +328,14 @@ export function resetAllTabFilters( ); } +export function setDependencyExpandedGroups(groups: string[]): void { + setViewState( + produce((draft) => { + draft.dependencyExpandedGroups = groups; + }) + ); +} + export function toggleExpandedRepo( tab: string, repoFullName: string diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 8ac626cc..8d256ab7 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2226,9 +2226,9 @@ describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clea await waitFor(() => screen.getByRole("tab", { name: /Dependencies/ })); await user.click(screen.getByRole("tab", { name: /Dependencies/ })); - // DependenciesTab renders the PR inside a status group + // DependenciesTab renders the PR with structured title (package name) await waitFor(() => { - expect(screen.getByText(depPR.title)).toBeDefined(); + expect(screen.getByText("axios")).toBeDefined(); }); }); }); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 64c00825..97434bce 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../src/app/lib/url", () => ({ import { render } from "@solidjs/testing-library"; import DependenciesTab from "../../../src/app/components/dashboard/DependenciesTab.js"; import { resetConfig, updateConfig } from "../../../src/app/stores/config.js"; -import { setTabFilter, resetViewState, viewState } from "../../../src/app/stores/view.js"; +import { setTabFilter, resetViewState, viewState, setDependencyExpandedGroups } from "../../../src/app/stores/view.js"; import { makePullRequest } from "../../helpers/factories.js"; import type { AbandonedDependency } from "../../../src/app/lib/dependency-dashboard.js"; @@ -43,8 +43,6 @@ const EMPTY_MAPS = { }; const BASE_PROPS = { - userLogin: "testuser", - trackedBotLogins: new Set(), rebaseLabel: "rebase", ...EMPTY_MAPS, }; @@ -54,8 +52,8 @@ function renderTab(overrides: Partial[0]> = { return render(() => ); } -// A PR that lands in "needs-review" (enriched, not draft, CI passing, not approved) -function makeNeedsReviewPR(overrides: Parameters[0] = {}) { +// A PR that lands in "mergeable" (enriched, not draft, CI passing, not approved) +function makeMergeablePR(overrides: Parameters[0] = {}) { return makePullRequest({ userLogin: "renovate[bot]", headRef: "renovate/lodash-4.x", @@ -70,8 +68,8 @@ function makeNeedsReviewPR(overrides: Parameters[0] = {} }); } -// A PR that lands in "waiting" (CI pending, recent so not stale) -function makeWaitingPR(overrides: Parameters[0] = {}) { +// A PR that lands in "needs-action" (CI pending, recent so not stale) +function makeNeedsActionPR(overrides: Parameters[0] = {}) { const recentDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2h ago return makePullRequest({ userLogin: "dependabot[bot]", @@ -124,8 +122,8 @@ describe("DependenciesTab — empty state", () => { it("does not show status group headers when empty", () => { renderTab({ pullRequests: [] }); - expect(screen.queryByText("Needs Review")).toBeNull(); - expect(screen.queryByText("Waiting")).toBeNull(); + expect(screen.queryByText("Mergeable")).toBeNull(); + expect(screen.queryByText("Needs Action")).toBeNull(); expect(screen.queryByText("Stale")).toBeNull(); }); @@ -154,16 +152,16 @@ describe("DependenciesTab — empty state", () => { // ── Status groups ───────────────────────────────────────────────────────────── describe("DependenciesTab — status groups", () => { - it("renders Needs Review group for enriched passing PRs", () => { - const pr = makeNeedsReviewPR(); + it("renders Mergeable group for enriched passing PRs", () => { + const pr = makeMergeablePR(); renderTab({ pullRequests: [pr] }); - expect(screen.getByText("Needs Review")).toBeDefined(); + expect(screen.getByText("Mergeable")).toBeDefined(); }); - it("renders Waiting group for CI-pending PRs", () => { - const pr = makeWaitingPR(); + it("renders Needs Action group for CI-pending PRs", () => { + const pr = makeNeedsActionPR(); renderTab({ pullRequests: [pr] }); - expect(screen.getByText("Waiting")).toBeDefined(); + expect(screen.getByText("Needs Action")).toBeDefined(); }); it("renders Stale group for old PRs", () => { @@ -172,43 +170,108 @@ describe("DependenciesTab — status groups", () => { expect(screen.getByText("Stale")).toBeDefined(); }); - it("shows Needs Review expanded by default — PR title visible", () => { - const pr = makeNeedsReviewPR(); + it("shows Mergeable expanded by default — displayed title visible", () => { + const pr = makeMergeablePR(); renderTab({ pullRequests: [pr] }); - expect(screen.getByText(pr.title)).toBeDefined(); + // "chore(deps): update dependency lodash to v5" → displayTitle: "lodash" + expect(screen.getByText("lodash")).toBeDefined(); }); - it("Waiting group collapsed by default — content div is hidden", () => { - const pr = makeWaitingPR(); + it("Needs Action group collapsed by default — content div is hidden", () => { + const pr = makeNeedsActionPR(); renderTab({ pullRequests: [pr] }); - const contentDiv = document.getElementById("dep-group-waiting")!; + const contentDiv = document.getElementById("dep-group-needs-action")!; expect(contentDiv.classList.contains("hidden")).toBe(true); }); - it("expands Waiting group when header is clicked", () => { - const pr = makeWaitingPR(); + it("expands Needs Action group when header is clicked", () => { + const pr = makeNeedsActionPR(); renderTab({ pullRequests: [pr] }); - const header = screen.getByText("Waiting").closest("button")!; + const header = screen.getByText("Needs Action").closest("button")!; fireEvent.click(header); - const contentDiv = document.getElementById("dep-group-waiting")!; + const contentDiv = document.getElementById("dep-group-needs-action")!; expect(contentDiv.classList.contains("hidden")).toBe(false); }); it("collapses an expanded group on second click", () => { - const pr = makeNeedsReviewPR(); + const pr = makeMergeablePR(); renderTab({ pullRequests: [pr] }); - const header = screen.getByText("Needs Review").closest("button")!; + const header = screen.getByText("Mergeable").closest("button")!; fireEvent.click(header); - const contentDiv = document.getElementById("dep-group-needs-review")!; + const contentDiv = document.getElementById("dep-group-mergeable")!; expect(contentDiv.classList.contains("hidden")).toBe(true); }); - it("shows count badge in group header", () => { - const pr1 = makeNeedsReviewPR(); - const pr2 = makeNeedsReviewPR({ title: "chore(deps): update dependency react to v18" }); + it("does not show count badges in group headers", () => { + const pr1 = makeMergeablePR(); + const pr2 = makeMergeablePR({ title: "Bump react from 17.0.0 to 18.0.0" }); renderTab({ pullRequests: [pr1, pr2] }); - const header = screen.getByText("Needs Review").closest("button")!; - expect(header.textContent).toContain("2"); + const header = screen.getByText("Mergeable").closest("button")!; + expect(header.textContent).not.toContain("2"); + }); +}); + +// ── Expand state persistence ───────────────────────────────────────────────── + +describe("DependenciesTab — expand state persistence", () => { + it("persists expanded groups to viewState", () => { + const pr = makeNeedsActionPR(); + renderTab({ pullRequests: [pr] }); + const header = screen.getByText("Needs Action").closest("button")!; + fireEvent.click(header); + expect(viewState.dependencyExpandedGroups).toContain("needs-action"); + }); + + it("restores expanded groups from viewState", () => { + setDependencyExpandedGroups(["needs-action", "stale"]); + const pr = makeNeedsActionPR(); + renderTab({ pullRequests: [pr] }); + const contentDiv = document.getElementById("dep-group-needs-action")!; + expect(contentDiv.classList.contains("hidden")).toBe(false); + }); + + it("expand all opens all groups", () => { + const pr1 = makeMergeablePR(); + const pr2 = makeNeedsActionPR(); + const pr3 = makeStalePR(); + renderTab({ pullRequests: [pr1, pr2, pr3] }); + const expandAllBtn = screen.getByLabelText("Expand all repos"); + fireEvent.click(expandAllBtn); + expect(document.getElementById("dep-group-mergeable")!.classList.contains("hidden")).toBe(false); + expect(document.getElementById("dep-group-needs-action")!.classList.contains("hidden")).toBe(false); + expect(document.getElementById("dep-group-stale")!.classList.contains("hidden")).toBe(false); + }); + + it("collapse all closes all groups", () => { + const pr = makeMergeablePR(); + renderTab({ pullRequests: [pr] }); + const collapseAllBtn = screen.getByLabelText("Collapse all repos"); + fireEvent.click(collapseAllBtn); + expect(document.getElementById("dep-group-mergeable")!.classList.contains("hidden")).toBe(true); + }); +}); + +// ── Pending Rebase status ──────────────────────────────────────────────────── + +describe("DependenciesTab — pending rebase", () => { + it("classifies PR with rebase label into Pending Rebase group", () => { + const pr = makeMergeablePR({ labels: [{ name: "rebase", color: "ededed" }] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + expect(screen.getByText("Pending Rebase")).toBeDefined(); + expect(screen.queryByText("Mergeable")).toBeNull(); + }); + + it("rebase classification is case-insensitive", () => { + const pr = makeMergeablePR({ labels: [{ name: "Rebase", color: "ededed" }] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + expect(screen.getByText("Pending Rebase")).toBeDefined(); + }); + + it("Pending Rebase group is collapsed by default", () => { + const pr = makeMergeablePR({ labels: [{ name: "rebase", color: "ededed" }] }); + renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); + const contentDiv = document.getElementById("dep-group-pending-rebase")!; + expect(contentDiv.classList.contains("hidden")).toBe(true); }); }); @@ -221,11 +284,11 @@ describe("DependenciesTab — stale threshold", () => { expect(screen.getByText("Stale")).toBeDefined(); }); - it("PR updated 12 hours ago is not stale (goes to waiting)", () => { + it("PR updated 12 hours ago is not stale (goes to needs-action)", () => { const recentDate = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); - const pr = makeWaitingPR({ updatedAt: recentDate }); + const pr = makeNeedsActionPR({ updatedAt: recentDate }); renderTab({ pullRequests: [pr] }); - expect(screen.getByText("Waiting")).toBeDefined(); + expect(screen.getByText("Needs Action")).toBeDefined(); expect(screen.queryByText("Stale")).toBeNull(); }); }); @@ -234,51 +297,41 @@ describe("DependenciesTab — stale threshold", () => { describe("DependenciesTab — version badges", () => { it("shows 'major' badge for major version bump", () => { - const pr = makeNeedsReviewPR({ title: "Bump lodash from 3.10.0 to 4.0.0" }); + const pr = makeMergeablePR({ title: "Bump lodash from 3.10.0 to 4.0.0" }); renderTab({ pullRequests: [pr] }); expect(screen.getByText("major")).toBeDefined(); }); it("shows 'minor' badge for minor version bump", () => { - const pr = makeNeedsReviewPR({ title: "Bump lodash from 4.16.0 to 4.17.0" }); + const pr = makeMergeablePR({ title: "Bump lodash from 4.16.0 to 4.17.0" }); renderTab({ pullRequests: [pr] }); expect(screen.getByText("minor")).toBeDefined(); }); it("shows 'patch' badge for patch version bump", () => { - const pr = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + const pr = makeMergeablePR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); renderTab({ pullRequests: [pr] }); expect(screen.getByText("patch")).toBeDefined(); }); it("shows no version badge for maintenance titles", () => { - const pr = makeNeedsReviewPR({ title: "chore(deps): pin dependencies" }); + const pr = makeMergeablePR({ title: "chore(deps): pin dependencies" }); renderTab({ pullRequests: [pr] }); expect(screen.queryByText("major")).toBeNull(); expect(screen.queryByText("minor")).toBeNull(); expect(screen.queryByText("patch")).toBeNull(); }); -}); -// ── Rebase indicator ────────────────────────────────────────────────────────── - -describe("DependenciesTab — rebase indicator", () => { - it("shows 'Rebasing' when PR has the rebase label", () => { - const pr = makeNeedsReviewPR({ labels: [{ name: "rebase", color: "ededed" }] }); - renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); - expect(screen.getByText("Rebasing")).toBeDefined(); - }); - - it("does not show 'Rebasing' when label does not match", () => { - const pr = makeNeedsReviewPR({ labels: [] }); - renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); - expect(screen.queryByText("Rebasing")).toBeNull(); + it("shows version arrow for bump PRs", () => { + const pr = makeMergeablePR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("4.17.20 → 4.17.21")).toBeDefined(); }); - it("rebase label check is case-insensitive", () => { - const pr = makeNeedsReviewPR({ labels: [{ name: "Rebase", color: "ededed" }] }); - renderTab({ pullRequests: [pr], rebaseLabel: "rebase" }); - expect(screen.getByText("Rebasing")).toBeDefined(); + it("shows structured title (package name) for parseable titles", () => { + const pr = makeMergeablePR({ title: "Bump webpack from 5.89.0 to 5.90.0" }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("webpack")).toBeDefined(); }); }); @@ -286,7 +339,7 @@ describe("DependenciesTab — rebase indicator", () => { describe("DependenciesTab — abandoned dep pill", () => { it("shows 'Abandoned dep' pill when PR title matches abandoned package", () => { - const pr = makeNeedsReviewPR({ + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5", repoFullName: "owner/repo", }); @@ -301,7 +354,7 @@ describe("DependenciesTab — abandoned dep pill", () => { }); it("does not show pill when no abandoned dep match", () => { - const pr = makeNeedsReviewPR({ title: "Bump react from 17.0.0 to 18.0.0" }); + const pr = makeMergeablePR({ title: "Bump react from 17.0.0 to 18.0.0" }); const abandonedDepsMap = new Map([ ["owner/repo", [{ datasource: "npm", packageName: "lodash", lastUpdated: "2024-01-01" }]], ]); @@ -310,7 +363,7 @@ describe("DependenciesTab — abandoned dep pill", () => { }); it("abandoned pill is an anchor when dashboard URL is safe (SEC-001)", () => { - const pr = makeNeedsReviewPR({ + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5", repoFullName: "owner/repo", }); @@ -327,7 +380,7 @@ describe("DependenciesTab — abandoned dep pill", () => { }); it("abandoned pill is a span when URL fails SEC-001 check", () => { - const pr = makeNeedsReviewPR({ + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5", repoFullName: "owner/repo", }); @@ -347,24 +400,25 @@ describe("DependenciesTab — abandoned dep pill", () => { describe("DependenciesTab — updateType filter", () => { it("shows all PRs by default (updateType=all)", () => { - const major = makeNeedsReviewPR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); - const patch = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + const major = makeMergeablePR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); + const patch = makeMergeablePR({ title: "Bump axios from 0.27.1 to 0.27.2" }); renderTab({ pullRequests: [major, patch] }); - expect(screen.getByText(major.title)).toBeDefined(); - expect(screen.getByText(patch.title)).toBeDefined(); + // Structured display shows package names + expect(screen.getByText("lodash")).toBeDefined(); + expect(screen.getByText("axios")).toBeDefined(); }); it("filters to major only when updateType=major is set", () => { - const major = makeNeedsReviewPR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); - const patch = makeNeedsReviewPR({ title: "Bump lodash from 4.17.20 to 4.17.21" }); + const major = makeMergeablePR({ title: "Bump lodash from 3.0.0 to 4.0.0" }); + const patch = makeMergeablePR({ title: "Bump axios from 0.27.1 to 0.27.2" }); setTabFilter("dependencies", "updateType", "major"); renderTab({ pullRequests: [major, patch] }); - expect(screen.getByText(major.title)).toBeDefined(); - expect(screen.queryByText(patch.title)).toBeNull(); + expect(screen.getByText("lodash")).toBeDefined(); + expect(screen.queryByText("axios")).toBeNull(); }); it("maintenance PRs pass through all updateType filters (unknown version type)", () => { - const pin = makeNeedsReviewPR({ title: "chore(deps): pin dependencies" }); + const pin = makeMergeablePR({ title: "chore(deps): pin dependencies" }); setTabFilter("dependencies", "updateType", "major"); renderTab({ pullRequests: [pin] }); expect(screen.getByText(pin.title)).toBeDefined(); @@ -373,12 +427,30 @@ describe("DependenciesTab — updateType filter", () => { describe("DependenciesTab — bot filter", () => { it("filters to specific bot when bot filter is set", () => { - const renovatePR = makeNeedsReviewPR({ userLogin: "renovate[bot]", title: "chore(deps): update lodash" }); - const dependabotPR = makeNeedsReviewPR({ userLogin: "dependabot[bot]", title: "Bump axios from 0.27 to 1.0.0" }); + const renovatePR = makeMergeablePR({ userLogin: "renovate[bot]", title: "chore(deps): update dependency lodash to v5" }); + const dependabotPR = makeMergeablePR({ userLogin: "dependabot[bot]", title: "Bump axios from 0.27.2 to 1.0.0" }); setTabFilter("dependencies", "bot", "renovate[bot]"); renderTab({ pullRequests: [renovatePR, dependabotPR] }); - expect(screen.getByText(renovatePR.title)).toBeDefined(); - expect(screen.queryByText(dependabotPR.title)).toBeNull(); + expect(screen.getByText("lodash")).toBeDefined(); + expect(screen.queryByText("axios")).toBeNull(); + }); +}); + +// ── Label filtering ────────────────────────────────────────────────────────── + +describe("DependenciesTab — label filtering", () => { + it("filters out dep-tool labels (dependencies, renovate)", () => { + const pr = makeMergeablePR({ + labels: [ + { name: "dependencies", color: "0075ca" }, + { name: "renovate", color: "1a7f37" }, + { name: "go", color: "00add8" }, + ], + }); + renderTab({ pullRequests: [pr] }); + expect(screen.queryByText("dependencies")).toBeNull(); + expect(screen.queryByText("renovate")).toBeNull(); + expect(screen.getByText("go")).toBeDefined(); }); }); @@ -386,21 +458,18 @@ describe("DependenciesTab — bot filter", () => { describe("DependenciesTab — ignore button", () => { it("clicking the ignore button hides the PR from the list", () => { - const pr = makeNeedsReviewPR({ title: "chore(deps): bump lodash to v5" }); + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5" }); renderTab({ pullRequests: [pr] }); - - // PR is visible before ignore - expect(screen.getByText(pr.title)).toBeDefined(); + expect(screen.getByText("lodash")).toBeDefined(); const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); fireEvent.click(ignoreBtn); - // PR should no longer be rendered - expect(screen.queryByText(pr.title)).toBeNull(); + expect(screen.queryByText("lodash")).toBeNull(); }); it("ignore button adds item to ignoredItems in viewState", () => { - const pr = makeNeedsReviewPR({ id: 5001, title: "chore(deps): update react to v19" }); + const pr = makeMergeablePR({ id: 5001, title: "chore(deps): update dependency react to v19" }); renderTab({ pullRequests: [pr] }); const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); @@ -410,15 +479,14 @@ describe("DependenciesTab — ignore button", () => { }); it("ignored PR is not rendered even when re-renderTab is called", () => { - const pr = makeNeedsReviewPR({ id: 5002, title: "Bump axios from 0.27 to 1.0.0 (ignored)" }); + const pr = makeMergeablePR({ id: 5002, title: "Bump axios from 0.27.2 to 1.0.0" }); const { unmount } = renderTab({ pullRequests: [pr] }); fireEvent.click(screen.getByRole("button", { name: /^Ignore #/ })); unmount(); - // Re-render with same PR data — ignored item should still be filtered out renderTab({ pullRequests: [pr] }); - expect(screen.queryByText(pr.title)).toBeNull(); + expect(screen.queryByText("axios")).toBeNull(); }); }); @@ -427,7 +495,7 @@ describe("DependenciesTab — ignore button", () => { describe("DependenciesTab — track button", () => { it("track button is not rendered when enableTracking is false", () => { updateConfig({ enableTracking: false }); - const pr = makeNeedsReviewPR({ title: "chore(deps): update lodash to v5" }); + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5" }); renderTab({ pullRequests: [pr] }); expect(screen.queryByRole("button", { name: /^Pin #/ })).toBeNull(); @@ -435,7 +503,7 @@ describe("DependenciesTab — track button", () => { it("track button renders when enableTracking is true", () => { updateConfig({ enableTracking: true }); - const pr = makeNeedsReviewPR({ title: "chore(deps): update lodash to v5" }); + const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5" }); renderTab({ pullRequests: [pr] }); expect(screen.getByRole("button", { name: /^Pin #/ })).toBeDefined(); @@ -443,7 +511,7 @@ describe("DependenciesTab — track button", () => { it("clicking track button adds the PR to trackedItems", () => { updateConfig({ enableTracking: true }); - const pr = makeNeedsReviewPR({ id: 6001, title: "Bump react from 17 to 18" }); + const pr = makeMergeablePR({ id: 6001, title: "Bump react from 17.0.0 to 18.0.0" }); renderTab({ pullRequests: [pr] }); fireEvent.click(screen.getByRole("button", { name: /^Pin #/ })); @@ -453,14 +521,12 @@ describe("DependenciesTab — track button", () => { it("clicking track button a second time removes the PR from trackedItems (toggle)", () => { updateConfig({ enableTracking: true }); - const pr = makeNeedsReviewPR({ id: 6002, title: "Bump typescript from 4 to 5" }); + const pr = makeMergeablePR({ id: 6002, title: "Bump typescript from 4.0.0 to 5.0.0" }); renderTab({ pullRequests: [pr] }); - // First click: track (aria-label is "Pin #…") fireEvent.click(screen.getByRole("button", { name: /^Pin #/ })); expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(true); - // Second click: untrack (aria-label switches to "Unpin #…" when tracked) fireEvent.click(screen.getByRole("button", { name: /^Unpin #/ })); expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(false); }); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 28eaa614..fc4d51d7 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -7,6 +7,8 @@ import { KNOWN_DEP_BOT_LOGINS, DEP_BRANCH_PREFIXES, DEP_TITLE_PATTERN, + DEP_TOOL_LABEL_NAMES, + ALL_DEP_STATUSES, } from "../../src/app/lib/dependency-detection.js"; import { makePullRequest } from "../helpers/factories.js"; @@ -95,17 +97,22 @@ describe("isDependencyPr", () => { describe("extractVersionInfo", () => { it("extracts major update from Dependabot bump title", () => { const result = extractVersionInfo("Bump lodash from 3.0.0 to 4.0.0"); - expect(result).toEqual({ from: "3.0.0", to: "4.0.0", updateType: "major" }); + expect(result).toEqual({ packageName: "lodash", from: "3.0.0", to: "4.0.0", updateType: "major" }); }); it("extracts minor update from Dependabot bump title", () => { const result = extractVersionInfo("Bump lodash from 4.0.0 to 4.1.0"); - expect(result).toEqual({ from: "4.0.0", to: "4.1.0", updateType: "minor" }); + expect(result).toEqual({ packageName: "lodash", from: "4.0.0", to: "4.1.0", updateType: "minor" }); }); it("extracts patch update from Dependabot bump title", () => { const result = extractVersionInfo("Bump lodash from 4.17.20 to 4.17.21"); - expect(result).toEqual({ from: "4.17.20", to: "4.17.21", updateType: "patch" }); + expect(result).toEqual({ packageName: "lodash", from: "4.17.20", to: "4.17.21", updateType: "patch" }); + }); + + it("extracts package name from chore(deps) bump title", () => { + const result = extractVersionInfo("chore(deps): bump webpack from 5.90.0 to 5.90.1"); + expect(result).toEqual({ packageName: "webpack", from: "5.90.0", to: "5.90.1", updateType: "patch" }); }); it("returns major for Renovate 'update all major dependencies'", () => { @@ -123,16 +130,19 @@ describe("extractVersionInfo", () => { expect(result).toEqual({ updateType: "minor" }); }); - it("extracts to-version for Renovate single-dep title", () => { + it("extracts package name and to-version for Renovate single-dep title", () => { const result = extractVersionInfo("chore(deps): update dependency pytest to v9"); - expect(result).toMatchObject({ to: "v9" }); - expect(result?.updateType).toBeUndefined(); + expect(result).toEqual({ packageName: "pytest", to: "v9" }); }); - it("extracts to-version for Renovate action title", () => { + it("extracts package name and to-version for Renovate action title", () => { const result = extractVersionInfo("chore(deps): update astral-sh/setup-uv action to v8"); - expect(result).toMatchObject({ to: "v8" }); - expect(result?.updateType).toBeUndefined(); + expect(result).toEqual({ packageName: "astral-sh/setup-uv", to: "v8" }); + }); + + it("extracts to-version for plain Renovate dependency title (no chore prefix)", () => { + const result = extractVersionInfo("Update dependency @types/node to v20.11.5"); + expect(result).toEqual({ packageName: "@types/node", to: "v20.11.5" }); }); it("returns null for 'pin dependencies'", () => { @@ -149,11 +159,10 @@ describe("extractVersionInfo", () => { it("strips [security] suffix before parsing", () => { const result = extractVersionInfo("chore(deps): update dependency pytest to v9 [security]"); - expect(result).toMatchObject({ to: "v9" }); + expect(result).toEqual({ packageName: "pytest", to: "v9" }); }); it("does not throw for non-semver bump (date-based version)", () => { - // parseSemver of "22.04" → [22, 4, 0] which parses as valid; result is non-null but that's acceptable expect(() => extractVersionInfo("Bump ubuntu from 22.04 to 24.04")).not.toThrow(); }); @@ -163,7 +172,7 @@ describe("extractVersionInfo", () => { it("returns null updateType when from and to are identical versions", () => { const result = extractVersionInfo("Bump lodash from 4.17.21 to 4.17.21"); - expect(result).toEqual({ from: "4.17.21", to: "4.17.21", updateType: undefined }); + expect(result).toEqual({ packageName: "lodash", from: "4.17.21", to: "4.17.21", updateType: undefined }); }); }); @@ -195,10 +204,10 @@ describe("isRebasing", () => { }); describe("classifyDepStatus", () => { - const RECENT = new Date(Date.now() - 7 * 86_400_000).toISOString(); // 7 days ago - const OLD = new Date(Date.now() - 31 * 86_400_000).toISOString(); // 31 days ago + const RECENT = new Date(Date.now() - 7 * 86_400_000).toISOString(); + const OLD = new Date(Date.now() - 31 * 86_400_000).toISOString(); - it("returns needs-review for enriched, non-draft, passing CI, not approved", () => { + it("returns mergeable for enriched, non-draft, passing CI, not approved", () => { const pr = makePullRequest({ enriched: true, draft: false, @@ -206,12 +215,10 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("needs-review"); + expect(classifyDepStatus(pr, "", 14)).toBe("mergeable"); }); - it("returns needs-review even for old PR if CI passing and not approved", () => { - // needs-review wins over stale when actionable + it("returns mergeable even for old PR if CI passing and not approved", () => { const pr = makePullRequest({ enriched: true, draft: false, @@ -219,12 +226,43 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: OLD, }); - const result = classifyDepStatus(pr, 14); - // needs-review check runs first and wins - expect(result).toBe("needs-review"); + expect(classifyDepStatus(pr, "", 14)).toBe("mergeable"); }); - it("returns stale for old PR that is not needs-review eligible", () => { + it("returns pending-rebase when PR has rebase label", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: null, + labels: [{ name: "rebase", color: "ffffff" }], + updatedAt: RECENT, + }); + expect(classifyDepStatus(pr, "rebase", 14)).toBe("pending-rebase"); + }); + + it("pending-rebase takes priority over mergeable", () => { + const pr = makePullRequest({ + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: null, + labels: [{ name: "rebase", color: "ffffff" }], + updatedAt: RECENT, + }); + expect(classifyDepStatus(pr, "rebase")).toBe("pending-rebase"); + }); + + it("pending-rebase takes priority over stale", () => { + const pr = makePullRequest({ + draft: true, + labels: [{ name: "rebase", color: "ffffff" }], + updatedAt: OLD, + }); + expect(classifyDepStatus(pr, "rebase")).toBe("pending-rebase"); + }); + + it("returns stale for old PR that is not mergeable", () => { const pr = makePullRequest({ enriched: true, draft: false, @@ -232,8 +270,7 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: OLD, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("stale"); + expect(classifyDepStatus(pr, "", 14)).toBe("stale"); }); it("returns stale for old draft PR", () => { @@ -241,20 +278,18 @@ describe("classifyDepStatus", () => { draft: true, updatedAt: OLD, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("stale"); + expect(classifyDepStatus(pr, "", 14)).toBe("stale"); }); - it("returns waiting for recent draft PR", () => { + it("returns needs-action for recent draft PR", () => { const pr = makePullRequest({ draft: true, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("waiting"); + expect(classifyDepStatus(pr, "", 14)).toBe("needs-action"); }); - it("returns waiting for recent PR with pending CI", () => { + it("returns needs-action for recent PR with pending CI", () => { const pr = makePullRequest({ enriched: true, draft: false, @@ -262,21 +297,19 @@ describe("classifyDepStatus", () => { reviewDecision: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("waiting"); + expect(classifyDepStatus(pr, "", 14)).toBe("needs-action"); }); - it("returns waiting for unenriched PR (enriched=false, checkStatus is null)", () => { + it("returns needs-action for unenriched PR (enriched=false, checkStatus is null)", () => { const pr = makePullRequest({ enriched: false, checkStatus: null, updatedAt: RECENT, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("waiting"); + expect(classifyDepStatus(pr, "", 14)).toBe("needs-action"); }); - it("returns waiting for approved PR (already handled, not needs-review)", () => { + it("returns needs-action for approved PR (already handled, not mergeable)", () => { const pr = makePullRequest({ enriched: true, draft: false, @@ -284,8 +317,7 @@ describe("classifyDepStatus", () => { reviewDecision: "APPROVED", updatedAt: RECENT, }); - const result = classifyDepStatus(pr, 14); - expect(result).toBe("waiting"); + expect(classifyDepStatus(pr, "", 14)).toBe("needs-action"); }); it("uses default stale threshold of 14 days when not provided", () => { @@ -295,9 +327,38 @@ describe("classifyDepStatus", () => { const recent = makePullRequest({ draft: true, updatedAt: thirteenDaysAgo }); const old = makePullRequest({ draft: true, updatedAt: fifteenDaysAgo }); - expect(classifyDepStatus(recent)).toBe("waiting"); + expect(classifyDepStatus(recent)).toBe("needs-action"); expect(classifyDepStatus(old)).toBe("stale"); }); + + it("skips rebase check when rebaseLabel is empty", () => { + const pr = makePullRequest({ + labels: [{ name: "rebase", color: "ffffff" }], + enriched: true, + draft: false, + checkStatus: "success", + reviewDecision: null, + updatedAt: RECENT, + }); + expect(classifyDepStatus(pr)).toBe("mergeable"); + }); +}); + +describe("ALL_DEP_STATUSES", () => { + it("contains all status values in render order", () => { + expect(ALL_DEP_STATUSES).toEqual(["mergeable", "pending-rebase", "needs-action", "stale"]); + }); +}); + +describe("DEP_TOOL_LABEL_NAMES", () => { + it("contains known dep tool label names", () => { + expect(DEP_TOOL_LABEL_NAMES.has("dependencies")).toBe(true); + expect(DEP_TOOL_LABEL_NAMES.has("renovate")).toBe(true); + }); + + it("does not contain non-dep labels", () => { + expect(DEP_TOOL_LABEL_NAMES.has("bug")).toBe(false); + }); }); describe("KNOWN_DEP_BOT_LOGINS", () => { From 33d12f81bb536fec218708310bc835ab542e7212 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:22:15 -0400 Subject: [PATCH 21/48] fix(deps): fixes unknown bot detection bugs - Excludes authenticated user from unknown bot flagging - Handles dependabot vs dependabot[bot] via base-name matching - Replaces pushNotification with inline banner + Track/Dismiss buttons - Passes userLogin prop from DashboardPage to DependenciesTab --- .../components/dashboard/DashboardPage.tsx | 1 + .../components/dashboard/DependenciesTab.tsx | 90 +++++++++++++------ src/app/lib/dependency-detection.ts | 9 ++ .../dashboard/DependenciesTab.test.tsx | 58 +++++++++++- tests/lib/dependency-detection.test.ts | 28 ++++++ 5 files changed, 158 insertions(+), 28 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index e5ec473f..1a05107d 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1255,6 +1255,7 @@ export default function DashboardPage() { hotPollingPRIds={hotPollingPRIds()} refreshTick={refreshTick()} rebaseLabel={config.dependencies.rebaseLabel} + userLogin={userLogin()} /> diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index f41d0fc6..9581f935 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -1,15 +1,14 @@ -import { createEffect, createMemo, For, on, Show } from "solid-js"; -import { config } from "../../stores/config"; +import { createMemo, createSignal, For, Show } from "solid-js"; +import { config, updateConfig } from "../../stores/config"; import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem, DependencyFiltersSchema, setDependencyExpandedGroups } from "../../stores/view"; import { isSafeGitHubUrl } from "../../lib/url"; -import { pushNotification } from "../../lib/errors"; import type { PullRequest } from "../../services/api"; import type { AbandonedDependency } from "../../lib/dependency-dashboard"; import { classifyDepStatus, extractVersionInfo, ALL_DEP_STATUSES, - KNOWN_DEP_BOT_LOGINS, + isKnownDepBot, DEP_TOOL_LABEL_NAMES, type DepStatus, type VersionInfo, @@ -59,10 +58,9 @@ interface DependenciesTabProps { hotPollingPRIds?: ReadonlySet; refreshTick?: number; rebaseLabel: string; + userLogin: string; } -const _notifiedBots = new Set(); - export default function DependenciesTab(props: DependenciesTabProps) { const expandedGroups = createMemo(() => new Set(viewState.dependencyExpandedGroups) @@ -179,27 +177,38 @@ export default function DependenciesTab(props: DependenciesTabProps) { return groups; }); - // Unknown bot detection — one-time notification per session - createEffect( - on( - () => props.pullRequests, - (prs) => { - const known = trackedBotLogins(); - for (const pr of prs) { - const login = pr.userLogin.toLowerCase(); - if (KNOWN_DEP_BOT_LOGINS.has(login)) continue; - if (known.has(login)) continue; - if (_notifiedBots.has(login)) continue; - _notifiedBots.add(login); - pushNotification( - `unknown-dep-bot:${login}`, - `Found dependency PRs from "${pr.userLogin}". Track this bot in Settings → Tracked Users for full coverage.`, - "info" - ); - } - } - ) - ); + // Unknown bot detection — inline banner with "Track" action + const [dismissedBots, setDismissedBots] = createSignal(new Set()); + + const unknownBots = createMemo(() => { + const known = trackedBotLogins(); + 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 existing = config.trackedUsers.map((u) => u.login.toLowerCase()); + if (existing.includes(login.toLowerCase())) return; + updateConfig({ + trackedUsers: [...config.trackedUsers, { login, avatarUrl, name: null, type: "bot" as const }], + }); + } + + 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() }); @@ -231,6 +240,33 @@ export default function DependenciesTab(props: DependenciesTabProps) { />
+ + {(bot) => ( +
+ + {bot.login} + + + Dependency PRs from {bot.login} — track this bot for full coverage? + + + +
+ )} +
+ diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index d4de84b7..c7e53110 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -25,6 +25,15 @@ export const DEP_TOOL_LABEL_NAMES = new Set([ "renovate", ]); +const KNOWN_DEP_BOT_BASE_NAMES = new Set( + [...KNOWN_DEP_BOT_LOGINS].map((l) => l.replace(/\[bot\]$/, "")) +); + +export function isKnownDepBot(login: string): boolean { + const lower = login.toLowerCase(); + return KNOWN_DEP_BOT_LOGINS.has(lower) || KNOWN_DEP_BOT_BASE_NAMES.has(lower.replace(/\[bot\]$/, "")); +} + export type DepStatus = "mergeable" | "needs-action" | "stale" | "pending-rebase"; export const ALL_DEP_STATUSES: readonly DepStatus[] = [ diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 97434bce..f766a328 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -30,7 +30,7 @@ vi.mock("../../../src/app/lib/url", () => ({ import { render } from "@solidjs/testing-library"; import DependenciesTab from "../../../src/app/components/dashboard/DependenciesTab.js"; -import { resetConfig, updateConfig } from "../../../src/app/stores/config.js"; +import { config, resetConfig, updateConfig } from "../../../src/app/stores/config.js"; import { setTabFilter, resetViewState, viewState, setDependencyExpandedGroups } from "../../../src/app/stores/view.js"; import { makePullRequest } from "../../helpers/factories.js"; import type { AbandonedDependency } from "../../../src/app/lib/dependency-dashboard.js"; @@ -44,6 +44,7 @@ const EMPTY_MAPS = { const BASE_PROPS = { rebaseLabel: "rebase", + userLogin: "testuser", ...EMPTY_MAPS, }; @@ -531,3 +532,58 @@ describe("DependenciesTab — track button", () => { expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(false); }); }); + +// ── Unknown bot detection ──────────────────────────────────────────────────── + +describe("DependenciesTab — unknown bot banner", () => { + it("shows banner for unknown bot authors", () => { + const pr = makeMergeablePR({ + userLogin: "custom-dep-bot", + userAvatarUrl: "https://avatars.githubusercontent.com/u/12345", + }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByRole("button", { name: "Track bot" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); + }); + + it("does not show banner for known dep bots", () => { + const pr = makeMergeablePR({ userLogin: "renovate[bot]" }); + renderTab({ pullRequests: [pr] }); + expect(screen.queryByRole("button", { name: "Track bot" })).toBeNull(); + }); + + it("does not show banner for known bots without [bot] suffix", () => { + const pr = makeMergeablePR({ userLogin: "dependabot" }); + renderTab({ pullRequests: [pr] }); + expect(screen.queryByRole("button", { name: "Track bot" })).toBeNull(); + }); + + it("does not show banner for the authenticated user", () => { + const pr = makeMergeablePR({ userLogin: "testuser" }); + renderTab({ pullRequests: [pr], userLogin: "testuser" }); + expect(screen.queryByRole("button", { name: "Track bot" })).toBeNull(); + }); + + it("dismiss button hides the banner for the session", () => { + const pr = makeMergeablePR({ + userLogin: "custom-dep-bot", + userAvatarUrl: "https://avatars.githubusercontent.com/u/12345", + }); + renderTab({ pullRequests: [pr] }); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("button", { name: "Track bot" })).toBeNull(); + }); + + it("track button adds bot to config.trackedUsers", () => { + const pr = makeMergeablePR({ + userLogin: "custom-dep-bot", + userAvatarUrl: "https://avatars.githubusercontent.com/u/12345", + }); + renderTab({ pullRequests: [pr] }); + fireEvent.click(screen.getByRole("button", { name: "Track bot" })); + + expect(config.trackedUsers.some((u) => u.login === "custom-dep-bot" && u.type === "bot")).toBe(true); + }); +}); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index fc4d51d7..5e016d2d 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -4,6 +4,7 @@ import { extractVersionInfo, classifyDepStatus, isRebasing, + isKnownDepBot, KNOWN_DEP_BOT_LOGINS, DEP_BRANCH_PREFIXES, DEP_TITLE_PATTERN, @@ -361,6 +362,33 @@ describe("DEP_TOOL_LABEL_NAMES", () => { }); }); +describe("isKnownDepBot", () => { + it("returns true for exact match with [bot] suffix", () => { + expect(isKnownDepBot("dependabot[bot]")).toBe(true); + expect(isKnownDepBot("renovate[bot]")).toBe(true); + }); + + it("returns true for base name without [bot] suffix", () => { + expect(isKnownDepBot("dependabot")).toBe(true); + expect(isKnownDepBot("renovate")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isKnownDepBot("Dependabot[bot]")).toBe(true); + expect(isKnownDepBot("RENOVATE")).toBe(true); + }); + + it("returns true for bots without [bot] in known list", () => { + expect(isKnownDepBot("snyk-bot")).toBe(true); + expect(isKnownDepBot("scala-steward")).toBe(true); + }); + + it("returns false for unknown logins", () => { + expect(isKnownDepBot("octocat")).toBe(false); + expect(isKnownDepBot("my-custom-bot")).toBe(false); + }); +}); + describe("KNOWN_DEP_BOT_LOGINS", () => { it("contains expected bot logins", () => { expect(KNOWN_DEP_BOT_LOGINS.has("dependabot[bot]")).toBe(true); From ec26049a9019f15b6b621d822127be40ef9fe393 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:29:18 -0400 Subject: [PATCH 22/48] fix(deps): fixes header styling, title parsing, and sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds repo-header-text class to status group headers for consistent color - Moves version info into title string (package: from → to) for visibility - Adds Python requirement pattern (>=A to >=B) to extractVersionInfo - Default sort clusters by repo first, then updatedAt within each repo --- .../components/dashboard/DependenciesTab.tsx | 24 ++++++------ src/app/lib/dependency-detection.ts | 8 ++++ tests/components/DashboardPage.test.tsx | 4 +- .../dashboard/DependenciesTab.test.tsx | 39 +++++++++++-------- tests/lib/dependency-detection.test.ts | 10 +++++ 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 9581f935..2b334bb5 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -140,8 +140,16 @@ export default function DependenciesTab(props: DependenciesTabProps) { const { field, direction } = viewState.globalSort; const items = [...classifiedPRs()]; const dir = direction === "asc" ? 1 : -1; + const isDefault = field === "updatedAt" && direction === "desc"; items.sort((a, b) => { + // Default sort: cluster by repo, then newest first within each repo + if (isDefault) { + const repoCmp = a.pr.repoFullName.localeCompare(b.pr.repoFullName); + if (repoCmp !== 0) return repoCmp; + return b.pr.updatedAt.localeCompare(a.pr.updatedAt); + } + let cmp = 0; switch (field) { case "repo": cmp = a.pr.repoFullName.localeCompare(b.pr.repoFullName); break; @@ -330,6 +338,8 @@ interface StatusGroupProps { function displayTitle(pr: PullRequest, versionInfo: VersionInfo | null): string { if (!versionInfo?.packageName) return pr.title; + if (versionInfo.from && versionInfo.to) return `${versionInfo.packageName}: ${versionInfo.from} → ${versionInfo.to}`; + if (versionInfo.to) return `${versionInfo.packageName} → ${versionInfo.to}`; return versionInfo.packageName; } @@ -344,7 +354,7 @@ function StatusGroup(props: StatusGroupProps) {
-
+
From 4601888664ca24efa521da01b4ef1ef8d91e0ef1 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:39:29 -0400 Subject: [PATCH 24/48] fix(deps): matches filter bar layout, strips commit prefixes from titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Matches PullRequestsTab filter bar: gap-3 compact:gap-2, shrink-0 wrapper - Strips chore(deps)/fix(deps) prefix from all displayed titles - Lock file maintenance → Lock file maintenance - Pin dependencies → Pin dependencies - Update all major dependencies → Update all major dependencies --- .../components/dashboard/DependenciesTab.tsx | 27 ++++++++++++------- .../dashboard/DependenciesTab.test.tsx | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 1d1d9fae..3c901db6 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -234,7 +234,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { return (
-
+
resetAllTabFilters("dependencies")} />
- +
+ +
@@ -336,11 +338,18 @@ interface StatusGroupProps { onTrack: (pr: PullRequest) => void; } +function stripCommitPrefix(title: string): string { + const stripped = title.replace(/^(?:chore|fix|build)\(deps[^)]*\):\s*/i, ""); + return stripped.charAt(0).toUpperCase() + stripped.slice(1); +} + function displayTitle(pr: PullRequest, versionInfo: VersionInfo | null): string { - if (!versionInfo?.packageName) return pr.title; - if (versionInfo.from && versionInfo.to) return `${versionInfo.packageName}: ${versionInfo.from} → ${versionInfo.to}`; - if (versionInfo.to) return `${versionInfo.packageName} → ${versionInfo.to}`; - return versionInfo.packageName; + 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 }[] { diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 76010b4c..b27ca534 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -427,7 +427,7 @@ describe("DependenciesTab — updateType filter", () => { const pin = makeMergeablePR({ title: "chore(deps): pin dependencies" }); setTabFilter("dependencies", "updateType", "major"); renderTab({ pullRequests: [pin] }); - expect(screen.getByText(pin.title)).toBeDefined(); + expect(screen.getByText("Pin dependencies")).toBeDefined(); }); }); From 7d86d628a05a3d3b19c0a5a7209c9d43699fcbc1 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:49:28 -0400 Subject: [PATCH 25/48] fix(deps): matches tracked bots with and without [bot] suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expandBotLogins() generates both variants — tracking 'khepri-bot' now matches PRs from 'khepri-bot[bot]' and vice versa. Applied in both DashboardPage (isDependencyPr) and DependenciesTab (unknown bot banner). --- .../components/dashboard/DashboardPage.tsx | 4 ++-- .../components/dashboard/DependenciesTab.tsx | 3 ++- src/app/lib/dependency-detection.ts | 11 +++++++++ tests/lib/dependency-detection.test.ts | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 1a05107d..72862bb7 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -11,7 +11,7 @@ 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 } from "../../lib/dependency-detection"; +import { isDependencyPr, expandBotLogins } from "../../lib/dependency-detection"; import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; import { fetchDashboardIssueBodies } from "../../services/api"; import type { SortOption } from "../shared/SortDropdown"; @@ -820,7 +820,7 @@ export default function DashboardPage() { // Dep PR detection — placed above exclusiveOwnership so pre-exclusivity claims run first const trackedBotLogins = createMemo(() => - new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) + expandBotLogins(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) ); const dependencyPullRequests = createMemo(() => { if (!config.dependencies.enabled) return []; diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 3c901db6..eb43e49e 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -9,6 +9,7 @@ import { extractVersionInfo, ALL_DEP_STATUSES, isKnownDepBot, + expandBotLogins, DEP_TOOL_LABEL_NAMES, type DepStatus, type VersionInfo, @@ -109,7 +110,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { ); const trackedBotLogins = createMemo(() => - new Set(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) + expandBotLogins(config.trackedUsers.filter((u) => u.type === "bot").map((u) => u.login.toLowerCase())) ); const classifiedPRs = createMemo(() => { diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index 131048ae..1b64b18f 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -34,6 +34,17 @@ export function isKnownDepBot(login: string): boolean { return KNOWN_DEP_BOT_LOGINS.has(lower) || KNOWN_DEP_BOT_BASE_NAMES.has(lower.replace(/\[bot\]$/, "")); } +export function expandBotLogins(logins: string[]): Set { + const set = new Set(); + for (const login of logins) { + set.add(login); + const base = login.replace(/\[bot\]$/, ""); + set.add(base); + if (base === login) set.add(`${login}[bot]`); + } + return set; +} + export type DepStatus = "mergeable" | "needs-action" | "stale" | "pending-rebase"; export const ALL_DEP_STATUSES: readonly DepStatus[] = [ diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 4539c658..496c7d40 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -5,6 +5,7 @@ import { classifyDepStatus, isRebasing, isKnownDepBot, + expandBotLogins, KNOWN_DEP_BOT_LOGINS, DEP_BRANCH_PREFIXES, DEP_TITLE_PATTERN, @@ -399,6 +400,28 @@ describe("isKnownDepBot", () => { }); }); +describe("expandBotLogins", () => { + it("includes both base and [bot] variant for plain login", () => { + const set = expandBotLogins(["khepri-bot"]); + expect(set.has("khepri-bot")).toBe(true); + expect(set.has("khepri-bot[bot]")).toBe(true); + }); + + it("includes both base and [bot] variant for [bot] login", () => { + const set = expandBotLogins(["renovate[bot]"]); + expect(set.has("renovate[bot]")).toBe(true); + expect(set.has("renovate")).toBe(true); + }); + + it("handles mixed logins", () => { + const set = expandBotLogins(["my-bot", "other[bot]"]); + expect(set.has("my-bot")).toBe(true); + expect(set.has("my-bot[bot]")).toBe(true); + expect(set.has("other[bot]")).toBe(true); + expect(set.has("other")).toBe(true); + }); +}); + describe("KNOWN_DEP_BOT_LOGINS", () => { it("contains expected bot logins", () => { expect(KNOWN_DEP_BOT_LOGINS.has("dependabot[bot]")).toBe(true); From 39cdd82be42ae33640c0f2e58db52f6e75e98206 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 18:03:05 -0400 Subject: [PATCH 26/48] fix(deps): normalizes bot logins on write, expands on read - handleTrackBot strips [bot] suffix before storing - validateGitHubUser strips [bot] from input before API call - fetchIssuesAndPullRequests runs both base and [bot] variant searches for tracked bots (mergeTrackedUserResults deduplicates) - expandBotLogins still covers local matching in both memos --- .../components/dashboard/DependenciesTab.tsx | 5 +-- src/app/services/api.ts | 24 +++++++++---- tests/services/api.test.ts | 35 +++++++++++-------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index eb43e49e..c016e9f7 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -208,10 +208,11 @@ export default function DependenciesTab(props: DependenciesTabProps) { }); function handleTrackBot(login: string, avatarUrl: string) { + const normalized = login.replace(/\[bot\]$/i, ""); const existing = config.trackedUsers.map((u) => u.login.toLowerCase()); - if (existing.includes(login.toLowerCase())) return; + if (existing.includes(normalized.toLowerCase())) return; updateConfig({ - trackedUsers: [...config.trackedUsers, { login, avatarUrl, name: null, type: "bot" as const }], + trackedUsers: [...config.trackedUsers, { login: normalized, avatarUrl, name: null, type: "bot" as const }], }); } diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 78ced310..b3ce6779 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1354,8 +1354,19 @@ export async function fetchIssuesAndPullRequests( // Tracked user searches — scoped to normalRepos only. Monitored repos are already // covered by graphqlUnfilteredSearch (all open items, no user qualifier), so running // involves: on them would duplicate work and add spurious surfacedBy annotations. - const trackedSearchPromise = hasTrackedUsers && normalRepos.length > 0 - ? Promise.allSettled(trackedUsers!.map((u) => graphqlLightCombinedSearch(octokit, normalRepos, u.login, "globalUserSearch"))) + // Bot users get both base and [bot] variant searches for coverage. + const trackedSearchTasks: { login: string; promise: Promise }[] = []; + if (hasTrackedUsers && normalRepos.length > 0) { + for (const u of trackedUsers!) { + trackedSearchTasks.push({ login: u.login, promise: graphqlLightCombinedSearch(octokit, normalRepos, u.login, "globalUserSearch") }); + if (u.type === "bot") { + const botVariant = `${u.login}[bot]`; + trackedSearchTasks.push({ login: u.login, promise: graphqlLightCombinedSearch(octokit, normalRepos, botVariant, "globalUserSearch") }); + } + } + } + const trackedSearchPromise = trackedSearchTasks.length > 0 + ? Promise.allSettled(trackedSearchTasks.map((t) => t.promise)) : Promise.resolve([] as PromiseSettledResult[]); // Unfiltered search for monitored repos — runs in parallel with tracked searches @@ -1394,11 +1405,11 @@ export async function fetchIssuesAndPullRequests( // Merge tracked user results and collect new (delta) node IDs for both // monitored repo PRs (added above) and tracked user PRs (added below). - if (hasTrackedUsers) { + if (trackedSearchTasks.length > 0) { const settled = trackedResults as PromiseSettledResult[]; for (let i = 0; i < settled.length; i++) { const result = settled[i]; - const trackedLogin = trackedUsers![i].login; + const trackedLogin = trackedSearchTasks[i].login; if (result.status === "fulfilled") { allErrors.push(...result.value.errors); mergeTrackedUserResults(issueMap, prMap, nodeIdMap, result.value, trackedLogin); @@ -1817,11 +1828,12 @@ export async function validateGitHubUser( octokit: GitHubOctokit, login: string ): Promise { - if (!VALID_TRACKED_LOGIN.test(login)) return null; + const normalized = login.replace(/\[bot\]$/i, ""); + if (!VALID_TRACKED_LOGIN.test(normalized)) return null; let response: { data: RawGitHubUser; headers: Record }; try { - response = await octokit.request("GET /users/{username}", { username: login }) as { data: RawGitHubUser; headers: Record }; + response = await octokit.request("GET /users/{username}", { username: normalized }) as { data: RawGitHubUser; headers: Record }; } catch (err) { const status = typeof err === "object" && err !== null && "status" in err diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index b83f00a5..92431860 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -598,22 +598,29 @@ describe("validateGitHubUser — VALID_TRACKED_LOGIN and type detection", () => expect(result).toBeNull(); }); - it("returns null for [Bot] (case-sensitive — only [bot] accepted)", async () => { - const octokit = makeOctokit(async () => ({ data: {} })); - const result = await validateGitHubUser( - octokit as never, - "user[Bot]" - ); - expect(result).toBeNull(); + it("normalizes [Bot] (case-insensitive) and validates as base login", async () => { + const octokit = makeUserOctokit({ + login: "user", + avatar_url: "https://avatars.githubusercontent.com/u/1", + name: null, + type: "Bot", + }); + const result = await validateGitHubUser(octokit as never, "user[Bot]"); + expect(result).not.toBeNull(); + expect(result?.login).toBe("user"); + expect(result?.type).toBe("bot"); }); - it("returns null for user[bot][bot] (double suffix)", async () => { - const octokit = makeOctokit(async () => ({ data: {} })); - const result = await validateGitHubUser( - octokit as never, - "user[bot][bot]" - ); - expect(result).toBeNull(); + it("normalizes user[bot][bot] by stripping trailing [bot]", async () => { + const octokit = makeUserOctokit({ + login: "user[bot]", + avatar_url: "https://avatars.githubusercontent.com/u/1", + name: null, + type: "Bot", + }); + const result = await validateGitHubUser(octokit as never, "user[bot][bot]"); + expect(result).not.toBeNull(); + expect(result?.login).toBe("user[bot]"); }); it("returns null on 404 for bot login", async () => { From 18667d8c3eab368bfafbb9514023c964a394a368 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 18:07:43 -0400 Subject: [PATCH 27/48] feat(deps): triggers refresh after tracking a bot onRefresh callback fires manualRefresh via the poll coordinator so newly tracked bot PRs appear immediately. --- src/app/components/dashboard/DashboardPage.tsx | 1 + src/app/components/dashboard/DependenciesTab.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 72862bb7..5439580d 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1256,6 +1256,7 @@ export default function DashboardPage() { refreshTick={refreshTick()} rebaseLabel={config.dependencies.rebaseLabel} userLogin={userLogin()} + onRefresh={() => _coordinator()?.manualRefresh()} /> diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index c016e9f7..16277a28 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -60,6 +60,7 @@ interface DependenciesTabProps { refreshTick?: number; rebaseLabel: string; userLogin: string; + onRefresh?: () => void; } export default function DependenciesTab(props: DependenciesTabProps) { @@ -214,6 +215,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { updateConfig({ trackedUsers: [...config.trackedUsers, { login: normalized, avatarUrl, name: null, type: "bot" as const }], }); + props.onRefresh?.(); } function handleDismissBot(login: string) { From 1afec24b3eb8823f0d3ce440c5f7682aeab0f39c Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 18:17:05 -0400 Subject: [PATCH 28/48] fix(deps): adds [bot] fallback in validateGitHubUser Tries base login first, then auto-appends [bot] on 404. Entering 'khepri-bot' in Settings now finds the GitHub App bot automatically. Response login normalized (strips [bot]) for consistent storage. --- src/app/services/api.ts | 31 ++++++++++++------- tests/services/api-users.test.ts | 2 +- tests/services/api.test.ts | 51 ++++++++++++++++++++++++++------ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/app/services/api.ts b/src/app/services/api.ts index b3ce6779..f9d748a4 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1824,16 +1824,13 @@ interface RawGitHubUser { * Returns null if the login is invalid or the user does not exist (404). * Throws on network or server errors. */ -export async function validateGitHubUser( +async function tryGetUser( octokit: GitHubOctokit, - login: string -): Promise { - const normalized = login.replace(/\[bot\]$/i, ""); - if (!VALID_TRACKED_LOGIN.test(normalized)) return null; - - let response: { data: RawGitHubUser; headers: Record }; + username: string +): Promise { try { - response = await octokit.request("GET /users/{username}", { username: normalized }) as { data: RawGitHubUser; headers: Record }; + const response = await octokit.request("GET /users/{username}", { username }) as { data: RawGitHubUser }; + return response.data; } catch (err) { const status = typeof err === "object" && err !== null && "status" in err @@ -1842,13 +1839,27 @@ export async function validateGitHubUser( if (status === 404) return null; throw err; } - const raw = response.data; +} + +export async function validateGitHubUser( + octokit: GitHubOctokit, + login: string +): Promise { + const base = login.replace(/\[bot\]$/i, ""); + if (!VALID_TRACKED_LOGIN.test(base)) return null; + + let raw = await tryGetUser(octokit, base); + if (!raw) { + raw = await tryGetUser(octokit, `${base}[bot]`); + } + if (!raw) return null; + const avatarUrl = raw.avatar_url.startsWith(AVATAR_CDN_PREFIX) ? raw.avatar_url : AVATAR_FALLBACK; return { - login: raw.login.toLowerCase(), + login: raw.login.toLowerCase().replace(/\[bot\]$/, ""), avatarUrl, name: raw.name ?? null, type: raw.type === "Bot" ? "bot" : "user", diff --git a/tests/services/api-users.test.ts b/tests/services/api-users.test.ts index 98465afa..9f2edd8f 100644 --- a/tests/services/api-users.test.ts +++ b/tests/services/api-users.test.ts @@ -119,7 +119,7 @@ describe("validateGitHubUser", () => { }); const result = await validateGitHubUser(octokit as never, "nonexistent-user"); expect(result).toBeNull(); - expect(octokit.request).toHaveBeenCalledOnce(); + expect(octokit.request).toHaveBeenCalledTimes(2); }); it("returns null for invalid login without making API call", async () => { diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index 92431860..904eee72 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -548,10 +548,10 @@ describe("validateGitHubUser — VALID_TRACKED_LOGIN and type detection", () => ); expect(result).not.toBeNull(); expect(result?.type).toBe("bot"); - expect(result?.login).toBe("dependabot[bot]"); + expect(result?.login).toBe("dependabot"); }); - it("accepts another bot login — khepri-bot[bot]", async () => { + it("accepts another bot login — khepri-bot[bot] normalized to khepri-bot", async () => { const octokit = makeUserOctokit({ login: "khepri-bot[bot]", avatar_url: "https://avatars.githubusercontent.com/u/999", @@ -564,6 +564,7 @@ describe("validateGitHubUser — VALID_TRACKED_LOGIN and type detection", () => ); expect(result).not.toBeNull(); expect(result?.type).toBe("bot"); + expect(result?.login).toBe("khepri-bot"); }); it("returns type:user when API returns type:User", async () => { @@ -598,29 +599,61 @@ describe("validateGitHubUser — VALID_TRACKED_LOGIN and type detection", () => expect(result).toBeNull(); }); - it("normalizes [Bot] (case-insensitive) and validates as base login", async () => { + it("strips [Bot] case-insensitively and tries base login first", async () => { const octokit = makeUserOctokit({ login: "user", avatar_url: "https://avatars.githubusercontent.com/u/1", name: null, - type: "Bot", + type: "User", }); const result = await validateGitHubUser(octokit as never, "user[Bot]"); expect(result).not.toBeNull(); expect(result?.login).toBe("user"); - expect(result?.type).toBe("bot"); }); - it("normalizes user[bot][bot] by stripping trailing [bot]", async () => { + it("returns null for user[bot][bot] (base 'user[bot]' validated but both API calls 404)", async () => { + const octokit = makeOctokit(async () => { + throw Object.assign(new Error("Not Found"), { status: 404 }); + }); + const result = await validateGitHubUser(octokit as never, "user[bot][bot]"); + expect(result).toBeNull(); + }); + + it("strips [bot] from API response login for storage normalization", async () => { const octokit = makeUserOctokit({ - login: "user[bot]", + login: "khepri-bot[bot]", avatar_url: "https://avatars.githubusercontent.com/u/1", name: null, type: "Bot", }); - const result = await validateGitHubUser(octokit as never, "user[bot][bot]"); + const result = await validateGitHubUser(octokit as never, "khepri-bot[bot]"); expect(result).not.toBeNull(); - expect(result?.login).toBe("user[bot]"); + expect(result?.login).toBe("khepri-bot"); + expect(result?.type).toBe("bot"); + }); + + it("falls back to [bot] suffix when base login returns 404", async () => { + let callCount = 0; + const octokit = makeOctokit(async (_url: string, opts: unknown) => { + const { username } = opts as { username: string }; + callCount++; + if (!username.endsWith("[bot]")) { + throw Object.assign(new Error("Not Found"), { status: 404 }); + } + return { + data: { + login: "khepri-bot[bot]", + avatar_url: "https://avatars.githubusercontent.com/u/99", + name: null, + type: "Bot", + }, + }; + }); + const result = await validateGitHubUser(octokit as never, "khepri-bot"); + expect(callCount).toBe(2); + expect(result).not.toBeNull(); + expect(result?.login).toBe("khepri-bot"); + expect(result?.type).toBe("bot"); }); it("returns null on 404 for bot login", async () => { From c3d89fdc9c8a978c5908ef3db99dee943dcc138e Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 18:29:50 -0400 Subject: [PATCH 29/48] fix(deps): assigns category to every PR, fixes filter passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR gets a DepCategory: major | minor | patch | maintenance | other. Filter is now exact match — no more unconditional passthrough for undetected types. Maintenance (pin, lock file) and Other (unknown bump magnitude) are filterable options. --- .../components/dashboard/DependenciesTab.tsx | 17 +++++++++++++---- src/app/stores/view.ts | 2 +- .../dashboard/DependenciesTab.test.tsx | 9 ++++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 16277a28..8c9d091b 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -34,6 +34,8 @@ const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { { value: "major", label: "Major" }, { value: "minor", label: "Minor" }, { value: "patch", label: "Patch" }, + { value: "maintenance", label: "Maintenance" }, + { value: "other", label: "Other" }, ], }; @@ -44,10 +46,18 @@ const STATUS_META: Record { + .filter(({ pr, category }) => { if (ignored.has(pr.id)) return false; if (filters.bot !== "all" && pr.userLogin !== filters.bot) return false; - if (filters.updateType !== "all") { - if (versionInfo !== null && versionInfo.updateType !== undefined && versionInfo.updateType !== filters.updateType) return false; - } + if (filters.updateType !== "all" && category !== filters.updateType) return false; return true; }); }); diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index dea3e8a9..8801b31e 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -47,7 +47,7 @@ export const ActionsFiltersSchema = z.object({ }); export const DependencyFiltersSchema = z.object({ - updateType: z.enum(["all", "major", "minor", "patch"]).default("all"), + updateType: z.enum(["all", "major", "minor", "patch", "maintenance", "other"]).default("all"), bot: z.string().default("all"), }); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index b27ca534..8a07fdfa 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -423,10 +423,17 @@ describe("DependenciesTab — updateType filter", () => { expect(screen.queryByText("axios: 0.27.1 → 0.27.2")).toBeNull(); }); - it("maintenance PRs pass through all updateType filters (unknown version type)", () => { + it("maintenance PRs are hidden when a specific version type is selected", () => { const pin = makeMergeablePR({ title: "chore(deps): pin dependencies" }); setTabFilter("dependencies", "updateType", "major"); renderTab({ pullRequests: [pin] }); + expect(screen.queryByText("Pin dependencies")).toBeNull(); + }); + + it("maintenance PRs are shown when maintenance filter is selected", () => { + const pin = makeMergeablePR({ title: "chore(deps): pin dependencies" }); + setTabFilter("dependencies", "updateType", "maintenance"); + renderTab({ pullRequests: [pin] }); expect(screen.getByText("Pin dependencies")).toBeDefined(); }); }); From 3ffc9fceaf4001f92a6e8dbc2dbd745de8905f65 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 18:33:25 -0400 Subject: [PATCH 30/48] feat(deps): adds pin category and label fallback for update type - Pin dependencies is now its own filterable category - Lock file maintenance stays as 'maintenance' - When title parsing can't determine major/minor/patch, checks PR labels as fallback (Renovate/Dependabot may label PRs) - Body parsing for Renovate tables is a future enhancement --- .../components/dashboard/DependenciesTab.tsx | 34 ++++++++++++++++--- src/app/stores/view.ts | 2 +- .../dashboard/DependenciesTab.test.tsx | 21 ++++++++++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 8c9d091b..a030f025 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -34,6 +34,7 @@ const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = { { value: "major", label: "Major" }, { value: "minor", label: "Minor" }, { value: "patch", label: "Patch" }, + { value: "pin", label: "Pin" }, { value: "maintenance", label: "Maintenance" }, { value: "other", label: "Other" }, ], @@ -46,11 +47,34 @@ const STATUS_META: Record { expect(screen.queryByText("Pin dependencies")).toBeNull(); }); - it("maintenance PRs are shown when maintenance filter is selected", () => { + it("pin PRs are shown when pin filter is selected", () => { const pin = makeMergeablePR({ title: "chore(deps): pin dependencies" }); - setTabFilter("dependencies", "updateType", "maintenance"); + setTabFilter("dependencies", "updateType", "pin"); renderTab({ pullRequests: [pin] }); expect(screen.getByText("Pin dependencies")).toBeDefined(); }); + + it("lock file PRs are shown when maintenance filter is selected", () => { + const lockFile = makeMergeablePR({ title: "chore(deps): lock file maintenance" }); + setTabFilter("dependencies", "updateType", "maintenance"); + renderTab({ pullRequests: [lockFile] }); + expect(screen.getByText("Lock file maintenance")).toBeDefined(); + }); + + it("uses label as fallback when title has no version info", () => { + const pr = makeMergeablePR({ + title: "chore(deps): update dependency foo to v2", + labels: [{ name: "major", color: "ff0000" }], + }); + setTabFilter("dependencies", "updateType", "major"); + renderTab({ pullRequests: [pr] }); + expect(screen.getByText("foo → v2")).toBeDefined(); + }); }); describe("DependenciesTab — bot filter", () => { From 624d79a47135c842e8ecefba1189f8bfd8e2a2c8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 09:57:58 -0400 Subject: [PATCH 31/48] feat(deps): body fallback for update type, ignored visibility - Add parseRenovateBody to parse update type from Renovate PR body table - Fetch PR bodies on demand for dep PRs where title yields no updateType - Merge body-derived version info (packageName, from, to, updateType) into classifiedPRs for richer display titles and accurate category badges - Expand VersionInfo.updateType to include pin and digest - Add needsBodyFallback helper to identify PRs needing body fetch - Add fetchDepPRBodies GraphQL query in api.ts (nodes batch pattern) - Ignored dep PRs now stay visible on Dependencies tab (exclusive ownership still prevents them from appearing on Pull Requests tab) - Change empty filter message to 'No dependency PRs match your current filters' --- .../components/dashboard/DashboardPage.tsx | 42 ++++- .../components/dashboard/DependenciesTab.tsx | 32 ++-- src/app/lib/dependency-detection.ts | 46 ++++- src/app/services/api.ts | 47 +++++ src/shared/types.ts | 2 + tests/components/DashboardPage.test.tsx | 4 +- .../dashboard/DependenciesTab.test.tsx | 10 +- tests/lib/dependency-detection.test.ts | 166 ++++++++++++++++++ 8 files changed, 327 insertions(+), 22 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 5439580d..b4c2062e 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -11,9 +11,9 @@ 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 } from "../../lib/dependency-detection"; +import { isDependencyPr, expandBotLogins, needsBodyFallback } from "../../lib/dependency-detection"; import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard"; -import { fetchDashboardIssueBodies } from "../../services/api"; +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"; @@ -145,6 +145,7 @@ let _jiraFetching = false; const [abandonedDepsMap, setAbandonedDepsMap] = createSignal>(new Map()); const [dashboardIssueUrls, setDashboardIssueUrls] = createSignal>(new Map()); let _fetchingDashboardBodies = false; +let _fetchingDepBodies = false; // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { @@ -1035,7 +1036,7 @@ export default function DashboardPage() { return true; }).length }; })() : {}), - ...(enableDependencies() ? { dependencies: dependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), + ...(enableDependencies() ? { dependencies: dependencyPullRequests().length } : {}), ...customCounts, }; }); @@ -1156,6 +1157,41 @@ export default function DashboardPage() { { defer: true } )); + // Fetch PR bodies for dependency PRs where title parsing can't determine update type. + // Bodies are stored on the PR objects so DependenciesTab can parse Renovate's table. + createEffect(on( + () => _coordinator()?.lastRefreshAt(), + () => { + if (!config.dependencies.enabled) return; + if (_fetchingDepBodies) return; + const octokit = getClient(); + if (!octokit) return; + + const depPrs = dependencyPullRequests(); + const toFetch = depPrs.filter(needsBodyFallback); + 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; + + setDashboardData(produce((s) => { + for (const pr of s.pullRequests) { + const body = bodyMap.get(pr.id); + if (body) pr.body = body; + } + })); + } finally { + _fetchingDepBodies = false; + } + })(); + }, + { defer: true } + )); + // 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. diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index a030f025..6bb834ee 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -7,6 +7,7 @@ import type { AbandonedDependency } from "../../lib/dependency-dashboard"; import { classifyDepStatus, extractVersionInfo, + parseRenovateBody, ALL_DEP_STATUSES, isKnownDepBot, expandBotLogins, @@ -49,15 +50,19 @@ const STATUS_META: Record): DepCategory { + if (ut === "digest") return "patch"; + return ut; +} + function depCategory(pr: PullRequest, versionInfo: VersionInfo | null): DepCategory { - if (versionInfo?.updateType) return versionInfo.updateType; + if (versionInfo?.updateType) return mapUpdateType(versionInfo.updateType); const titleLower = pr.title.toLowerCase(); if (/pin\s+dep/i.test(titleLower)) return "pin"; if (/lock\s*file\s+maintenance/i.test(titleLower)) return "maintenance"; if (!versionInfo) { - // Label fallback — Renovate/Dependabot may label PRs with update type for (const l of pr.labels) { const name = l.name.toLowerCase(); if (name === "major") return "major"; @@ -67,7 +72,6 @@ function depCategory(pr: PullRequest, versionInfo: VersionInfo | null): DepCateg return "maintenance"; } - // Has versionInfo but no updateType — check labels before giving up for (const l of pr.labels) { const name = l.name.toLowerCase(); if (name === "major") return "major"; @@ -134,10 +138,6 @@ export default function DependenciesTab(props: DependenciesTabProps) { const filterGroups = createMemo(() => [UPDATE_TYPE_OPTIONS, botOptions()]); - const ignoredIds = createMemo( - () => new Set(viewState.ignoredItems.filter((i) => i.type === "pullRequest").map((i) => i.id)) - ); - const trackedPrIds = createMemo(() => config.enableTracking ? new Set(viewState.trackedItems.filter((t) => t.type === "pullRequest").map((t) => t.id)) @@ -150,10 +150,21 @@ export default function DependenciesTab(props: DependenciesTabProps) { const classifiedPRs = createMemo(() => { const filters = activeFilters(); - const ignored = ignoredIds(); return props.pullRequests .map((pr) => { - const versionInfo = extractVersionInfo(pr.title); + const titleInfo = extractVersionInfo(pr.title); + let versionInfo = titleInfo; + if (pr.body && (!titleInfo?.updateType || !titleInfo?.from)) { + const bodyInfo = parseRenovateBody(pr.body); + if (bodyInfo) { + versionInfo = { + packageName: titleInfo?.packageName ?? bodyInfo.packageName, + from: titleInfo?.from ?? bodyInfo.from, + to: titleInfo?.to ?? bodyInfo.to, + updateType: titleInfo?.updateType ?? bodyInfo.updateType, + }; + } + } const abandonedDeps = props.abandonedDepsMap.get(pr.repoFullName) ?? []; return { pr, @@ -164,7 +175,6 @@ export default function DependenciesTab(props: DependenciesTabProps) { }; }) .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; @@ -330,7 +340,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { 0}>
-

No PRs match your current filters

+

No dependency PRs match your current filters

diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index 1b64b18f..e0a1fd82 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -95,7 +95,7 @@ export interface VersionInfo { packageName?: string; from?: string; to?: string; - updateType?: "major" | "minor" | "patch"; + updateType?: "major" | "minor" | "patch" | "pin" | "digest"; } export function extractVersionInfo(title: string): VersionInfo | null { @@ -148,6 +148,50 @@ export function extractVersionInfo(title: string): VersionInfo | null { return null; } +export function parseRenovateBody(body: string): VersionInfo | null { + const lines = body.split("\n"); + for (const line of lines) { + const cells = line.split("|").map((c) => c.trim()).filter(Boolean); + if (cells.length < 4) continue; + + const updateCol = cells[2]?.toLowerCase(); + if (!updateCol) continue; + + const validTypes = ["major", "minor", "patch", "pin", "digest"] as const; + const matched = validTypes.find((t) => t === updateCol); + if (!matched) continue; + + const result: VersionInfo = { updateType: matched }; + + const pkgCell = cells[0]; + const linkMatch = /\[([^\]]+)\]/.exec(pkgCell); + result.packageName = linkMatch ? linkMatch[1] : pkgCell; + + const changeCell = cells[3]; + const changeMatch = /`?([^`\s]+)`?\s*→\s*`?([^`\s]+)`?/.exec(changeCell); + if (changeMatch) { + result.from = changeMatch[1]; + result.to = changeMatch[2]; + } + + return result; + } + return null; +} + +export function needsBodyFallback(pr: PullRequest): boolean { + if (pr.body || !pr.nodeId) return false; + const vi = extractVersionInfo(pr.title); + if (vi?.updateType) return false; + if (/pin\s+dep/i.test(pr.title)) return false; + if (/lock\s*file\s+maintenance/i.test(pr.title)) return false; + for (const l of pr.labels) { + const name = l.name.toLowerCase(); + if (name === "major" || name === "minor" || name === "patch") return false; + } + return true; +} + export function isRebasing(pr: PullRequest, rebaseLabel: string): boolean { const target = rebaseLabel.toLowerCase(); // SEC-003: plain string equality, never used in regex constructor diff --git a/src/app/services/api.ts b/src/app/services/api.ts index f9d748a4..281e96ed 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1205,6 +1205,53 @@ export async function fetchDashboardIssueBodies( return result; } +// ── Dependency PR body fetch ───────────────────────────────────────────────── + +const DEP_PR_BODIES_QUERY = ` + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest { databaseId body } + } + rateLimit { cost limit remaining resetAt } + } +`; + +interface DepPRBodiesResponse { + nodes: Array<{ databaseId: number; body: string | null } | null>; + rateLimit?: GraphQLRateLimit; +} + +export async function fetchDepPRBodies( + octokit: GitHubOctokit, + prNodeIds: string[] +): Promise> { + const result = new Map(); + if (prNodeIds.length === 0) return result; + + const batches = chunkArray(prNodeIds, NODES_BATCH_SIZE); + await Promise.allSettled(batches.map(async (batch) => { + try { + const response = await octokit.graphql( + DEP_PR_BODIES_QUERY, + { ids: batch, request: { apiSource: "depPRBodies" } } + ); + if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); + for (const node of response.nodes) { + if (!node || node.databaseId == null || !node.body) continue; + result.set(node.databaseId, node.body); + } + } 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); + } + })); + + return result; +} + /** * Merges phase 2 enrichment data into light PRs. Returns enriched PR array. * Also detects fork PRs for the statusCheckRollup fallback. diff --git a/src/shared/types.ts b/src/shared/types.ts index 06fd5ce0..5c5ce85a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -74,6 +74,8 @@ export interface PullRequest { enriched?: boolean; /** GraphQL global node ID — used for hot-poll status updates */ nodeId?: string; + /** PR body — fetched on demand for dependency update type detection */ + body?: string; surfacedBy?: string[]; } diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 6c2ebf77..3e163a8a 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2054,7 +2054,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); }); - it("Dependencies tab count reflects dep PR count (excluding ignored)", async () => { + it("Dependencies tab count includes ignored dep PRs", async () => { vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], pullRequests: [ @@ -2075,7 +2075,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { viewStore.ignoreItem({ id: 1, type: "pullRequest", repo: "owner/repo", title: "Bump lodash", ignoredAt: Date.now() }); await waitFor(() => { const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); - expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("2"); }); }); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 337e1811..98f154b1 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -146,7 +146,7 @@ describe("DependenciesTab — empty state", () => { }); setTabFilter("dependencies", "updateType", "major"); renderTab({ pullRequests: [pr] }); - expect(screen.getByText("No PRs match your current filters")).toBeDefined(); + expect(screen.getByText("No dependency PRs match your current filters")).toBeDefined(); }); }); @@ -487,7 +487,7 @@ describe("DependenciesTab — label filtering", () => { // ── Ignore button ───────────────────────────────────────────────────────────── describe("DependenciesTab — ignore button", () => { - it("clicking the ignore button hides the PR from the list", () => { + it("clicking ignore keeps the PR visible (deps tab does not filter ignored items)", () => { const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5" }); renderTab({ pullRequests: [pr] }); expect(screen.getByText("lodash → v5")).toBeDefined(); @@ -495,7 +495,7 @@ describe("DependenciesTab — ignore button", () => { const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); fireEvent.click(ignoreBtn); - expect(screen.queryByText("lodash → v5")).toBeNull(); + expect(screen.getByText("lodash → v5")).toBeDefined(); }); it("ignore button adds item to ignoredItems in viewState", () => { @@ -508,7 +508,7 @@ describe("DependenciesTab — ignore button", () => { expect(viewState.ignoredItems.some((i) => i.id === 5001 && i.type === "pullRequest")).toBe(true); }); - it("ignored PR is not rendered even when re-renderTab is called", () => { + it("ignored PR remains visible on re-render", () => { const pr = makeMergeablePR({ id: 5002, title: "Bump axios from 0.27.2 to 1.0.0" }); const { unmount } = renderTab({ pullRequests: [pr] }); @@ -516,7 +516,7 @@ describe("DependenciesTab — ignore button", () => { unmount(); renderTab({ pullRequests: [pr] }); - expect(screen.queryByText(/axios/)).toBeNull(); + expect(screen.getByText(/axios/)).toBeDefined(); }); }); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 496c7d40..0e0f2400 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { isDependencyPr, extractVersionInfo, + parseRenovateBody, + needsBodyFallback, classifyDepStatus, isRebasing, isKnownDepBot, @@ -446,3 +448,167 @@ describe("DEP_TITLE_PATTERN", () => { expect(DEP_TITLE_PATTERN.test("Fix authentication bug")).toBe(false); }); }); + +describe("parseRenovateBody", () => { + it("parses major update from Renovate table", () => { + const body = [ + "| Package | Type | Update | Change |", + "|---|---|---|---|", + "| [determinatesystems/nix-installer-action](https://example.com) | action | major | `v21` → `v22` |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "determinatesystems/nix-installer-action", + updateType: "major", + from: "v21", + to: "v22", + }); + }); + + it("parses minor update", () => { + const body = "| [react](url) | dependencies | minor | `18.2.0` → `18.3.0` |"; + expect(parseRenovateBody(body)).toEqual({ + packageName: "react", + updateType: "minor", + from: "18.2.0", + to: "18.3.0", + }); + }); + + it("parses patch update", () => { + const body = "| [lodash](url) | devDependencies | patch | `4.17.20` → `4.17.21` |"; + expect(parseRenovateBody(body)).toEqual({ + packageName: "lodash", + updateType: "patch", + from: "4.17.20", + to: "4.17.21", + }); + }); + + it("parses pin update", () => { + const body = "| [actions/checkout](url) | action | pin | `abc1234` → `def5678` |"; + expect(parseRenovateBody(body)).toEqual({ + packageName: "actions/checkout", + updateType: "pin", + from: "abc1234", + to: "def5678", + }); + }); + + it("parses digest update", () => { + const body = "| [node](url) | final | digest | `sha256:abc` → `sha256:def` |"; + expect(parseRenovateBody(body)).toEqual({ + packageName: "node", + updateType: "digest", + from: "sha256:abc", + to: "sha256:def", + }); + }); + + it("handles plain text package name (no markdown link)", () => { + const body = "| some-package | dependencies | major | `1.0.0` → `2.0.0` |"; + expect(parseRenovateBody(body)).toEqual({ + packageName: "some-package", + updateType: "major", + from: "1.0.0", + to: "2.0.0", + }); + }); + + it("returns null when no valid table row found", () => { + expect(parseRenovateBody("This is just a PR description with no table.")).toBeNull(); + }); + + it("returns null for header/separator rows", () => { + const body = [ + "| Package | Type | Update | Change |", + "|---|---|---|---|", + ].join("\n"); + expect(parseRenovateBody(body)).toBeNull(); + }); + + it("skips rows with unknown update type", () => { + const body = "| [pkg](url) | deps | rollback | `2.0` → `1.0` |"; + expect(parseRenovateBody(body)).toBeNull(); + }); + + it("handles body with surrounding text before table", () => { + const body = [ + "This PR updates dependencies.", + "", + "| Package | Type | Update | Change |", + "|---|---|---|---|", + "| [webpack](url) | devDependencies | minor | `5.90.0` → `5.91.0` |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "webpack", + updateType: "minor", + from: "5.90.0", + to: "5.91.0", + }); + }); + + it("returns result without from/to when Change column has no arrow", () => { + const body = "| [pkg](url) | action | major | see notes |"; + const result = parseRenovateBody(body); + expect(result).toEqual({ packageName: "pkg", updateType: "major" }); + }); +}); + +describe("needsBodyFallback", () => { + it("returns true when title gives no updateType and no labels help", () => { + const pr = makePullRequest({ + title: "chore(deps): update determinatesystems/nix-installer-action action to v22", + nodeId: "PR_abc", + }); + expect(needsBodyFallback(pr)).toBe(true); + }); + + it("returns false when title gives updateType", () => { + const pr = makePullRequest({ + title: "Bump lodash from 3.0.0 to 4.0.0", + nodeId: "PR_abc", + }); + expect(needsBodyFallback(pr)).toBe(false); + }); + + it("returns false when body already present", () => { + const pr = makePullRequest({ + title: "chore(deps): update something action to v5", + nodeId: "PR_abc", + body: "already fetched", + }); + expect(needsBodyFallback(pr)).toBe(false); + }); + + it("returns false when no nodeId", () => { + const pr = makePullRequest({ + title: "chore(deps): update something action to v5", + }); + expect(needsBodyFallback(pr)).toBe(false); + }); + + it("returns false for pin dependencies title", () => { + const pr = makePullRequest({ + title: "chore(deps): pin dependencies", + nodeId: "PR_abc", + }); + expect(needsBodyFallback(pr)).toBe(false); + }); + + it("returns false for lock file maintenance title", () => { + const pr = makePullRequest({ + title: "chore(deps): lock file maintenance", + nodeId: "PR_abc", + }); + expect(needsBodyFallback(pr)).toBe(false); + }); + + it("returns false when major label present", () => { + const pr = makePullRequest({ + title: "chore(deps): update something action to v5", + nodeId: "PR_abc", + labels: [{ name: "major", color: "ff0000" }], + }); + expect(needsBodyFallback(pr)).toBe(false); + }); +}); From 67c5fd7f2d25fe4623a070d672264851ff99c44a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 10:37:18 -0400 Subject: [PATCH 32/48] fix(deps): body version priority, category badges, IgnoreBadge - Prefer body-derived versions over imprecise title versions (body has full semver, title may only have tag like v9) - Show category badge for all types including pin and maintenance (previously only shown when versionInfo.updateType was set) - Wire IgnoreBadge into Dependencies tab toolbar for unignoring dep PRs - Exclude dep PR IDs from Pull Requests tab IgnoreBadge via depPrIds prop - Restore ignored item filtering in Dependencies tab classifiedPRs --- .../components/dashboard/DashboardPage.tsx | 3 +- .../components/dashboard/DependenciesTab.tsx | 41 ++++++++++++------- .../components/dashboard/PullRequestsTab.tsx | 8 ++-- tests/components/DashboardPage.test.tsx | 4 +- .../dashboard/DependenciesTab.test.tsx | 17 ++++++-- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index b4c2062e..942734cc 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1036,7 +1036,7 @@ export default function DashboardPage() { return true; }).length }; })() : {}), - ...(enableDependencies() ? { dependencies: dependencyPullRequests().length } : {}), + ...(enableDependencies() ? { dependencies: dependencyPullRequests().filter((p) => !ignoredPRs.has(p.id)).length } : {}), ...customCounts, }; }); @@ -1280,6 +1280,7 @@ export default function DashboardPage() { configRepoNames={configRepoNames()} refreshTick={refreshTick()} jiraKeyMap={jiraKeyMap} + depPrIds={dependencyPrIds()} />
diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 6bb834ee..23293847 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -1,6 +1,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"; import { config, updateConfig } from "../../stores/config"; -import { viewState, setTabFilter, resetAllTabFilters, ignoreItem, trackItem, untrackItem, DependencyFiltersSchema, setDependencyExpandedGroups } from "../../stores/view"; +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"; @@ -138,6 +139,15 @@ export default function DependenciesTab(props: DependenciesTabProps) { 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)) @@ -150,6 +160,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { const classifiedPRs = createMemo(() => { const filters = activeFilters(); + const ignored = ignoredIds(); return props.pullRequests .map((pr) => { const titleInfo = extractVersionInfo(pr.title); @@ -159,9 +170,9 @@ export default function DependenciesTab(props: DependenciesTabProps) { if (bodyInfo) { versionInfo = { packageName: titleInfo?.packageName ?? bodyInfo.packageName, - from: titleInfo?.from ?? bodyInfo.from, - to: titleInfo?.to ?? bodyInfo.to, - updateType: titleInfo?.updateType ?? bodyInfo.updateType, + from: bodyInfo.from ?? titleInfo?.from, + to: bodyInfo.to ?? titleInfo?.to, + updateType: bodyInfo.updateType ?? titleInfo?.updateType, }; } } @@ -175,6 +186,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { }; }) .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; @@ -290,6 +302,7 @@ export default function DependenciesTab(props: DependenciesTabProps) { />
+ - {({ pr, versionInfo, abandonedDep }) => { + {({ pr, versionInfo, category, abandonedDep }) => { const dashUrl = () => props.dashboardIssueUrls.get(pr.repoFullName); const title = () => displayTitle(pr, versionInfo); return ( @@ -449,16 +462,14 @@ function StatusGroup(props: StatusGroupProps) { - - {(updateType) => ( - - {updateType()} - - )} + + + {category} + diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index ff018a2b..34336fea 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -42,6 +42,7 @@ export interface PullRequestsTabProps { customTabId?: string; filterPreset?: Record; jiraKeyMap?: () => ReadonlyMap; + depPrIds?: ReadonlySet; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size"; @@ -100,9 +101,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1; }); - const ignoredPullRequests = createMemo(() => - viewState.ignoredItems.filter(i => i.type === "pullRequest") - ); + const ignoredPullRequests = createMemo(() => { + const depIds = props.depPrIds; + return viewState.ignoredItems.filter(i => i.type === "pullRequest" && (!depIds || !depIds.has(i.id))); + }); // Merge chain: schema defaults → preset → stored runtime overrides const activeFilters = createMemo(() => diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 3e163a8a..6c2ebf77 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2054,7 +2054,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { }); }); - it("Dependencies tab count includes ignored dep PRs", async () => { + it("Dependencies tab count reflects dep PR count (excluding ignored)", async () => { vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], pullRequests: [ @@ -2075,7 +2075,7 @@ describe("DashboardPage — dependency pre-exclusivity", () => { viewStore.ignoreItem({ id: 1, type: "pullRequest", repo: "owner/repo", title: "Bump lodash", ignoredAt: Date.now() }); await waitFor(() => { const depsTab = screen.getByRole("tab", { name: /Dependencies/ }); - expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("2"); + expect(depsTab.textContent?.replace(/\D+/g, "")).toBe("1"); }); }); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 98f154b1..63de5d90 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -487,7 +487,7 @@ describe("DependenciesTab — label filtering", () => { // ── Ignore button ───────────────────────────────────────────────────────────── describe("DependenciesTab — ignore button", () => { - it("clicking ignore keeps the PR visible (deps tab does not filter ignored items)", () => { + it("clicking the ignore button hides the PR from the list", () => { const pr = makeMergeablePR({ title: "chore(deps): update dependency lodash to v5" }); renderTab({ pullRequests: [pr] }); expect(screen.getByText("lodash → v5")).toBeDefined(); @@ -495,7 +495,7 @@ describe("DependenciesTab — ignore button", () => { const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); fireEvent.click(ignoreBtn); - expect(screen.getByText("lodash → v5")).toBeDefined(); + expect(screen.queryByText("lodash → v5")).toBeNull(); }); it("ignore button adds item to ignoredItems in viewState", () => { @@ -508,7 +508,7 @@ describe("DependenciesTab — ignore button", () => { expect(viewState.ignoredItems.some((i) => i.id === 5001 && i.type === "pullRequest")).toBe(true); }); - it("ignored PR remains visible on re-render", () => { + it("ignored PR is not rendered even when re-renderTab is called", () => { const pr = makeMergeablePR({ id: 5002, title: "Bump axios from 0.27.2 to 1.0.0" }); const { unmount } = renderTab({ pullRequests: [pr] }); @@ -516,7 +516,16 @@ describe("DependenciesTab — ignore button", () => { unmount(); renderTab({ pullRequests: [pr] }); - expect(screen.getByText(/axios/)).toBeDefined(); + expect(screen.queryByText(/axios/)).toBeNull(); + }); + + it("ignored dep PRs appear in the IgnoreBadge", () => { + const pr = makeMergeablePR({ id: 5003, title: "chore(deps): update dependency chalk to v6" }); + renderTab({ pullRequests: [pr] }); + + fireEvent.click(screen.getByRole("button", { name: /^Ignore #/ })); + + expect(screen.getByLabelText(/ignored items/i)).toBeDefined(); }); }); From beb3f8bf182fa96f7fb740a8ba2640b3b24a7855 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 11:19:54 -0400 Subject: [PATCH 33/48] fix(deps): parse all Renovate table variants for update type - Detect table schema from header row (Package/Update/Change columns) - Handle 3-column format (Package | Update | Change) used by some PRs - Handle tables without Update column (Package | Change | Age | ...) by deriving updateType from semver comparison of version change - Strip markdown links from header cells (e.g. [Age](url)) - Scan all cells for version arrow when no Change column exists --- src/app/lib/dependency-detection.ts | 74 ++++++++++++---- tests/lib/dependency-detection.test.ts | 112 +++++++++++++++++++------ 2 files changed, 142 insertions(+), 44 deletions(-) diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index e0a1fd82..84ddcf4d 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -148,34 +148,72 @@ export function extractVersionInfo(title: string): VersionInfo | null { return null; } +const VALID_UPDATE_TYPES = new Set(["major", "minor", "patch", "pin", "digest"]); +const VERSION_ARROW_RE = /`?([^`\s]+)`?\s*→\s*`?([^`\s]+)`?/; + +function stripMarkdownLink(cell: string): string { + const m = /^\[([^\]]+)\]/.exec(cell); + return m ? m[1] : cell; +} + export function parseRenovateBody(body: string): VersionInfo | null { const lines = body.split("\n"); - for (const line of lines) { - const cells = line.split("|").map((c) => c.trim()).filter(Boolean); - if (cells.length < 4) continue; - const updateCol = cells[2]?.toLowerCase(); - if (!updateCol) continue; + let packageIdx = -1; + let updateIdx = -1; + let changeIdx = -1; + let headerLine = -1; + + for (let i = 0; i < lines.length; i++) { + const cells = lines[i].split("|").map((c) => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + const headers = cells.map((c) => stripMarkdownLink(c).toLowerCase()); + const pIdx = headers.indexOf("package"); + if (pIdx === -1) continue; + packageIdx = pIdx; + updateIdx = headers.indexOf("update"); + changeIdx = headers.indexOf("change"); + headerLine = i; + break; + } - const validTypes = ["major", "minor", "patch", "pin", "digest"] as const; - const matched = validTypes.find((t) => t === updateCol); - if (!matched) continue; + if (headerLine === -1) return null; - const result: VersionInfo = { updateType: matched }; + for (let i = headerLine + 1; i < lines.length; i++) { + const cells = lines[i].split("|").map((c) => c.trim()).filter(Boolean); + if (cells.length < 2) continue; + if (cells.every((c) => /^[-:]+$/.test(c))) continue; - const pkgCell = cells[0]; - const linkMatch = /\[([^\]]+)\]/.exec(pkgCell); - result.packageName = linkMatch ? linkMatch[1] : pkgCell; + const result: VersionInfo = {}; - const changeCell = cells[3]; - const changeMatch = /`?([^`\s]+)`?\s*→\s*`?([^`\s]+)`?/.exec(changeCell); - if (changeMatch) { - result.from = changeMatch[1]; - result.to = changeMatch[2]; + if (packageIdx >= 0 && packageIdx < cells.length) { + result.packageName = stripMarkdownLink(cells[packageIdx]); } - return result; + if (updateIdx >= 0 && updateIdx < cells.length) { + const val = cells[updateIdx].toLowerCase(); + if (VALID_UPDATE_TYPES.has(val)) result.updateType = val as VersionInfo["updateType"]; + } + + if (changeIdx >= 0 && changeIdx < cells.length) { + const m = VERSION_ARROW_RE.exec(cells[changeIdx]); + if (m) { result.from = m[1]; result.to = m[2]; } + } + + if (!result.from) { + for (const cell of cells) { + const m = VERSION_ARROW_RE.exec(cell); + if (m) { result.from = m[1]; result.to = m[2]; break; } + } + } + + if (!result.updateType && result.from && result.to) { + result.updateType = semverUpdateType(result.from, result.to) ?? undefined; + } + + if (result.updateType || result.from) return result; } + return null; } diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index 0e0f2400..7182304f 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -450,7 +450,7 @@ describe("DEP_TITLE_PATTERN", () => { }); describe("parseRenovateBody", () => { - it("parses major update from Renovate table", () => { + it("parses 4-column table (Package | Type | Update | Change)", () => { const body = [ "| Package | Type | Update | Change |", "|---|---|---|---|", @@ -464,8 +464,40 @@ describe("parseRenovateBody", () => { }); }); + it("parses 3-column table (Package | Update | Change)", () => { + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| [gitleaks/gitleaks](url) | patch | `v8.30.0` → `v8.30.1` |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "gitleaks/gitleaks", + updateType: "patch", + from: "v8.30.0", + to: "v8.30.1", + }); + }); + + it("derives updateType from semver when no Update column (Package | Change | Age | Confidence)", () => { + const body = [ + "| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |", + "|---|---|---|---|", + "| [pytest](url) ([changelog](url2)) | `8.3.4` → `9.0.3` | ![age](img) | ![confidence](img) |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "pytest", + updateType: "major", + from: "8.3.4", + to: "9.0.3", + }); + }); + it("parses minor update", () => { - const body = "| [react](url) | dependencies | minor | `18.2.0` → `18.3.0` |"; + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| [react](url) | minor | `18.2.0` → `18.3.0` |", + ].join("\n"); expect(parseRenovateBody(body)).toEqual({ packageName: "react", updateType: "minor", @@ -474,18 +506,12 @@ describe("parseRenovateBody", () => { }); }); - it("parses patch update", () => { - const body = "| [lodash](url) | devDependencies | patch | `4.17.20` → `4.17.21` |"; - expect(parseRenovateBody(body)).toEqual({ - packageName: "lodash", - updateType: "patch", - from: "4.17.20", - to: "4.17.21", - }); - }); - it("parses pin update", () => { - const body = "| [actions/checkout](url) | action | pin | `abc1234` → `def5678` |"; + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| [actions/checkout](url) | pin | `abc1234` → `def5678` |", + ].join("\n"); expect(parseRenovateBody(body)).toEqual({ packageName: "actions/checkout", updateType: "pin", @@ -495,7 +521,11 @@ describe("parseRenovateBody", () => { }); it("parses digest update", () => { - const body = "| [node](url) | final | digest | `sha256:abc` → `sha256:def` |"; + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| [node](url) | digest | `sha256:abc` → `sha256:def` |", + ].join("\n"); expect(parseRenovateBody(body)).toEqual({ packageName: "node", updateType: "digest", @@ -505,7 +535,11 @@ describe("parseRenovateBody", () => { }); it("handles plain text package name (no markdown link)", () => { - const body = "| some-package | dependencies | major | `1.0.0` → `2.0.0` |"; + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| some-package | major | `1.0.0` → `2.0.0` |", + ].join("\n"); expect(parseRenovateBody(body)).toEqual({ packageName: "some-package", updateType: "major", @@ -518,19 +552,14 @@ describe("parseRenovateBody", () => { expect(parseRenovateBody("This is just a PR description with no table.")).toBeNull(); }); - it("returns null for header/separator rows", () => { + it("returns null for header/separator rows only", () => { const body = [ - "| Package | Type | Update | Change |", - "|---|---|---|---|", + "| Package | Update | Change |", + "|---|---|---|", ].join("\n"); expect(parseRenovateBody(body)).toBeNull(); }); - it("skips rows with unknown update type", () => { - const body = "| [pkg](url) | deps | rollback | `2.0` → `1.0` |"; - expect(parseRenovateBody(body)).toBeNull(); - }); - it("handles body with surrounding text before table", () => { const body = [ "This PR updates dependencies.", @@ -548,9 +577,40 @@ describe("parseRenovateBody", () => { }); it("returns result without from/to when Change column has no arrow", () => { - const body = "| [pkg](url) | action | major | see notes |"; - const result = parseRenovateBody(body); - expect(result).toEqual({ packageName: "pkg", updateType: "major" }); + const body = [ + "| Package | Update | Change |", + "|---|---|---|", + "| [pkg](url) | major | see notes |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ packageName: "pkg", updateType: "major" }); + }); + + it("finds version arrow in any cell when no Change column exists", () => { + const body = [ + "| Package | Version |", + "|---|---|", + "| [lodash](url) | `4.17.20` → `4.17.21` |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "lodash", + updateType: "patch", + from: "4.17.20", + to: "4.17.21", + }); + }); + + it("strips markdown links from header cells", () => { + const body = [ + "| Package | Change | [Age](https://example.com) |", + "|---|---|---|", + "| [pkg](url) | `1.0.0` → `2.0.0` | ![age](img) |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "pkg", + updateType: "major", + from: "1.0.0", + to: "2.0.0", + }); }); }); From f0f04127dc1b1b71d6ecd5ebc28e8c81c73519ac Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 11:29:25 -0400 Subject: [PATCH 34/48] feat(deps): cleans up row layout for deps tab - Add hideAuthor, hideNumber, subtleRepo, titlePrefix props to ItemRow - Dependencies tab: repo toned down (no pill, just monospace text), fixed min-width for vertical alignment of titles across rows - Type badge moved to titlePrefix slot (between repo and title) with fixed min-width for column alignment - Author name and PR number hidden (noise for dep PRs) - Bot avatar removed (redundant when author is hidden) --- .../components/dashboard/DependenciesTab.tsx | 26 +++++----- src/app/components/dashboard/ItemRow.tsx | 48 ++++++++++++++----- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 23293847..45c2d4f4 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -448,22 +448,12 @@ function StatusGroup(props: StatusGroupProps) { onTrack={props.enableTracking ? () => props.onTrack(pr) : undefined} isTracked={props.enableTracking ? props.trackedPrIds.has(pr.id) : undefined} isPolling={props.hotPollingPRIds?.has(pr.id)} - > -
- - {pr.userLogin} - - - - - - + hideAuthor + hideNumber + subtleRepo + titlePrefix={ - + } + > +
+ + + diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 4f3c5025..21eb217c 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -22,6 +22,10 @@ export interface ItemRowProps { isTracked?: boolean; commentCount?: number; hideRepo?: boolean; + hideAuthor?: boolean; + hideNumber?: boolean; + subtleRepo?: boolean; + titlePrefix?: JSX.Element; surfacedByBadge?: JSX.Element; isPolling?: boolean; isFlashing?: boolean; @@ -113,8 +117,11 @@ export default function ItemRow(props: ItemRowProps) { when={isCompact()} fallback={ {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} + {" · "} +