also looking at this
fix: prevent generated dependency cache PRs
#1366
16 files
+356
-72
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -383,8 +383,10 @@ export class PullRequestGenerator { | ||
| 383 | 383 | } |
| 384 | 384 | |
| 385 | 385 | // Generate version change with diff link (Renovate style) |
| 386 | const diffUrl = `https://renovatebot.com/diffs/npm/${encodeURIComponent(cleanPackageName)}/${update.currentVersion}/${update.newVersion}` | |
| 387 | const change = `[\`${update.currentVersion}\` -> \`${update.newVersion}\`](${diffUrl})` | |
| 386 | const diffUrl = `https://renovatebot.com/diffs/npm/${encodeURIComponent(cleanPackageName)}/${encodeURIComponent(update.currentVersion)}/${encodeURIComponent(update.newVersion)}` | |
| 387 | const currentVersion = this.escapeMarkdownTableValue(update.currentVersion) | |
| 388 | const newVersion = this.escapeMarkdownTableValue(update.newVersion) | |
| 389 | const change = `[\`${currentVersion}\` -> \`${newVersion}\`](${diffUrl})` | |
| 388 | 390 | |
| 389 | 391 | // Generate confidence badges with clean package name |
| 390 | 392 | const badges = this.releaseNotesFetcher.generatePackageBadges( |
| @@ -977,6 +979,13 @@ export class PullRequestGenerator { | ||
| 977 | 979 | return `${currentConstraint} -> ${newConstraint}` |
| 978 | 980 | } |
| 979 | 981 | |
| 982 | /** | |
| 983 | * Escape delimiters that would otherwise split a GitHub Markdown table row. | |
| 984 | */ | |
| 985 | private escapeMarkdownTableValue(value: string): string { | |
| 986 | return value.replaceAll('|', '\\|').replaceAll(/\r?\n/g, ' ') | |
| 987 | } | |
| 988 | ||
| 980 | 989 | /** |
| 981 | 990 | * Generate a redirect source URL for Composer packages (like npm) |
| 982 | 991 | */ |
| @@ -139,7 +139,7 @@ export class ReleaseNotesFetcher { | ||
| 139 | 139 | }, |
| 140 | 140 | releaseNotes: [], |
| 141 | 141 | changelog: [], |
| 142 | compareUrl: `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/v${currentVersion}...v${newVersion}`, | |
| 142 | compareUrl: this.generateCompareUrl(repoInfo.owner, repoInfo.repo, currentVersion, newVersion), | |
| 143 | 143 | } |
| 144 | 144 | } |
| 145 | 145 | |
| @@ -435,30 +435,69 @@ export class ReleaseNotesFetcher { | ||
| 435 | 435 | /** |
| 436 | 436 | * Generate compare URL between versions |
| 437 | 437 | */ |
| 438 | private generateCompareUrl(owner: string, repo: string, fromVersion: string, toVersion: string): string { | |
| 439 | const cleanFrom = fromVersion.startsWith('v') ? fromVersion : `v${fromVersion}` | |
| 440 | const cleanTo = toVersion.startsWith('v') ? toVersion : `v${toVersion}` | |
| 441 | return `https://github.com/${owner}/${repo}/compare/${cleanFrom}...${cleanTo}` | |
| 438 | private generateCompareUrl(owner: string, repo: string, fromVersion: string, toVersion: string): string | undefined { | |
| 439 | const cleanFrom = this.getComparableVersion(fromVersion) | |
| 440 | const cleanTo = this.getComparableVersion(toVersion) | |
| 441 | if (!cleanFrom || !cleanTo) | |
| 442 | return undefined | |
| 443 | ||
| 444 | return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(`v${cleanFrom}`)}...${encodeURIComponent(`v${cleanTo}`)}` | |
| 442 | 445 | } |
| 443 | 446 | |
| 444 | 447 | /** |
| 445 | 448 | * Check if version is between current and new version |
| 446 | 449 | */ |
| 447 | 450 | private isVersionBetween(version: string, current: string, target: string): boolean { |
| 448 | const cleanVersion = version.replace(/^v/, '') | |
| 449 | const cleanCurrent = current.replace(/^v/, '') | |
| 450 | const cleanTarget = target.replace(/^v/, '') | |
| 451 | const cleanVersion = this.getComparableVersion(version) | |
| 452 | const cleanCurrent = this.getComparableVersion(current) | |
| 453 | const cleanTarget = this.getComparableVersion(target) | |
| 454 | if (!cleanVersion || !cleanCurrent || !cleanTarget) | |
| 455 | return false | |
| 451 | 456 | |
| 452 | // Proper semver compare — lexicographic ordering gets `1.10.0 > 1.9.0` wrong. | |
| 453 | // Fall back to string equality if either side isn't valid semver. | |
| 454 | 457 | try { |
| 455 | return cleanVersion === cleanTarget || semver.order(cleanVersion, cleanCurrent) > 0 | |
| 458 | return semver.order(cleanVersion, cleanCurrent) > 0 | |
| 459 | && semver.order(cleanVersion, cleanTarget) <= 0 | |
| 456 | 460 | } |
| 457 | 461 | catch { |
| 458 | return cleanVersion === cleanTarget || cleanVersion > cleanCurrent | |
| 462 | return cleanVersion > cleanCurrent && cleanVersion <= cleanTarget | |
| 459 | 463 | } |
| 460 | 464 | } |
| 461 | 465 | |
| 466 | /** | |
| 467 | * Derive a concrete lower-bound version for compare links and ordering. | |
| 468 | * For OR ranges, use the highest alternative's lower bound. | |
| 469 | */ | |
| 470 | private getComparableVersion(version: string): string | null { | |
| 471 | const candidates = version.split('||').flatMap((alternative) => { | |
| 472 | const trimmed = alternative.trim() | |
| 473 | if (trimmed.startsWith('<')) | |
| 474 | return [] | |
| 475 | ||
| 476 | const match = trimmed.match(/^(?:[~^]|>=?|=)?\s*v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Z.-]+)?)/i) | |
| 477 | if (!match) | |
| 478 | return [] | |
| 479 | ||
| 480 | const [core, prerelease] = match[1].split('-', 2) | |
| 481 | const parts = core.split('.') | |
| 482 | while (parts.length < 3) | |
| 483 | parts.push('0') | |
| 484 | ||
| 485 | return [`${parts.join('.')}${prerelease ? `-${prerelease}` : ''}`] | |
| 486 | }) | |
| 487 | ||
| 488 | if (candidates.length === 0) | |
| 489 | return null | |
| 490 | ||
| 491 | return candidates.reduce((highest, candidate) => { | |
| 492 | try { | |
| 493 | return semver.order(candidate, highest) > 0 ? candidate : highest | |
| 494 | } | |
| 495 | catch { | |
| 496 | return candidate > highest ? candidate : highest | |
| 497 | } | |
| 498 | }) | |
| 499 | } | |
| 500 | ||
| 462 | 501 | /** |
| 463 | 502 | * Generate confidence badges and metrics |
| 464 | 503 | */ |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { beforeAll, describe, expect, it } from 'bun:test' | |
| 2 | import { PullRequestGenerator } from '../src/pr/pr-generator' | |
| 3 | import { ReleaseNotesFetcher } from '../src/services/release-notes-fetcher' | |
| 4 | ||
| 5 | describe('PR body version ranges', () => { | |
| 6 | beforeAll(() => { | |
| 7 | process.env.APP_ENV = 'test' | |
| 8 | }) | |
| 9 | ||
| 10 | it('keeps compound npm ranges inside one table cell and URL-encodes links', async () => { | |
| 11 | const generator = new PullRequestGenerator({ | |
| 12 | repository: { | |
| 13 | provider: 'github', | |
| 14 | owner: 'stacksjs', | |
| 15 | name: 'stacks', | |
| 16 | }, | |
| 17 | }) | |
| 18 | ||
| 19 | const body = await generator.generateBody({ | |
| 20 | name: 'Major Update - acorn', | |
| 21 | title: 'chore(deps): update dependency acorn to 8.17.0', | |
| 22 | body: '', | |
| 23 | updateType: 'major', | |
| 24 | updates: [{ | |
| 25 | name: 'acorn', | |
| 26 | currentVersion: '^6.0.0 || ^7.0.0 || ^8.0.0', | |
| 27 | newVersion: '8.17.0', | |
| 28 | updateType: 'major', | |
| 29 | dependencyType: 'peerDependencies', | |
| 30 | file: 'package.json', | |
| 31 | }], | |
| 32 | }) | |
| 33 | ||
| 34 | expect(body).toContain('`^6.0.0 \\|\\| ^7.0.0 \\|\\| ^8.0.0`') | |
| 35 | expect(body).toContain('%5E6.0.0%20%7C%7C%20%5E7.0.0%20%7C%7C%20%5E8.0.0') | |
| 36 | expect(body).toContain('/compare/v8.0.0...v8.17.0') | |
| 37 | expect(body).not.toContain('/compare/v^6.0.0 ||') | |
| 38 | }) | |
| 39 | ||
| 40 | it('bounds release selection to the requested range', () => { | |
| 41 | const fetcher = new ReleaseNotesFetcher() as any | |
| 42 | ||
| 43 | expect(fetcher.isVersionBetween('8.16.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(true) | |
| 44 | expect(fetcher.isVersionBetween('9.0.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(false) | |
| 45 | expect(fetcher.isVersionBetween('8.0.0', '^6.0.0 || ^7.0.0 || ^8.0.0', '8.17.0')).toBe(false) | |
| 46 | }) | |
| 47 | }) | |