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
CHANGELOG.mdmodified+4-3
Changes to CHANGELOG.md
@@ -1,15 +1,16 @@
11# Changelog
2
23[Compare changes](https://github.com/stacksjs/buddy-bot/compare/v0.9.21...v0.9.22)
34
4### ⚡ Performance Improvements
5## ⚡ Performance Improvements
56
67- **build**: drop external sourcemaps from published package ([3fb2223](https://github.com/stacksjs/buddy-bot/commit/3fb2223)) _(by Chris <chrisbreuer93@gmail.com>)_
78
8### 🧹 Chores
9## 🧹 Chores
910
1011- release v0.9.22 ([2c99ecc](https://github.com/stacksjs/buddy-bot/commit/2c99ecc)) _(by Chris <chrisbreuer93@gmail.com>)_
1112
12### Contributors
13## Contributors
1314
1415- _Chris <chrisbreuer93@gmail.com>_
1516
docs/features/github-actions.mdmodified+1-1
Changes to docs/features/github-actions.md
@@ -16,7 +16,7 @@ GitHub Actions integration provides:
1616
1717Buddy automatically detects GitHub Actions in:
1818
19```
19```text
2020.github/
2121└── workflows/
2222 ├── ci.yml
docs/sponsors.mdmodified+1-1
Changes to docs/sponsors.md
@@ -128,4 +128,4 @@ You can also try to convince your employer to sponsor Stacks as a business. This
128128
129129#### Thanks to Vue.js for the inspiration of this sponsorship page
130130
131*You can find their sponsorship page [here](https://vuejs.org/sponsor/).*
131*You can find the [Vue.js sponsorship page](https://vuejs.org/sponsor/).*
src/git/github-provider.tsmodified+27-4
Changes to src/git/github-provider.ts
@@ -3,6 +3,7 @@ import type { FileChange, GitProvider, Issue, IssueOptions, PullRequest, PullReq
33import { Buffer } from 'node:buffer'
44import { spawn } from 'node:child_process'
55import process from 'node:process'
6import { assertUpdateTargetsExist, FileChangeValidationError, normalizeRepositoryPath } from '../utils/file-changes'
67import { detectRequiredPackageManagers, getAllLockFilePaths, regenerateLockFile } from '../utils/lock-file'
78
89// Match GitHub token formats (ghp_*, gho_*, ghs_*, ghu_*, ghr_*, github_pat_*)
@@ -141,7 +142,7 @@ export class GitHubProvider implements GitProvider {
141142 // Lockfile regeneration failures must NOT fall through to the API path —
142143 // the API path can't regenerate lockfiles either, so it would just produce
143144 // a PR with an updated manifest and stale lockfile. Bubble the error up.
144 if (gitError instanceof LockfileRegenerationError)
145 if (gitError instanceof LockfileRegenerationError || gitError instanceof FileChangeValidationError)
145146 throw gitError
146147
147148 console.warn(`⚠️ Git CLI commit failed, falling back to GitHub API: ${gitError}`)
@@ -240,9 +241,19 @@ export class GitHubProvider implements GitProvider {
240241 await this.runCommand('git', ['clean', '-fd'])
241242 console.log(`✅ Reset ${branchName} to origin/${baseBranch}`)
242243
244 await assertUpdateTargetsExist(files, async (cleanPath) => {
245 try {
246 await this.runCommand('git', ['cat-file', '-e', `origin/${baseBranch}:${cleanPath}`])
247 return true
248 }
249 catch {
250 return false
251 }
252 })
253
243254 // Apply file changes
244255 for (const file of files) {
245 const cleanPath = file.path.replace(/^\.\//, '').replace(/^\/+/, '')
256 const cleanPath = normalizeRepositoryPath(file.path)
246257
247258 // Safety check: prevent writing to sensitive files during tests
248259 if (process.env.NODE_ENV === 'test' || process.env.BUN_ENV === 'test') {
@@ -395,11 +406,23 @@ export class GitHubProvider implements GitProvider {
395406 const baseCommit = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/git/commits/${baseSha}`)
396407 const baseTreeSha = baseCommit.tree.sha
397408
409 await assertUpdateTargetsExist(files, async (cleanPath) => {
410 const encodedPath = cleanPath.split('/').map(segment => encodeURIComponent(segment)).join('/')
411 try {
412 await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/contents/${encodedPath}?ref=${encodeURIComponent(baseBranch)}`)
413 return true
414 }
415 catch (error: any) {
416 if (error.message?.includes('404'))
417 return false
418 throw error
419 }
420 })
421
398422 // Create new tree with file changes
399423 const tree = []
400424 for (const file of files) {
401 // Ensure path doesn't start with ./ or have leading slashes (GitHub API requires clean relative paths)
402 const cleanPath = file.path.replace(/^\.\//, '').replace(/^\/+/, '')
425 const cleanPath = normalizeRepositoryPath(file.path)
403426
404427 if (file.type === 'delete') {
405428 tree.push({
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/registry/registry-client.tsmodified+3-18
Changes to src/registry/registry-client.ts
@@ -7,6 +7,7 @@ import path from 'node:path'
77import process from 'node:process'
88import { PackageRegistryError } from '../types'
99import { getUpdateType } from '../utils/helpers'
10import { shouldSkipPackageDirectory } from '../utils/package-paths'
1011
1112export interface BunOutdatedResult {
1213 name: string
@@ -1342,26 +1343,10 @@ export class RegistryClient {
13421343 }
13431344
13441345 /**
1345 * Check if a directory should be skipped during scanning
1346 * Check if a directory is safe to descend into during package discovery.
13461347 */
13471348 private shouldSkipDirectory(dirName: string): boolean {
1348 const skipDirs = [
1349 'node_modules',
1350 '.git',
1351 '.next',
1352 '.nuxt',
1353 'dist',
1354 'build',
1355 'coverage',
1356 '.nyc_output',
1357 'tmp',
1358 'temp',
1359 '.cache',
1360 '.vscode',
1361 '.idea',
1362 ]
1363
1364 return skipDirs.includes(dirName) || dirName.startsWith('.')
1349 return shouldSkipPackageDirectory(dirName)
13651350 }
13661351
13671352 /**
src/scanner/package-scanner.tsmodified+4-26
Changes to src/scanner/package-scanner.ts
@@ -10,6 +10,7 @@ import { BuddyError } from '../types'
1010import { isDependencyFile, parseDependencyFile as parseDepFile } from '../utils/dependency-file-parser'
1111import { isDockerfile, parseDockerfile as parseDockerfileUtil } from '../utils/dockerfile-parser'
1212import { isGitHubActionsFile, parseGitHubActionsFile } from '../utils/github-actions-parser'
13import { shouldSkipPackageDirectory } from '../utils/package-paths'
1314import { parsePantryLockFile } from '../utils/pantry-parser'
1415import { parseZigManifest } from '../utils/zig-parser'
1516
@@ -537,7 +538,7 @@ export class PackageScanner {
537538
538539 if (stats.isDirectory()) {
539540 // Skip node_modules and other common ignored directories
540 if (!this.shouldSkipDirectory(entry)) {
541 if (!shouldSkipPackageDirectory(entry)) {
541542 const subFiles = await this.findFiles(fileName, fullPath)
542543 files.push(...subFiles)
543544 }
@@ -597,7 +598,7 @@ export class PackageScanner {
597598 // Normalize path separators to forward slashes for consistency
598599 files.push(relativePath.split(sep).join('/'))
599600 }
600 else if (stats.isDirectory() && !this.shouldSkipDirectory(entry)) {
601 else if (stats.isDirectory() && !shouldSkipPackageDirectory(entry)) {
601602 // Recursively search subdirectories
602603 const subFiles = await this.findFilesByPatternInDir(pattern, fullPath)
603604 files.push(...subFiles)
@@ -631,7 +632,7 @@ export class PackageScanner {
631632 // Normalize path separators to forward slashes for consistency
632633 files.push(relativePath.split(sep).join('/'))
633634 }
634 else if (stats.isDirectory() && !this.shouldSkipDirectory(entry)) {
635 else if (stats.isDirectory() && !shouldSkipPackageDirectory(entry)) {
635636 const subFiles = await this.findFilesByPatternInDir(pattern, fullPath)
636637 files.push(...subFiles)
637638 }
@@ -670,29 +671,6 @@ export class PackageScanner {
670671 return false
671672 }
672673
673 /**
674 * Check if a directory should be skipped during scanning
675 */
676 private shouldSkipDirectory(dirName: string): boolean {
677 const skipDirs = [
678 'node_modules',
679 '.git',
680 '.next',
681 '.nuxt',
682 'dist',
683 'build',
684 'coverage',
685 '.nyc_output',
686 'tmp',
687 'temp',
688 '.cache',
689 '.vscode',
690 '.idea',
691 ]
692
693 return skipDirs.includes(dirName) || dirName.startsWith('.')
694 }
695
696674 /**
697675 * Get relative path from project root
698676 */
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 */
src/utils/file-changes.tsadded+41-0
Changes to src/utils/file-changes.ts
@@ -0,0 +1,41 @@
1import type { FileChange } from '../types'
2import { posix } from 'node:path'
3
4export class FileChangeValidationError extends Error {
5 constructor(message: string) {
6 super(message)
7 this.name = 'FileChangeValidationError'
8 }
9}
10
11/**
12 * Convert a user or scanner supplied path into a safe repository-relative path.
13 */
14export function normalizeRepositoryPath(filePath: string): string {
15 const normalized = posix.normalize(filePath.replaceAll('\\', '/').replace(/^\.\/+/, ''))
16
17 if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)) {
18 throw new FileChangeValidationError(`Refusing unsafe repository path: ${JSON.stringify(filePath)}`)
19 }
20
21 return normalized
22}
23
24/**
25 * An update must modify a file that already exists on the base branch. Treating
26 * a missing update target as a create is how generated cache paths can leak
27 * into dependency PRs.
28 */
29export async function assertUpdateTargetsExist(
30 files: FileChange[],
31 targetExists: (path: string) => boolean | Promise<boolean>,
32): Promise<void> {
33 for (const file of files) {
34 const cleanPath = normalizeRepositoryPath(file.path)
35 if (file.type === 'update' && !(await targetExists(cleanPath))) {
36 throw new FileChangeValidationError(
37 `Refusing to create missing update target: ${cleanPath}. Use change type "create" for intentional new files.`,
38 )
39 }
40 }
41}
src/utils/package-paths.tsadded+32-0
Changes to src/utils/package-paths.ts
@@ -0,0 +1,32 @@
1const GENERATED_DEPENDENCY_DIRECTORIES = new Set([
2 'bower_components',
3 'node_modules',
4 'pantry',
5 'ts-pantry',
6 'vendor',
7])
8
9const GENERATED_OUTPUT_DIRECTORIES = new Set([
10 '.cache',
11 '.idea',
12 '.next',
13 '.nuxt',
14 '.nyc_output',
15 '.vscode',
16 'build',
17 'coverage',
18 'dist',
19 'temp',
20 'tmp',
21])
22
23/**
24 * Package discovery must not descend into installed dependencies, generated
25 * package-manager trees, or build output. Manifests in those directories are
26 * implementation details, not dependency declarations owned by the project.
27 */
28export function shouldSkipPackageDirectory(dirName: string): boolean {
29 return dirName.startsWith('.')
30 || GENERATED_DEPENDENCY_DIRECTORIES.has(dirName)
31 || GENERATED_OUTPUT_DIRECTORIES.has(dirName)
32}
test/bun-deps.test.tsmodified+0-4
Changes to test/bun-deps.test.ts
@@ -28,14 +28,11 @@ const mockResolveDependencyFile = mock(() => Promise.resolve({
2828
2929describe('Bun deps.yaml Update Tests', () => {
3030 let testDir: string
31 let originalCwd: string
3231 let config: BuddyBotConfig
3332
3433 beforeAll(async () => {
3534 // Create a temporary directory for testing
3635 testDir = await mkdtemp(join(tmpdir(), 'buddy-test-'))
37 originalCwd = process.cwd()
38 process.chdir(testDir)
3936
4037 // Create a basic package.json file
4138 await writeFile(
@@ -95,7 +92,6 @@ describe('Bun deps.yaml Update Tests', () => {
9592
9693 afterAll(async () => {
9794 // Clean up
98 process.chdir(originalCwd)
9995 await rm(testDir, { recursive: true, force: true })
10096 })
10197
test/file-change-validation.test.tsadded+39-0
Changes to test/file-change-validation.test.ts
@@ -0,0 +1,39 @@
1import type { FileChange } from '../src/types'
2import { describe, expect, it } from 'bun:test'
3import {
4 assertUpdateTargetsExist,
5 FileChangeValidationError,
6 normalizeRepositoryPath,
7} from '../src/utils/file-changes'
8
9describe('file change validation', () => {
10 it('normalizes repository-relative paths', () => {
11 expect(normalizeRepositoryPath('./packages\\app/package.json')).toBe('packages/app/package.json')
12 })
13
14 it('rejects paths outside the repository', () => {
15 expect(() => normalizeRepositoryPath('../package.json')).toThrow(FileChangeValidationError)
16 expect(() => normalizeRepositoryPath('/tmp/package.json')).toThrow(FileChangeValidationError)
17 })
18
19 it('allows updates only when the base target exists', async () => {
20 const files: FileChange[] = [
21 { path: 'package.json', content: '{}', type: 'update' },
22 { path: 'generated.json', content: '{}', type: 'create' },
23 ]
24
25 await expect(assertUpdateTargetsExist(files, path => path === 'package.json')).resolves.toBeUndefined()
26 })
27
28 it('rejects an update that would create a missing file', async () => {
29 const files: FileChange[] = [{
30 path: './pantry/install/cache/acorn/package.json',
31 content: '{}',
32 type: 'update',
33 }]
34
35 await expect(assertUpdateTargetsExist(files, () => false)).rejects.toThrow(
36 'Refusing to create missing update target: pantry/install/cache/acorn/package.json',
37 )
38 })
39})
test/generated-dependency-directories.test.tsadded+71-0
Changes to test/generated-dependency-directories.test.ts
@@ -0,0 +1,71 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { RegistryClient } from '../src/registry/registry-client'
6import { PackageScanner } from '../src/scanner/package-scanner'
7import { Logger } from '../src/utils/logger'
8import { shouldSkipPackageDirectory } from '../src/utils/package-paths'
9
10const generatedDirectories = [
11 'bower_components',
12 'node_modules',
13 'pantry',
14 'ts-pantry',
15 'vendor',
16]
17
18describe('generated dependency directory filtering', () => {
19 const temporaryDirectories: string[] = []
20 const logger = Logger.quiet()
21
22 afterEach(async () => {
23 await Promise.all(temporaryDirectories.splice(0).map(directory =>
24 rm(directory, { recursive: true, force: true }),
25 ))
26 })
27
28 it('classifies package-manager install trees as generated', () => {
29 for (const directory of generatedDirectories)
30 expect(shouldSkipPackageDirectory(directory)).toBe(true)
31
32 expect(shouldSkipPackageDirectory('packages')).toBe(false)
33 })
34
35 it('excludes generated manifests from both package discovery paths', async () => {
36 const projectPath = await mkdtemp(join(tmpdir(), 'buddy-generated-dependencies-'))
37 temporaryDirectories.push(projectPath)
38
39 await writePackageJson(projectPath, 'package.json', 'root')
40 await writePackageJson(projectPath, 'packages/app/package.json', 'app')
41
42 for (const directory of generatedDirectories)
43 await writePackageJson(projectPath, `${directory}/cached/package.json`, `generated-${directory}`)
44
45 const scanner = new PackageScanner(projectPath, logger)
46 const scannedPaths = (await scanner.scanProject()).map(file => file.path)
47
48 const registryClient = new RegistryClient(projectPath, logger)
49 const registryPaths = await (registryClient as any).findPackageJsonFiles()
50
51 expect(scannedPaths.filter(path => path.endsWith('package.json')).sort()).toEqual([
52 'package.json',
53 'packages/app/package.json',
54 ])
55 expect(registryPaths.sort()).toEqual([
56 'package.json',
57 'packages/app/package.json',
58 ])
59 })
60})
61
62async function writePackageJson(projectPath: string, relativePath: string, name: string): Promise<void> {
63 const filePath = join(projectPath, relativePath)
64 await mkdir(join(filePath, '..'), { recursive: true })
65 await writeFile(filePath, JSON.stringify({
66 name,
67 dependencies: {
68 acorn: '^8.0.0',
69 },
70 }))
71}
test/ignore-paths-autoclose.test.tsmodified+0-1
Changes to test/ignore-paths-autoclose.test.ts
@@ -13,7 +13,6 @@ describe('IgnorePaths Auto-Close Functionality', () => {
1313
1414 beforeEach(async () => {
1515 testDir = await mkdtemp(join(tmpdir(), 'buddy-ignore-paths-autoclose-'))
16 process.chdir(testDir)
1716 logger = new Logger(false) // Quiet logger for tests
1817 })
1918
test/noop-commit-behavior.test.tsmodified+24-0
Changes to test/noop-commit-behavior.test.ts
@@ -46,6 +46,30 @@ describe('No-op commit prevention', () => {
4646 expect(hasPush).toBeTrue()
4747 })
4848
49 it('does not fall back to an API create when an update target is missing from base', async () => {
50 const prov = new GitHubProvider('token', 'owner', 'repo', true) as any
51 const commands: Array<{ cmd: string, args: string[] }> = []
52 let apiCalled = false
53
54 prov.runCommand = async (command: string, args: string[]) => {
55 commands.push({ cmd: command, args })
56 if (command === 'git' && args[0] === 'cat-file')
57 throw new Error('missing from base')
58 return ''
59 }
60 prov.apiRequest = async () => {
61 apiCalled = true
62 return {}
63 }
64
65 await expect(prov.commitChanges('buddy/test-branch', 'test message', filesChanged)).rejects.toThrow(
66 'Refusing to create missing update target: test-package.json',
67 )
68
69 expect(apiCalled).toBeFalse()
70 expect(commands.some(command => command.args[0] === 'add')).toBeFalse()
71 })
72
4973 it('skips API commit when new tree equals current tree (API fallback path)', async () => {
5074 const prov = new GitHubProvider('token', 'owner', 'repo', true) as any
5175 const apiCalls: Array<{ endpoint: string, data: any }> = []
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})