Skip to content

Commit c6955f4

Browse files
committed
MAJOR ENHANCEMENT: Add support for detecting minor/patch composer updates in addition to major updates
- Enhanced composer outdated detection to find best patch, minor, and major updates per package - Uses 'composer show --available' to get all versions instead of just latest - Now composer packages will appear in non-major PRs when safe updates are available - Fixes the issue where only major updates were detected, missing safer bug fixes
1 parent 97d35ea commit c6955f4

File tree

2 files changed

+133
-25
lines changed

2 files changed

+133
-25
lines changed

src/buddy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export class Buddy {
214214
child.on('error', reject)
215215
})
216216
}
217-
217+
218218
// Reset to clean main state before generating file updates
219219
await runGitCommand('git', ['checkout', 'main'])
220220
await runGitCommand('git', ['reset', '--hard', 'HEAD'])

src/registry/registry-client.ts

Lines changed: 132 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ export class RegistryClient {
588588
this.logger.warn('Failed to read composer.json for dependency type detection:', error)
589589
}
590590

591-
// Parse composer outdated output and filter based on version constraints
591+
// Parse composer outdated output and find multiple update paths per package
592592
if (composerData.installed) {
593593
for (const pkg of composerData.installed) {
594594
if (pkg.name && pkg.version && pkg.latest) {
@@ -601,23 +601,12 @@ export class RegistryClient {
601601
continue // Skip packages not found in composer.json
602602
}
603603

604-
// Include all available updates - let grouping and strategy handle filtering
605-
const newVersion = pkg.latest
606-
607-
const updateType = getUpdateType(pkg.version, newVersion)
608-
609604
// Skip ignored packages
610605
const ignoredPackages = this.config?.packages?.ignore || []
611606
if (ignoredPackages.includes(pkg.name)) {
612607
continue
613608
}
614609

615-
// Check if this is a major update and if major updates are excluded
616-
const excludeMajor = this.config?.packages?.excludeMajor ?? false
617-
if (excludeMajor && updateType === 'major') {
618-
continue
619-
}
620-
621610
// Determine dependency type by checking composer.json
622611
let dependencyType: 'require' | 'require-dev' = 'require'
623612
if (composerJsonData['require-dev'] && composerJsonData['require-dev'][pkg.name]) {
@@ -627,18 +616,48 @@ export class RegistryClient {
627616
// Get additional metadata for the package
628617
const metadata = await this.getComposerPackageMetadata(pkg.name)
629618

630-
updates.push({
631-
name: pkg.name,
632-
currentVersion: pkg.version,
633-
newVersion,
634-
updateType,
635-
dependencyType,
636-
file: 'composer.json',
637-
metadata,
638-
releaseNotesUrl: this.getComposerReleaseNotesUrl(pkg.name, metadata),
639-
changelogUrl: this.getComposerChangelogUrl(pkg.name, metadata),
640-
homepage: metadata?.homepage,
641-
})
619+
// Find multiple update paths: patch, minor, and major
620+
const currentVersion = pkg.version
621+
const latestVersion = pkg.latest
622+
623+
// Get all available versions by querying composer show
624+
let availableVersions: string[] = []
625+
try {
626+
const showOutput = await this.runCommand('composer', ['show', pkg.name, '--available', '--format=json'])
627+
const showData = JSON.parse(showOutput)
628+
if (showData.versions) {
629+
availableVersions = Object.keys(showData.versions)
630+
}
631+
} catch (error) {
632+
console.warn(`Failed to get available versions for ${pkg.name}, using latest only:`, error)
633+
availableVersions = [latestVersion]
634+
}
635+
636+
// Find the best update for each type (patch, minor, major)
637+
const updateCandidates = await this.findBestUpdates(currentVersion, availableVersions, constraint)
638+
639+
for (const candidate of updateCandidates) {
640+
const updateType = getUpdateType(currentVersion, candidate.version)
641+
642+
// Check if this update type should be excluded
643+
const excludeMajor = this.config?.packages?.excludeMajor ?? false
644+
if (excludeMajor && updateType === 'major') {
645+
continue
646+
}
647+
648+
updates.push({
649+
name: pkg.name,
650+
currentVersion,
651+
newVersion: candidate.version,
652+
updateType,
653+
dependencyType,
654+
file: 'composer.json',
655+
metadata,
656+
releaseNotesUrl: this.getComposerReleaseNotesUrl(pkg.name, metadata),
657+
changelogUrl: this.getComposerChangelogUrl(pkg.name, metadata),
658+
homepage: metadata?.homepage,
659+
})
660+
}
642661
}
643662
}
644663
}
@@ -652,6 +671,95 @@ export class RegistryClient {
652671
}
653672
}
654673

674+
/**
675+
* Find the best patch, minor, and major updates for a package
676+
*/
677+
private async findBestUpdates(currentVersion: string, availableVersions: string[], constraint: string): Promise<{ version: string, type: 'patch' | 'minor' | 'major' }[]> {
678+
const { getUpdateType } = await import('../utils/helpers')
679+
const candidates: { version: string, type: 'patch' | 'minor' | 'major' }[] = []
680+
681+
// Parse current version
682+
const currentParts = this.parseVersion(currentVersion)
683+
if (!currentParts) return []
684+
685+
let bestPatch: string | null = null
686+
let bestMinor: string | null = null
687+
let bestMajor: string | null = null
688+
689+
for (const version of availableVersions) {
690+
// Skip dev/alpha/beta versions for now (could be enhanced later)
691+
if (version.includes('dev') || version.includes('alpha') || version.includes('beta') || version.includes('RC')) {
692+
continue
693+
}
694+
695+
const versionParts = this.parseVersion(version)
696+
if (!versionParts) continue
697+
698+
// Skip versions that are not newer
699+
if (this.compareVersions(version, currentVersion) <= 0) {
700+
continue
701+
}
702+
703+
const updateType = getUpdateType(currentVersion, version)
704+
705+
// Find best update for each type
706+
if (updateType === 'patch' && versionParts.major === currentParts.major && versionParts.minor === currentParts.minor) {
707+
if (!bestPatch || this.compareVersions(version, bestPatch) > 0) {
708+
bestPatch = version
709+
}
710+
} else if (updateType === 'minor' && versionParts.major === currentParts.major) {
711+
if (!bestMinor || this.compareVersions(version, bestMinor) > 0) {
712+
bestMinor = version
713+
}
714+
} else if (updateType === 'major') {
715+
if (!bestMajor || this.compareVersions(version, bestMajor) > 0) {
716+
bestMajor = version
717+
}
718+
}
719+
}
720+
721+
// Add the best candidates
722+
if (bestPatch) candidates.push({ version: bestPatch, type: 'patch' })
723+
if (bestMinor) candidates.push({ version: bestMinor, type: 'minor' })
724+
if (bestMajor) candidates.push({ version: bestMajor, type: 'major' })
725+
726+
return candidates
727+
}
728+
729+
/**
730+
* Parse a version string into major.minor.patch
731+
*/
732+
private parseVersion(version: string): { major: number, minor: number, patch: number } | null {
733+
// Remove 'v' prefix and any pre-release identifiers
734+
const cleanVersion = version.replace(/^v/, '').split('-')[0].split('+')[0]
735+
const parts = cleanVersion.split('.').map(p => parseInt(p, 10))
736+
737+
if (parts.length < 2 || parts.some(p => isNaN(p))) {
738+
return null
739+
}
740+
741+
return {
742+
major: parts[0] || 0,
743+
minor: parts[1] || 0,
744+
patch: parts[2] || 0
745+
}
746+
}
747+
748+
/**
749+
* Compare two version strings
750+
* Returns: -1 if a < b, 0 if a === b, 1 if a > b
751+
*/
752+
private compareVersions(a: string, b: string): number {
753+
const parseA = this.parseVersion(a)
754+
const parseB = this.parseVersion(b)
755+
756+
if (!parseA || !parseB) return 0
757+
758+
if (parseA.major !== parseB.major) return parseA.major - parseB.major
759+
if (parseA.minor !== parseB.minor) return parseA.minor - parseB.minor
760+
return parseA.patch - parseB.patch
761+
}
762+
655763
/**
656764
* Get Composer package metadata from Packagist
657765
*/

0 commit comments

Comments
 (0)