ReviewOS

also looking at this

stacks/buddy

fix: prevent generated dependency cache PRs

#1366
Merged chris wants to merge codex/prevent-generated-pantry-prs into main
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.

src/pr/pr-generator.tsmodified+11-2
Changes to src/pr/pr-generator.ts
@@ -383,8 +383,10 @@ export class PullRequestGenerator {
383383 }
384384
385385 // 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})`
388390
389391 // Generate confidence badges with clean package name
390392 const badges = this.releaseNotesFetcher.generatePackageBadges(
@@ -977,6 +979,13 @@ export class PullRequestGenerator {
977979 return `${currentConstraint} -> ${newConstraint}`
978980 }
979981
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
980989 /**
981990 * Generate a redirect source URL for Composer packages (like npm)
982991 */
src/services/release-notes-fetcher.tsmodified+51-12
Changes to src/services/release-notes-fetcher.ts
@@ -139,7 +139,7 @@ export class ReleaseNotesFetcher {
139139 },
140140 releaseNotes: [],
141141 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),
143143 }
144144 }
145145
@@ -435,30 +435,69 @@ export class ReleaseNotesFetcher {
435435 /**
436436 * Generate compare URL between versions
437437 */
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}`)}`
442445 }
443446
444447 /**
445448 * Check if version is between current and new version
446449 */
447450 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
451456
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.
454457 try {
455 return cleanVersion === cleanTarget || semver.order(cleanVersion, cleanCurrent) > 0
458 return semver.order(cleanVersion, cleanCurrent) > 0
459 && semver.order(cleanVersion, cleanTarget) <= 0
456460 }
457461 catch {
458 return cleanVersion === cleanTarget || cleanVersion > cleanCurrent
462 return cleanVersion > cleanCurrent && cleanVersion <= cleanTarget
459463 }
460464 }
461465
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
462501 /**
463502 * Generate confidence badges and metrics
464503 */
test/pr-body-version-ranges.test.tsadded+47-0
Changes to test/pr-body-version-ranges.test.ts
@@ -0,0 +1,47 @@
1import { beforeAll, describe, expect, it } from 'bun:test'
2import { PullRequestGenerator } from '../src/pr/pr-generator'
3import { ReleaseNotesFetcher } from '../src/services/release-notes-fetcher'
4
5describe('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})