diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 69add25c..70954c3b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -287,11 +287,12 @@ Unlike the Pull Requests tab (which groups by repo), the Dependencies tab groups | 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 | +| **Mergeable** | CI passing (all checks green), not yet approved — awaiting review | +| **Pending Rebase** | PR has the configured rebase label — waiting for the bot to rebase | +| **Needs Action** | 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). +Within each group, PRs are sorted by repository name, then update category (maintenance, pin, patch, minor, major), then update date. ### Abandoned Dependencies diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index caea9250..8356bb86 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -187,6 +187,7 @@ onAuthCleared(() => { setDepMeta(new Map()); localStorage.removeItem?.(DEP_META_STORAGE_KEY); _fetchingDashboardBodies = false; + _fetchingDepBodies = false; resetAbandonedPatternCache(); const coord = _coordinator(); if (coord) { @@ -1278,10 +1279,10 @@ export default function DashboardPage() { isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading} lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt} onRefresh={() => _coordinator()?.manualRefresh()} - sortOptions={globalSortOptions} - sortValue={viewState.globalSort.field} - sortDirection={viewState.globalSort.direction} - onSortChange={(field, dir) => setSortPreference(field, dir)} + sortOptions={activeTab() === "dependencies" ? undefined : globalSortOptions} + sortValue={activeTab() === "dependencies" ? undefined : viewState.globalSort.field} + sortDirection={activeTab() === "dependencies" ? undefined : viewState.globalSort.direction} + onSortChange={activeTab() === "dependencies" ? undefined : (field, dir) => setSortPreference(field, dir)} hideOrgRepo={!isBuiltinTab(activeTab()) || activeTab() === "dependencies"} /> @@ -1331,6 +1332,20 @@ export default function DashboardPage() { onRefresh={() => _coordinator()?.manualRefresh()} /> + + _coordinator()?.manualRefresh()} + /> + {/* TrackedTab intentionally receives unfiltered dashboardData — it bypasses exclusivity */} { - const { field, direction } = viewState.globalSort; const items = [...classifiedPRs()]; - const dir = direction === "asc" ? 1 : -1; - items.sort((a, b) => { - // User-selected sort as primary - let cmp = 0; - switch (field) { - case "repo": cmp = a.pr.repoFullName.localeCompare(b.pr.repoFullName); break; - case "title": cmp = a.pr.title.localeCompare(b.pr.title); break; - case "author": cmp = a.pr.userLogin.localeCompare(b.pr.userLogin); break; - case "comments": cmp = a.pr.comments - b.pr.comments; break; - case "checkStatus": cmp = (a.pr.checkStatus ?? "").localeCompare(b.pr.checkStatus ?? ""); break; - case "reviewDecision": cmp = (a.pr.reviewDecision ?? "").localeCompare(b.pr.reviewDecision ?? ""); break; - case "size": cmp = (a.pr.additions + a.pr.deletions) - (b.pr.additions + b.pr.deletions); break; - case "createdAt": cmp = a.pr.createdAt.localeCompare(b.pr.createdAt); break; - case "updatedAt": - default: - cmp = a.pr.updatedAt.localeCompare(b.pr.updatedAt); - break; - } - if (cmp !== 0) return cmp * dir; - - // Category as secondary (safest → least safe within each group) + const repoCmp = a.pr.repoFullName.localeCompare(b.pr.repoFullName); + if (repoCmp !== 0) return repoCmp; + const catCmp = CATEGORY_SORT_ORDER[a.category] - CATEGORY_SORT_ORDER[b.category]; if (catCmp !== 0) return catCmp; - return a.pr.repoFullName.localeCompare(b.pr.repoFullName); + return a.pr.updatedAt.localeCompare(b.pr.updatedAt); }); - return items; }); diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 21eb217c..07f8ccbf 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -117,22 +117,23 @@ export default function ItemRow(props: ItemRowProps) { when={isCompact()} fallback={ - {props.repo} + {props.subtleRepo ? repoShortName() : props.repo} } > {repoShortName()} diff --git a/src/app/lib/dependency-detection.ts b/src/app/lib/dependency-detection.ts index b3cb597e..171e8f9d 100644 --- a/src/app/lib/dependency-detection.ts +++ b/src/app/lib/dependency-detection.ts @@ -18,7 +18,7 @@ export const DEP_BRANCH_PREFIXES = [ "pyup-update-", ]; -export const DEP_TITLE_PATTERN = /^(Bump |Update dependency |chore\(deps|fix\(deps|build\(deps|\[Snyk\])/i; +export const DEP_TITLE_PATTERN = /^(Bump |Update dependency |\[Snyk\]|(?:chore|fix|build)\(deps[^)]*\):\s*(?:update |pin |lock file |bump ))/i; export const DEP_TOOL_LABEL_NAMES = new Set([ "dependencies", @@ -72,8 +72,14 @@ export function isDependencyPr(pr: PullRequest, trackedBotLogins: Set): return false; } +const VERSION_SPECIFIER_RE = /^[><=!~^]+/; + +function stripVersionSpecifier(v: string): string { + return v.replace(VERSION_SPECIFIER_RE, ""); +} + function parseSemver(v: string): [number, number, number] | null { - const cleaned = v.replace(/^v/, ""); + const cleaned = stripVersionSpecifier(v).replace(/^v/, ""); const parts = cleaned.split("."); if (parts.length < 2) return null; const nums = parts.slice(0, 3).map(Number); @@ -122,8 +128,8 @@ export function extractVersionInfo(title: string): VersionInfo | null { // "update X requirement from >=A to >=B" (Dependabot Python) const reqMatch = /^update\s+(.+?)\s+requirement\s+from\s+([^\s]+)\s+to\s+([^\s]+)/i.exec(body); if (reqMatch) { - const from = reqMatch[2]!.replace(/^[><=!~^]+/, ""); - const to = reqMatch[3]!.replace(/^[><=!~^]+/, ""); + const from = stripVersionSpecifier(reqMatch[2]!); + const to = stripVersionSpecifier(reqMatch[3]!); return { packageName: reqMatch[1]!, from, to, updateType: semverUpdateType(from, to) ?? undefined }; } @@ -197,13 +203,13 @@ export function parseRenovateBody(body: string): VersionInfo | null { 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 (m) { result.from = stripVersionSpecifier(m[1]); result.to = stripVersionSpecifier(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 (m) { result.from = stripVersionSpecifier(m[1]); result.to = stripVersionSpecifier(m[2]); break; } } } diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 281e96ed..517e6a5f 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -922,7 +922,7 @@ async function graphqlLightCombinedSearch( return { issues: [], pullRequests: [], - errors: [{ repo: "search", statusCode: null, message: "Invalid userLogin", retryable: false }], + errors: [{ repo: "search", statusCode: null, message: `Invalid userLogin: "${userLogin}"`, retryable: false }], }; } diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index fed62eb1..8fde1ee9 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -38,6 +38,17 @@ export function loadConfig(): Config { (parsed as Record).theme = "auto"; } } + // Migrate tracked user logins: strip [bot] suffix from stored logins. + // handleTrackBot now stores base names only, but pre-migration data may + // have "renovate[bot]" which causes a doubled "renovate[bot][bot]" variant. + if (parsed && typeof parsed === "object" && Array.isArray((parsed as Record).trackedUsers)) { + const users = (parsed as Record).trackedUsers as { login?: string }[]; + for (const u of users) { + if (typeof u.login === "string" && /\[bot\]$/i.test(u.login)) { + u.login = u.login.replace(/\[bot\]$/i, ""); + } + } + } const result = ConfigSchema.safeParse(parsed); if (result.success) { const data = result.data; diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index c9515c7e..ec826bf5 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -573,3 +573,56 @@ describe("IssuesTab", () => { expect(link.getAttribute("rel")).toBe("noopener noreferrer"); }); }); + +describe("IssuesTab — hideDepDashboard + dependencies.enabled interaction", () => { + it("shows Dependency Dashboard in custom tab even when hideDepDashboard=true and deps disabled", () => { + viewStore.updateViewState({ hideDepDashboard: true }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo", userLogin: "me" }), + ]; + setAllExpanded("my-custom-tab", ["org/repo"], true); + render(() => ); + screen.getByText("Dependency Dashboard"); + }); +}); + +describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => { + it("hides Dependency Dashboard issue when hideDepDashboard=true and dependencies.enabled=false", () => { + viewStore.updateViewState({ hideDepDashboard: true }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), + makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }), + ]; + setAllExpanded("issues", ["org/repo"], true); + render(() => ); + expect(screen.queryByText("Dependency Dashboard")).toBeNull(); + screen.getByText("Regular issue"); + }); + + it("shows Dependency Dashboard issue when hideDepDashboard=true but dependencies.enabled=true", () => { + viewStore.updateViewState({ hideDepDashboard: true }); + updateConfig({ dependencies: { enabled: true, rebaseLabel: "rebase" } }); + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), + makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }), + ]; + setAllExpanded("issues", ["org/repo"], true); + render(() => ); + screen.getByText("Dependency Dashboard"); + screen.getByText("Regular issue"); + }); + + it("shows Dependency Dashboard issue when hideDepDashboard=false regardless of dependencies.enabled", () => { + viewStore.updateViewState({ hideDepDashboard: false }); + updateConfig({ dependencies: { enabled: false, rebaseLabel: "rebase" } }); + const issues = [ + makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }), + ]; + setAllExpanded("issues", ["org/repo"], true); + render(() => ); + screen.getByText("Dependency Dashboard"); + }); + +}); diff --git a/tests/components/dashboard/DependenciesTab.test.tsx b/tests/components/dashboard/DependenciesTab.test.tsx index 897d6bdc..ea47e4b8 100644 --- a/tests/components/dashboard/DependenciesTab.test.tsx +++ b/tests/components/dashboard/DependenciesTab.test.tsx @@ -528,6 +528,148 @@ describe("DependenciesTab — ignore button", () => { expect(screen.getByLabelText(/ignored items/i)).toBeDefined(); }); + + 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(); + }); +}); + +// ── Track button ────────────────────────────────────────────────────────────── + +describe("DependenciesTab — track button", () => { + it("track button is not rendered when enableTracking is false", () => { + updateConfig({ enableTracking: false }); + const pr = makeMergeablePR({ title: "chore(deps): update dependency 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 = makeMergeablePR({ title: "chore(deps): update dependency 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 = makeMergeablePR({ id: 6001, title: "Bump react from 17.0.0 to 18.0.0" }); + 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 = makeMergeablePR({ id: 6002, title: "Bump typescript from 4.0.0 to 5.0.0" }); + renderTab({ pullRequests: [pr] }); + + fireEvent.click(screen.getByRole("button", { name: /^Pin #/ })); + expect(viewState.trackedItems.some((t) => t.id === 6002)).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: /^Unpin #/ })); + 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); + }); +}); + +// ── Ignore button ───────────────────────────────────────────────────────────── + +describe("DependenciesTab — ignore button", () => { + 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(); + + const ignoreBtn = screen.getByRole("button", { name: /^Ignore #/ }); + fireEvent.click(ignoreBtn); + + expect(screen.queryByText("lodash → v5")).toBeNull(); + }); + + it("ignore button adds item to ignoredItems in viewState", () => { + const pr = makeMergeablePR({ id: 5001, title: "chore(deps): update dependency 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 = 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(); + + renderTab({ pullRequests: [pr] }); + expect(screen.queryByText(/axios/)).toBeNull(); + }); }); // ── Track button ────────────────────────────────────────────────────────────── diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 0bf0bf9c..27101128 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../src/app/lib/url", () => ({ import { produce } from "solid-js/store"; import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; -import { viewState, setViewState, setTabFilter, setAllExpanded, resetViewState, updateViewState, setCustomTabFilter } from "../../../src/app/stores/view"; +import { viewState, setViewState, setTabFilter, setAllExpanded, resetViewState, updateViewState, setCustomTabFilter, ignoreItem } from "../../../src/app/stores/view"; import type { TrackedUser } from "../../../src/app/stores/config"; import { updateConfig, resetConfig } from "../../../src/app/stores/config"; @@ -869,3 +869,34 @@ describe("PullRequestsTab — customTabId filter preset", () => { screen.getByText("My PR"); }); }); + +// ── PullRequestsTab — depPrIds ignored-item filtering ─────────────────────── + +describe("PullRequestsTab — depPrIds ignored-item filtering", () => { + it("excludes dep PR IDs from ignored items when depPrIds is provided", () => { + ignoreItem({ id: 100, type: "pullRequest", repo: "owner/repo", title: "Dep PR", ignoredAt: Date.now() }); + ignoreItem({ id: 200, type: "pullRequest", repo: "owner/repo", title: "Normal PR", ignoredAt: Date.now() }); + + render(() => ); + + screen.getByRole("button", { name: /1 ignored/i }); + expect(screen.queryByRole("button", { name: /2 ignored/i })).toBeNull(); + }); + + it("includes all ignored PRs when depPrIds is undefined", () => { + ignoreItem({ id: 100, type: "pullRequest", repo: "owner/repo", title: "PR A", ignoredAt: Date.now() }); + ignoreItem({ id: 200, type: "pullRequest", repo: "owner/repo", title: "PR B", ignoredAt: Date.now() }); + + render(() => ); + + screen.getByRole("button", { name: /2 ignored/i }); + }); + + it("shows no badge when all ignored PRs belong to depPrIds", () => { + ignoreItem({ id: 100, type: "pullRequest", repo: "owner/repo", title: "Dep only", ignoredAt: Date.now() }); + + render(() => ); + + expect(screen.queryByRole("button", { name: /ignored/i })).toBeNull(); + }); +}); diff --git a/tests/components/layout/TabBar.test.tsx b/tests/components/layout/TabBar.test.tsx index 2f86528d..366b13a4 100644 --- a/tests/components/layout/TabBar.test.tsx +++ b/tests/components/layout/TabBar.test.tsx @@ -371,4 +371,64 @@ describe("TabBar", () => { await user.click(depTab); expect(onTabChange).toHaveBeenCalledWith("dependencies"); }); + + // ── 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"); + }); }); diff --git a/tests/lib/dependency-detection.test.ts b/tests/lib/dependency-detection.test.ts index ba7180aa..8fb4b6bc 100644 --- a/tests/lib/dependency-detection.test.ts +++ b/tests/lib/dependency-detection.test.ts @@ -91,6 +91,21 @@ describe("isDependencyPr", () => { expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(false); }); + it("returns false for human fix(deps) PR that is not a bot update", () => { + const pr = makePullRequest({ + userLogin: "octocat", + headRef: "fix/deps-tab-fixes", + title: "fix(deps): post-deploy dependency tab fixes", + labels: [], + }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(false); + }); + + it("returns true for bot fix(deps) PR with update action", () => { + const pr = makePullRequest({ title: "fix(deps): update all non-major dependencies" }); + expect(isDependencyPr(pr, NO_TRACKED_BOTS)).toBe(true); + }); + it("returns false for regular PR even with tracked users of type user", () => { const pr = makePullRequest({ userLogin: "alice" }); const tracked = new Set(["bob"]); @@ -180,6 +195,11 @@ describe("extractVersionInfo", () => { expect(result).toEqual({ packageName: "mypy", from: "1.15.0", to: "1.20.1", updateType: "minor" }); }); + it("handles pinned == pip requirement specifier", () => { + const result = extractVersionInfo("update boto3 requirement from ==1.26.0 to ==1.34.0"); + expect(result).toEqual({ packageName: "boto3", from: "1.26.0", to: "1.34.0", updateType: "minor" }); + }); + it("returns null for unrecognized title format", () => { expect(extractVersionInfo("Fix a bug in auth flow")).toBeNull(); }); @@ -531,6 +551,20 @@ describe("parseRenovateBody", () => { }); }); + it("strips Python version specifiers (==, >=, ~=) from versions", () => { + const body = [ + "| Package | Change | [Age](url) | [Confidence](url) |", + "|---|---|---|---|", + "| [stamina](url) ([changelog](url2)) | `==25.2.0` → `==26.1.0` | ![age](img) | ![confidence](img) |", + ].join("\n"); + expect(parseRenovateBody(body)).toEqual({ + packageName: "stamina", + updateType: "major", + from: "25.2.0", + to: "26.1.0", + }); + }); + it("parses digest update", () => { const body = [ "| Package | Update | Change |",