ReviewOS

also looking at this

stacks/buddy

refactor: one update-type classifier, with the edge cases decided instead of accidental

#1439
Merged glennmichael123 wants to merge refactor/one-update-type into main
4 files +108 -116
src/utils/helpers.tsmodified+46-43
Changes to src/utils/helpers.ts
@@ -417,12 +417,21 @@ export function sortUpdatesByPriority(
417417}
418418
419419/**
420 * Parse version string and determine update type using Bun's semver
420 * Parse a version pair and classify the update, using Bun's semver.
421 *
422 * The one implementation of update-type classification. `Buddy` and
423 * `VersionResolver` used to carry private variants, and the three disagreed
424 * on exactly the inputs that matter: short actions tags ('v4'), v-prefixes,
425 * suffixed Docker tags and prereleases.
426 *
427 * Non-semver input ('latest', 'workspace:*', branch names) and downgrades
428 * deliberately degrade to 'patch' — the least-alarming label for a change
429 * that cannot be reasoned about.
421430 */
422431export function getUpdateType(currentVersion: string, newVersion: string): 'major' | 'minor' | 'patch' {
423432 // Remove any prefixes like ^, ~, >=, v, @, etc.
424 const cleanCurrent = currentVersion.replace(/^[v^~>=<@]+/, '')
425 const cleanNew = newVersion.replace(/^[v^~>=<@]+/, '')
433 const cleanCurrent = padNumericVersion(currentVersion.replace(/^[v^~>=<@]+/, ''))
434 const cleanNew = padNumericVersion(newVersion.replace(/^[v^~>=<@]+/, ''))
426435
427436 // If not an upgrade or equal, treat as patch (no-op or bugfix)
428437 try {
@@ -434,52 +443,46 @@ export function getUpdateType(currentVersion: string, newVersion: string): 'majo
434443 return 'patch'
435444 }
436445
437 try {
438 // Parse version parts manually for more reliable comparison
439 const currentParts = cleanCurrent.split('.').map(n => Number.parseInt(n, 10))
440 const newParts = cleanNew.split('.').map(n => Number.parseInt(n, 10))
446 // Parse parts manually: parseInt's truncation at the first non-digit is
447 // what lets a suffixed Docker tag ('3.19-alpine') classify by its numeric
448 // core rather than collapsing to 'patch'.
449 const currentParts = cleanCurrent.split('.').map(n => Number.parseInt(n, 10))
450 const newParts = cleanNew.split('.').map(n => Number.parseInt(n, 10))
441451
442 // Ensure we have at least 3 parts (major.minor.patch)
443 while (currentParts.length < 3) currentParts.push(0)
444 while (newParts.length < 3) newParts.push(0)
452 // Ensure we have at least 3 parts (major.minor.patch)
453 while (currentParts.length < 3) currentParts.push(0)
454 while (newParts.length < 3) newParts.push(0)
445455
446 const [currentMajor, currentMinor, currentPatch] = currentParts
447 const [newMajor, newMinor, newPatch] = newParts
448
449 // Major version change
450 if (newMajor > currentMajor) {
451 return 'major'
452 }
456 const [currentMajor, currentMinor] = currentParts
457 const [newMajor, newMinor] = newParts
453458
454 // Minor version change (same major, higher minor)
455 if (newMajor === currentMajor && newMinor > currentMinor) {
456 return 'minor'
457 }
459 if (newMajor > currentMajor)
460 return 'major'
458461
459 // Patch version change (same major and minor, higher patch)
460 if (newMajor === currentMajor && newMinor === currentMinor && newPatch > currentPatch) {
461 return 'patch'
462 }
463 }
464 catch {
465 // Fallback to semver satisfaction checks if parsing fails
466 try {
467 // Patch: within same minor series
468 if (Bun.semver.satisfies(cleanNew, `~${cleanCurrent}`))
469 return 'patch'
470 }
471 catch {}
462 if (newMajor === currentMajor && newMinor > currentMinor)
463 return 'minor'
472464
473 try {
474 // Minor: within same major series
475 if (Bun.semver.satisfies(cleanNew, `^${cleanCurrent}`))
476 return 'minor'
477 }
478 catch {}
479 }
465 // The guard above confirmed an upgrade with major and minor unchanged: a
466 // patch bump, or a prerelease moving under an unchanged triple
467 // (1.0.0-beta.1 → 1.0.0). This used to fall through to 'major', which let
468 // an excludeMajor filter block a beta graduation as a breaking change.
469 return 'patch'
470}
480471
481 // Otherwise major
482 return 'major'
472/**
473 * Pad a purely numeric short version to major.minor.patch.
474 *
475 * GitHub Actions tags are routinely 'v4', and Bun.semver.order('4.2.2', '4')
476 * is -1 — without padding, the upgrade guard misreads a real bump as a
477 * downgrade. Restricted to all-numeric parts so suffixed tags like
478 * '3.19-alpine' pass through untouched.
479 */
480function padNumericVersion(version: string): string {
481 const parts = version.split('.')
482 if (parts.length >= 3 || !parts.every(part => /^\d+$/.test(part)))
483 return version
484 while (parts.length < 3) parts.push('0')
485 return parts.join('.')
483486}
484487
485488/**