Skip to content

Commit 91e5d18

Browse files
committed
feat: add Minimum Release Age options
1 parent ec6c964 commit 91e5d18

File tree

8 files changed

+826
-6
lines changed

8 files changed

+826
-6
lines changed

bun.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@stacksjs/docs": "^0.70.23",
1616
"@stacksjs/eslint-config": "^4.14.0-beta.3",
1717
"@stacksjs/gitlint": "^0.1.5",
18-
"@types/bun": "^1.2.21",
18+
"@types/bun": "^1.2.22",
1919
"bun-git-hooks": "^0.2.19",
2020
"bun-plugin-dtsx": "0.9.5",
2121
"typescript": "^5.9.2",
@@ -500,7 +500,7 @@
500500

501501
"@sindresorhus/is": ["@sindresorhus/[email protected]", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
502502

503-
"@stacksjs/bumpx": ["@stacksjs/[email protected].78", "", { "dependencies": { "@stacksjs/clapp": "^0.1.16", "@stacksjs/logsmith": "^0.1.15", "bunfig": "^0.14.1" }, "bin": { "bumpx": "dist/bin/cli.js" } }, "sha512-lLPd3vQSk3H6s60s7TtJiopmykVCJ0ZSU2C5zRPaHDbdL7XZv1+PBCPOMDxfO04xOZsX3NmCZumHz511kVqFqg=="],
503+
"@stacksjs/bumpx": ["@stacksjs/[email protected].84", "", { "dependencies": { "@stacksjs/clapp": "^0.1.16", "@stacksjs/logsmith": "^0.1.15", "bunfig": "^0.14.1" }, "bin": { "bumpx": "dist/bin/cli.js" } }, "sha512-Mniy85XvWhjbQ94UwGo0781ERlCqZDQ5w9Lj1Qgd1T21tK+MTRaCDTYeyrmkz/Xqvn97TJmBSxRg07VkS4Z8VA=="],
504504

505505
"@stacksjs/clapp": ["@stacksjs/[email protected]", "", { "bin": { "clapp": "dist/bin/cli.js", "@stacksjs/clapp": "dist/bin/cli.js" } }, "sha512-BDmYu9Rk/HHIVc4vQjgQO6MzXNMJvPG6ZGiiEAPQT8EAidx3/6S6O7kyY2UdfJSksiCd5SKFK+WYd1uAs88BrQ=="],
506506

@@ -532,7 +532,7 @@
532532

533533
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
534534

535-
"@types/bun": ["@types/[email protected].21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
535+
"@types/bun": ["@types/[email protected].22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
536536

537537
"@types/cacheable-request": ["@types/[email protected]", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
538538

@@ -802,7 +802,7 @@
802802

803803
"bun-plugin-dtsx": ["[email protected]", "", { "dependencies": { "@stacksjs/dtsx": "0.9.4" } }, "sha512-PMGr8kna2C7rbN5NFKW+nqj8TyXjs05Yh2QM7Xjp9PN1/cJMyZML3JJAJT0Ne/6eOYCcubmLM91r+Rix/cqn8Q=="],
804804

805-
"bun-types": ["[email protected].21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
805+
"bun-types": ["[email protected].22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
806806

807807
"bunfig": ["[email protected]", "", { "bin": { "bunfig": "bin/cli.js" } }, "sha512-KxblKbteHmlDgbEv6L9AYghcU+6mpoJhbmNa1cbfn1LuS99+1UGAcTG1u4u4zcjT4JHVff5cblSKjZmOw5+I7w=="],
808808

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@
6666
"ts-pkgx": "0.4.52"
6767
},
6868
"devDependencies": {
69-
"@stacksjs/bumpx": "^0.1.78",
69+
"@stacksjs/bumpx": "^0.1.84",
7070
"@stacksjs/docs": "^0.70.23",
7171
"@stacksjs/eslint-config": "^4.14.0-beta.3",
7272
"@stacksjs/gitlint": "^0.1.5",
73-
"@types/bun": "^1.2.21",
73+
"@types/bun": "^1.2.22",
7474
"bun-git-hooks": "^0.2.19",
7575
"bun-plugin-dtsx": "0.9.5",
7676
"typescript": "^5.9.2"

src/buddy.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export class Buddy {
8282
updates = this.filterUpdatesByStrategy(updates, this.config.packages.strategy)
8383
}
8484

85+
// Apply minimum release age filtering
86+
updates = await this.filterUpdatesByMinimumReleaseAge(updates)
87+
8588
// Sort updates by priority
8689
updates = sortUpdatesByPriority(updates)
8790

@@ -945,6 +948,49 @@ export class Buddy {
945948
})
946949
}
947950

951+
/**
952+
* Filter updates by minimum release age requirement
953+
*/
954+
private async filterUpdatesByMinimumReleaseAge(updates: PackageUpdate[]): Promise<PackageUpdate[]> {
955+
const minimumReleaseAge = this.config.packages?.minimumReleaseAge ?? 0
956+
957+
// If no minimum age is set, return all updates
958+
if (minimumReleaseAge === 0) {
959+
return updates
960+
}
961+
962+
this.logger.info(`Applying minimum release age filter (${minimumReleaseAge} minutes)...`)
963+
964+
const filteredUpdates: PackageUpdate[] = []
965+
966+
for (const update of updates) {
967+
try {
968+
const meetsRequirement = await this.registryClient.meetsMinimumReleaseAge(
969+
update.name,
970+
update.newVersion,
971+
update.dependencyType
972+
)
973+
974+
if (meetsRequirement) {
975+
filteredUpdates.push(update)
976+
} else {
977+
this.logger.debug(`Filtered out ${update.name}@${update.newVersion} (${update.dependencyType}) due to minimum release age requirement`)
978+
}
979+
} catch (error) {
980+
// If there's an error checking the release age, be conservative and include the update
981+
this.logger.warn(`Error checking release age for ${update.name}@${update.newVersion} (${update.dependencyType}), including update:`, error)
982+
filteredUpdates.push(update)
983+
}
984+
}
985+
986+
const filteredCount = updates.length - filteredUpdates.length
987+
if (filteredCount > 0) {
988+
this.logger.info(`Filtered out ${filteredCount} updates due to minimum release age requirement`)
989+
}
990+
991+
return filteredUpdates
992+
}
993+
948994
/**
949995
* Group updates based on configuration
950996
*/
@@ -978,6 +1024,9 @@ export class Buddy {
9781024
filteredUpdates = this.filterUpdatesByStrategy(groupUpdates, groupConfig.strategy)
9791025
}
9801026

1027+
// Note: Minimum release age filtering is already applied globally before grouping,
1028+
// so we don't need to apply it again here
1029+
9811030
groups.push({
9821031
name: groupConfig.name,
9831032
updates: filteredUpdates,

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const defaultConfig: BuddyBotConfig = {
3232
includePrerelease: false,
3333
excludeMajor: false,
3434
respectLatest: true,
35+
minimumReleaseAge: 0,
36+
minimumReleaseAgeExclude: [],
3537
},
3638
}
3739

src/registry/registry-client.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,175 @@ export class RegistryClient {
13141314
return null
13151315
}
13161316

1317+
/**
1318+
* Get the release date of a specific package version from registry
1319+
*/
1320+
async getPackageVersionReleaseDate(packageName: string, version: string): Promise<Date | null> {
1321+
try {
1322+
// First try using bun info to get package metadata
1323+
const result = await this.runCommand('bun', ['info', `${packageName}@${version}`, '--json'])
1324+
const data = JSON.parse(result)
1325+
1326+
// Check if the package data has time information
1327+
if (data.time && typeof data.time === 'string') {
1328+
return new Date(data.time)
1329+
}
1330+
1331+
// If bun doesn't provide time info, fall back to npm registry API
1332+
if (!data.time) {
1333+
try {
1334+
const npmResult = await this.runCommand('npm', ['view', `${packageName}@${version}`, 'time', '--json'])
1335+
const timeData = JSON.parse(npmResult)
1336+
1337+
if (timeData && typeof timeData === 'string') {
1338+
return new Date(timeData)
1339+
}
1340+
}
1341+
catch (npmError) {
1342+
this.logger.debug(`npm fallback failed for ${packageName}@${version}:`, npmError)
1343+
}
1344+
}
1345+
1346+
return null
1347+
}
1348+
catch (error) {
1349+
this.logger.debug(`Failed to get release date for ${packageName}@${version}:`, error)
1350+
return null
1351+
}
1352+
}
1353+
1354+
/**
1355+
* Get the release date for GitHub Actions
1356+
*/
1357+
async getGitHubActionReleaseDate(actionName: string, version: string): Promise<Date | null> {
1358+
try {
1359+
// GitHub Actions use GitHub releases API
1360+
// Format: owner/repo@version
1361+
const [owner, repo] = actionName.split('/')
1362+
if (!owner || !repo) {
1363+
return null
1364+
}
1365+
1366+
// Use GitHub API to get release information
1367+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`
1368+
1369+
// Use curl to fetch the release data
1370+
const result = await this.runCommand('curl', ['-s', '-H', 'Accept: application/vnd.github.v3+json', apiUrl])
1371+
const releaseData = JSON.parse(result)
1372+
1373+
if (releaseData.published_at) {
1374+
return new Date(releaseData.published_at)
1375+
}
1376+
1377+
return null
1378+
}
1379+
catch (error) {
1380+
this.logger.debug(`Failed to get GitHub Action release date for ${actionName}@${version}:`, error)
1381+
return null
1382+
}
1383+
}
1384+
1385+
/**
1386+
* Get the release date for Composer packages
1387+
*/
1388+
async getComposerPackageReleaseDate(packageName: string, version: string): Promise<Date | null> {
1389+
try {
1390+
// Use Packagist API for Composer packages
1391+
const apiUrl = `https://packagist.org/packages/${packageName}.json`
1392+
1393+
const result = await this.runCommand('curl', ['-s', apiUrl])
1394+
const packageData = JSON.parse(result)
1395+
1396+
if (packageData.package && packageData.package.versions && packageData.package.versions[version]) {
1397+
const versionData = packageData.package.versions[version]
1398+
if (versionData.time) {
1399+
return new Date(versionData.time)
1400+
}
1401+
}
1402+
1403+
return null
1404+
}
1405+
catch (error) {
1406+
this.logger.debug(`Failed to get Composer package release date for ${packageName}@${version}:`, error)
1407+
return null
1408+
}
1409+
}
1410+
1411+
/**
1412+
* Get the release date for Docker images
1413+
*/
1414+
async getDockerImageReleaseDate(imageName: string, version: string): Promise<Date | null> {
1415+
try {
1416+
// For Docker Hub images, we can use the Docker Hub API
1417+
// This is more complex as it requires authentication for some images
1418+
// For now, we'll be conservative and allow Docker image updates
1419+
this.logger.debug(`Docker image release date checking not fully implemented for ${imageName}:${version}`)
1420+
return null
1421+
}
1422+
catch (error) {
1423+
this.logger.debug(`Failed to get Docker image release date for ${imageName}:${version}:`, error)
1424+
return null
1425+
}
1426+
}
1427+
1428+
/**
1429+
* Check if a package version meets the minimum release age requirement
1430+
*/
1431+
async meetsMinimumReleaseAge(packageName: string, version: string, dependencyType?: string): Promise<boolean> {
1432+
const minimumReleaseAge = this.config?.packages?.minimumReleaseAge ?? 0
1433+
const excludeList = this.config?.packages?.minimumReleaseAgeExclude ?? []
1434+
1435+
// If no minimum age is set, allow all packages
1436+
if (minimumReleaseAge === 0) {
1437+
return true
1438+
}
1439+
1440+
// If package is in exclude list, allow it immediately
1441+
if (excludeList.includes(packageName)) {
1442+
this.logger.debug(`Package ${packageName} is excluded from minimum release age requirement`)
1443+
return true
1444+
}
1445+
1446+
// Get the release date based on dependency type
1447+
let releaseDate: Date | null = null
1448+
1449+
switch (dependencyType) {
1450+
case 'github-actions':
1451+
releaseDate = await this.getGitHubActionReleaseDate(packageName, version)
1452+
break
1453+
case 'require':
1454+
case 'require-dev':
1455+
releaseDate = await this.getComposerPackageReleaseDate(packageName, version)
1456+
break
1457+
case 'docker-image':
1458+
releaseDate = await this.getDockerImageReleaseDate(packageName, version)
1459+
break
1460+
default:
1461+
// For npm/bun packages (dependencies, devDependencies, etc.)
1462+
releaseDate = await this.getPackageVersionReleaseDate(packageName, version)
1463+
break
1464+
}
1465+
1466+
if (!releaseDate) {
1467+
// If we can't get the release date, be conservative and allow the update
1468+
// This prevents blocking updates when registry data is unavailable
1469+
this.logger.warn(`Could not determine release date for ${packageName}@${version} (${dependencyType || 'unknown type'}), allowing update`)
1470+
return true
1471+
}
1472+
1473+
// Calculate age in minutes
1474+
const now = new Date()
1475+
const ageInMinutes = (now.getTime() - releaseDate.getTime()) / (1000 * 60)
1476+
1477+
const meetsRequirement = ageInMinutes >= minimumReleaseAge
1478+
1479+
if (!meetsRequirement) {
1480+
this.logger.info(`Package ${packageName}@${version} (${dependencyType || 'unknown type'}) is too new (${Math.round(ageInMinutes)} minutes old, minimum: ${minimumReleaseAge} minutes)`)
1481+
}
1482+
1483+
return meetsRequirement
1484+
}
1485+
13171486
/**
13181487
* Run bun outdated for a specific workspace
13191488
*/

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export interface BuddyBotConfig {
4343
excludeMajor?: boolean
4444
/** Respect "latest" and "*" version indicators (default: true) */
4545
respectLatest?: boolean
46+
/** Minimum age in minutes that a package version must have before installation (default: 0) */
47+
minimumReleaseAge?: number
48+
/** Package names to exclude from minimum release age requirement */
49+
minimumReleaseAgeExclude?: string[]
4650
}
4751

4852
/** PR generation settings */

0 commit comments

Comments
 (0)