@@ -50,6 +50,139 @@ describe('Releases Script Utility Functions', () => {
5050 return data
5151 }
5252
53+ /**
54+ * Calculate relative time string (e.g., "3+ years ago", "2 months ago")
55+ */
56+ function getRelativeTime ( publishedAt ) {
57+ const now = new Date ( )
58+ const published = new Date ( publishedAt )
59+ const diffInMs = now - published
60+ const diffInDays = Math . floor ( diffInMs / ( 1000 * 60 * 60 * 24 ) )
61+ const diffInMonths = Math . floor ( diffInDays / 30 )
62+ const diffInYears = Math . floor ( diffInDays / 365 )
63+
64+ if ( diffInYears >= 1 ) {
65+ return `${ diffInYears } + year${ diffInYears > 1 ? 's' : '' } ago`
66+ } else if ( diffInMonths >= 1 ) {
67+ return `${ diffInMonths } + month${ diffInMonths > 1 ? 's' : '' } ago`
68+ } else if ( diffInDays >= 7 ) {
69+ const weeks = Math . floor ( diffInDays / 7 )
70+ return `${ weeks } + week${ weeks > 1 ? 's' : '' } ago`
71+ } else if ( diffInDays >= 1 ) {
72+ return `${ diffInDays } + day${ diffInDays > 1 ? 's' : '' } ago`
73+ } else {
74+ return 'today'
75+ }
76+ }
77+
78+ /**
79+ * Check if a version is a pre-release version (alpha, beta, rc, etc.)
80+ */
81+ function isPreRelease ( version ) {
82+ return / - ( a l p h a | b e t a | r c | p r e | d e v | s n a p s h o t ) / i. test ( version )
83+ }
84+
85+ /**
86+ * Compare two version strings for sorting (semantic versioning)
87+ */
88+ function compareVersions ( a , b ) {
89+ // Remove pre-release identifiers for comparison
90+ const cleanA = a . replace ( / - .* $ / , '' )
91+ const cleanB = b . replace ( / - .* $ / , '' )
92+
93+ const partsA = cleanA . split ( '.' ) . map ( Number )
94+ const partsB = cleanB . split ( '.' ) . map ( Number )
95+
96+ // Normalize to same length by padding with zeros
97+ const maxLength = Math . max ( partsA . length , partsB . length )
98+ while ( partsA . length < maxLength ) partsA . push ( 0 )
99+ while ( partsB . length < maxLength ) partsB . push ( 0 )
100+
101+ for ( let i = 0 ; i < maxLength ; i ++ ) {
102+ if ( partsA [ i ] !== partsB [ i ] ) {
103+ return partsB [ i ] - partsA [ i ] // Descending order (newest first)
104+ }
105+ }
106+
107+ // If versions are equal, put pre-release after stable
108+ const aIsPre = isPreRelease ( a )
109+ const bIsPre = isPreRelease ( b )
110+
111+ if ( aIsPre && ! bIsPre ) return 1
112+ if ( ! aIsPre && bIsPre ) return - 1
113+
114+ return 0
115+ }
116+
117+ /**
118+ * Identify releases that should be pruned based on heuristics
119+ */
120+ function identifyReleasesToPrune ( releases ) {
121+ if ( releases . length <= 3 ) {
122+ return [ ] // Keep at least 3 releases minimum
123+ }
124+
125+ const now = new Date ( )
126+ const sixMonthsAgo = new Date ( now . getTime ( ) - 6 * 30 * 24 * 60 * 60 * 1000 )
127+ const twoYearsAgo = new Date ( now . getTime ( ) - 2 * 365 * 24 * 60 * 60 * 1000 )
128+
129+ // Sort releases by version (newest first)
130+ const sortedReleases = [ ...releases ] . sort ( ( a , b ) => compareVersions ( a . version , b . version ) )
131+
132+ const toPrune = [ ]
133+ const stableReleases = sortedReleases . filter ( ( r ) => ! isPreRelease ( r . version ) )
134+ const preReleaseReleases = sortedReleases . filter ( ( r ) => isPreRelease ( r . version ) )
135+
136+ // Keep the latest stable release
137+ const latestStable = stableReleases [ 0 ]
138+
139+ // Keep releases from the last 6 months
140+ const recentReleases = releases . filter ( ( r ) => new Date ( r . publishedAt ) >= sixMonthsAgo )
141+
142+ // Keep the latest 2-3 pre-release versions if they're recent
143+ const recentPreReleases = preReleaseReleases . filter ( ( r ) => new Date ( r . publishedAt ) >= sixMonthsAgo ) . slice ( 0 , 3 )
144+
145+ // Identify releases to prune
146+ for ( const release of releases ) {
147+ const isRecent = new Date ( release . publishedAt ) >= sixMonthsAgo
148+ const isOld = new Date ( release . publishedAt ) < twoYearsAgo
149+ const isLatestStable = release === latestStable
150+ const isRecentPreRelease = recentPreReleases . includes ( release )
151+
152+ // Prune if:
153+ // 1. It's old (2+ years) AND not the latest stable
154+ // 2. It's a pre-release that's not recent and we have more than 5 pre-releases
155+ // 3. It's not recent and not the latest stable and we have more than 5 total releases
156+
157+ if ( isOld && ! isLatestStable ) {
158+ toPrune . push ( release )
159+ } else if ( isPreRelease ( release . version ) && ! isRecentPreRelease && preReleaseReleases . length > 5 ) {
160+ toPrune . push ( release )
161+ } else if ( ! isRecent && ! isLatestStable && releases . length > 5 ) {
162+ toPrune . push ( release )
163+ }
164+ }
165+
166+ return toPrune
167+ }
168+
169+ /**
170+ * Generate prune commands for identified releases
171+ */
172+ function generatePruneCommands ( releasesToPrune ) {
173+ if ( releasesToPrune . length === 0 ) {
174+ return 'No releases recommended for pruning.'
175+ }
176+
177+ const commands = releasesToPrune . map ( ( release ) => `gh release delete "${ release . tag } " -y` )
178+
179+ if ( releasesToPrune . length === 1 ) {
180+ return `Recommended prune command:\n${ commands [ 0 ] } `
181+ }
182+
183+ return `Recommended prune commands:\n${ commands . join ( '\n' ) } \n\nTo prune all at once:\n${ commands . join ( ' && ' ) } `
184+ }
185+
53186 /**
54187 * Ensure the version being released is new and not a duplicate
55188 */
@@ -217,6 +350,197 @@ describe('Releases Script Utility Functions', () => {
217350 } )
218351 } )
219352
353+ describe ( 'getRelativeTime' , ( ) => {
354+ test ( 'should return "today" for recent dates' , ( ) => {
355+ const today = new Date ( ) . toISOString ( )
356+ expect ( getRelativeTime ( today ) ) . toBe ( 'today' )
357+ } )
358+
359+ test ( 'should return days ago for recent dates' , ( ) => {
360+ const yesterday = new Date ( Date . now ( ) - 24 * 60 * 60 * 1000 ) . toISOString ( )
361+ expect ( getRelativeTime ( yesterday ) ) . toBe ( '1+ day ago' )
362+
363+ const threeDaysAgo = new Date ( Date . now ( ) - 3 * 24 * 60 * 60 * 1000 ) . toISOString ( )
364+ expect ( getRelativeTime ( threeDaysAgo ) ) . toBe ( '3+ days ago' )
365+ } )
366+
367+ test ( 'should return weeks ago for recent dates' , ( ) => {
368+ const oneWeekAgo = new Date ( Date . now ( ) - 7 * 24 * 60 * 60 * 1000 ) . toISOString ( )
369+ expect ( getRelativeTime ( oneWeekAgo ) ) . toBe ( '1+ week ago' )
370+
371+ const twoWeeksAgo = new Date ( Date . now ( ) - 14 * 24 * 60 * 60 * 1000 ) . toISOString ( )
372+ expect ( getRelativeTime ( twoWeeksAgo ) ) . toBe ( '2+ weeks ago' )
373+ } )
374+
375+ test ( 'should return months ago for older dates' , ( ) => {
376+ const oneMonthAgo = new Date ( Date . now ( ) - 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
377+ expect ( getRelativeTime ( oneMonthAgo ) ) . toBe ( '1+ month ago' )
378+
379+ const threeMonthsAgo = new Date ( Date . now ( ) - 90 * 24 * 60 * 60 * 1000 ) . toISOString ( )
380+ expect ( getRelativeTime ( threeMonthsAgo ) ) . toBe ( '3+ months ago' )
381+ } )
382+
383+ test ( 'should return years ago for old dates' , ( ) => {
384+ const oneYearAgo = new Date ( Date . now ( ) - 365 * 24 * 60 * 60 * 1000 ) . toISOString ( )
385+ expect ( getRelativeTime ( oneYearAgo ) ) . toBe ( '1+ year ago' )
386+
387+ const threeYearsAgo = new Date ( Date . now ( ) - 3 * 365 * 24 * 60 * 60 * 1000 ) . toISOString ( )
388+ expect ( getRelativeTime ( threeYearsAgo ) ) . toBe ( '3+ years ago' )
389+ } )
390+
391+ test ( 'should handle edge cases' , ( ) => {
392+ // Test with a very old date
393+ const veryOldDate = new Date ( '2020-01-01T00:00:00Z' ) . toISOString ( )
394+ const result = getRelativeTime ( veryOldDate )
395+ expect ( result ) . toMatch ( / \d + \+ y e a r s ? a g o / )
396+ } )
397+ } )
398+
399+ describe ( 'isPreRelease' , ( ) => {
400+ test ( 'should identify pre-release versions' , ( ) => {
401+ expect ( isPreRelease ( '1.0.0-alpha.1' ) ) . toBe ( true )
402+ expect ( isPreRelease ( '1.0.0-beta.2' ) ) . toBe ( true )
403+ expect ( isPreRelease ( '1.0.0-rc.1' ) ) . toBe ( true )
404+ expect ( isPreRelease ( '1.0.0-pre.1' ) ) . toBe ( true )
405+ expect ( isPreRelease ( '1.0.0-dev' ) ) . toBe ( true )
406+ expect ( isPreRelease ( '1.0.0-snapshot' ) ) . toBe ( true )
407+ } )
408+
409+ test ( 'should identify stable versions' , ( ) => {
410+ expect ( isPreRelease ( '1.0.0' ) ) . toBe ( false )
411+ expect ( isPreRelease ( '2.1.3' ) ) . toBe ( false )
412+ expect ( isPreRelease ( '0.1.0' ) ) . toBe ( false )
413+ } )
414+
415+ test ( 'should be case insensitive' , ( ) => {
416+ expect ( isPreRelease ( '1.0.0-ALPHA' ) ) . toBe ( true )
417+ expect ( isPreRelease ( '1.0.0-Beta' ) ) . toBe ( true )
418+ expect ( isPreRelease ( '1.0.0-RC' ) ) . toBe ( true )
419+ } )
420+ } )
421+
422+ describe ( 'compareVersions' , ( ) => {
423+ test ( 'should sort versions correctly' , ( ) => {
424+ const versions = [ '1.0.0' , '2.0.0' , '1.1.0' , '1.0.1' ]
425+ const sorted = versions . sort ( compareVersions )
426+ expect ( sorted ) . toEqual ( [ '2.0.0' , '1.1.0' , '1.0.1' , '1.0.0' ] )
427+ } )
428+
429+ test ( 'should handle pre-release versions' , ( ) => {
430+ const versions = [ '1.0.0' , '1.0.0-beta.1' , '1.0.0-alpha.1' ]
431+ const sorted = versions . sort ( compareVersions )
432+ expect ( sorted ) . toEqual ( [ '1.0.0' , '1.0.0-beta.1' , '1.0.0-alpha.1' ] )
433+ } )
434+
435+ test ( 'should handle different version lengths' , ( ) => {
436+ const versions = [ '1.0' , '1.0.0' , '1.0.0.0' ]
437+ const sorted = versions . sort ( compareVersions )
438+ expect ( sorted ) . toEqual ( [ '1.0' , '1.0.0' , '1.0.0.0' ] )
439+ } )
440+ } )
441+
442+ describe ( 'identifyReleasesToPrune' , ( ) => {
443+ const createRelease = ( version , publishedAt ) => ( {
444+ name : 'test.plugin' ,
445+ tag : `test.plugin-v${ version } ` ,
446+ version,
447+ publishedAt,
448+ } )
449+
450+ test ( 'should not prune if 3 or fewer releases' , ( ) => {
451+ const releases = [ createRelease ( '1.0.0' , '2023-01-01T00:00:00Z' ) , createRelease ( '1.1.0' , '2023-02-01T00:00:00Z' ) , createRelease ( '1.2.0' , '2023-03-01T00:00:00Z' ) ]
452+ expect ( identifyReleasesToPrune ( releases ) ) . toEqual ( [ ] )
453+ } )
454+
455+ test ( 'should prune old releases (2+ years) but keep latest stable' , ( ) => {
456+ const now = new Date ( )
457+ const threeYearsAgo = new Date ( now . getTime ( ) - 3 * 365 * 24 * 60 * 60 * 1000 ) . toISOString ( )
458+ const oneYearAgo = new Date ( now . getTime ( ) - 1 * 365 * 24 * 60 * 60 * 1000 ) . toISOString ( )
459+
460+ const releases = [
461+ createRelease ( '1.0.0' , threeYearsAgo ) , // Should be pruned
462+ createRelease ( '2.0.0' , oneYearAgo ) , // Should be kept (latest stable)
463+ createRelease ( '1.1.0' , threeYearsAgo ) , // Should be pruned
464+ createRelease ( '1.2.0' , threeYearsAgo ) , // Should be pruned
465+ createRelease ( '1.3.0' , threeYearsAgo ) , // Should be pruned
466+ ]
467+
468+ const toPrune = identifyReleasesToPrune ( releases )
469+ expect ( toPrune . length ) . toBeGreaterThan ( 0 )
470+ expect ( toPrune . map ( ( r ) => r . version ) ) . toEqual ( expect . arrayContaining ( [ '1.0.0' , '1.1.0' ] ) )
471+ expect ( toPrune . map ( ( r ) => r . version ) ) . not . toContain ( '2.0.0' )
472+ } )
473+
474+ test ( 'should keep recent releases (6 months)' , ( ) => {
475+ const now = new Date ( )
476+ const oneMonthAgo = new Date ( now . getTime ( ) - 1 * 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
477+ const threeYearsAgo = new Date ( now . getTime ( ) - 3 * 365 * 24 * 60 * 60 * 1000 ) . toISOString ( )
478+
479+ const releases = [
480+ createRelease ( '1.0.0' , oneMonthAgo ) , // Should be kept (recent)
481+ createRelease ( '2.0.0' , threeYearsAgo ) , // Should be pruned (old)
482+ createRelease ( '1.1.0' , oneMonthAgo ) , // Should be kept (recent)
483+ createRelease ( '1.2.0' , threeYearsAgo ) , // Should be pruned (old)
484+ createRelease ( '1.3.0' , threeYearsAgo ) , // Should be pruned (old)
485+ ]
486+
487+ const toPrune = identifyReleasesToPrune ( releases )
488+ expect ( toPrune . length ) . toBeGreaterThan ( 0 )
489+ expect ( toPrune . map ( ( r ) => r . version ) ) . toContain ( '1.2.0' )
490+ expect ( toPrune . map ( ( r ) => r . version ) ) . toContain ( '1.3.0' )
491+ expect ( toPrune . map ( ( r ) => r . version ) ) . not . toContain ( '2.0.0' ) // Latest stable should be kept
492+ } )
493+
494+ test ( 'should prune excess pre-release versions' , ( ) => {
495+ const now = new Date ( )
496+ const oneMonthAgo = new Date ( now . getTime ( ) - 1 * 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
497+ const eightMonthsAgo = new Date ( now . getTime ( ) - 8 * 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
498+
499+ const releases = [
500+ createRelease ( '1.0.0' , oneMonthAgo ) , // Stable - keep
501+ createRelease ( '1.1.0-alpha.1' , oneMonthAgo ) , // Recent pre - keep
502+ createRelease ( '1.1.0-beta.1' , oneMonthAgo ) , // Recent pre - keep
503+ createRelease ( '1.1.0-alpha.2' , eightMonthsAgo ) , // Old pre - prune
504+ createRelease ( '1.1.0-beta.2' , eightMonthsAgo ) , // Old pre - prune
505+ createRelease ( '1.1.0-rc.1' , eightMonthsAgo ) , // Old pre - prune
506+ createRelease ( '1.1.0-dev' , eightMonthsAgo ) , // Old pre - prune
507+ ]
508+
509+ const toPrune = identifyReleasesToPrune ( releases )
510+ expect ( toPrune . length ) . toBeGreaterThan ( 0 )
511+ // Should keep recent pre-releases and stable
512+ expect ( toPrune . map ( ( r ) => r . version ) ) . not . toContain ( '1.0.0' )
513+ expect ( toPrune . map ( ( r ) => r . version ) ) . not . toContain ( '1.1.0-alpha.1' )
514+ expect ( toPrune . map ( ( r ) => r . version ) ) . not . toContain ( '1.1.0-beta.1' )
515+ } )
516+ } )
517+
518+ describe ( 'generatePruneCommands' , ( ) => {
519+ test ( 'should return no pruning message for empty array' , ( ) => {
520+ expect ( generatePruneCommands ( [ ] ) ) . toBe ( 'No releases recommended for pruning.' )
521+ } )
522+
523+ test ( 'should generate single prune command without "all at once" section' , ( ) => {
524+ const releasesToPrune = [ { tag : 'plugin-v1.0.0' , version : '1.0.0' } ]
525+
526+ const result = generatePruneCommands ( releasesToPrune )
527+ expect ( result ) . toBe ( 'Recommended prune command:\ngh release delete "plugin-v1.0.0" -y' )
528+ expect ( result ) . not . toContain ( 'To prune all at once:' )
529+ } )
530+
531+ test ( 'should generate multiple prune commands with "all at once" section' , ( ) => {
532+ const releasesToPrune = [
533+ { tag : 'plugin-v1.0.0' , version : '1.0.0' } ,
534+ { tag : 'plugin-v1.1.0' , version : '1.1.0' } ,
535+ ]
536+
537+ const result = generatePruneCommands ( releasesToPrune )
538+ expect ( result ) . toContain ( 'gh release delete "plugin-v1.0.0" -y' )
539+ expect ( result ) . toContain ( 'gh release delete "plugin-v1.1.0" -y' )
540+ expect ( result ) . toContain ( 'To prune all at once:' )
541+ } )
542+ } )
543+
220544 describe ( 'ensureVersionIsNew' , ( ) => {
221545 const mockReleases = [
222546 { name : 'test.plugin' , tag : 'test.plugin-v1.0.0' , version : '1.0.0' , publishedAt : '2023-01-01T00:00:00Z' } ,
0 commit comments