1+ /**
2+ * Parse a Keep a Changelog formatted changelog file.
3+ *
4+ * @param {string } changelogContent - The raw markdown content of the changelog
5+ * @param {string|null } version - Specific version to extract (e.g., "v1.14.0" or "1.14.0"), or null/undefined for all versions
6+ * @returns {Array<{name: string, sections: Object, contents: string}> } Array of version objects
7+ */
8+ function parseChangelog ( changelogContent , version = null ) {
9+ const versions = [ ] ;
10+ const lines = changelogContent . split ( '\n' ) ;
11+
12+ let currentVersion = null ;
13+ let currentSection = null ;
14+ let currentSectionContent = [ ] ;
15+ let generalContent = [ ] ; // Content without section headers
16+
17+ // Regex to match version headers: ## v1.14.0 or ## v1.14.0 - 2024-04-29
18+ const versionRegex = / ^ # # \s + v ? ( \d + \. \d + \. \d + (?: - [ ^ \s ] + ) ? ) \s * (?: - \s * ( .* ) ) ? $ / ;
19+ // Regex to match section headers: ### Added, ### Changed, etc.
20+ const sectionRegex = / ^ # # # \s + ( .+ ) $ / ;
21+
22+ for ( let i = 0 ; i < lines . length ; i ++ ) {
23+ const line = lines [ i ] ;
24+
25+ // Check for version header
26+ const versionMatch = line . match ( versionRegex ) ;
27+ if ( versionMatch ) {
28+ // Save previous version if exists
29+ if ( currentVersion ) {
30+ saveCurrentSection ( ) ;
31+ saveGeneralContent ( ) ;
32+ versions . push ( currentVersion ) ;
33+ }
34+
35+ // Start new version
36+ currentVersion = {
37+ name : versionMatch [ 1 ] , // Version without 'v' prefix
38+ sections : { } ,
39+ contents : ''
40+ } ;
41+ currentSection = null ;
42+ currentSectionContent = [ ] ;
43+ generalContent = [ ] ;
44+ continue ;
45+ }
46+
47+ // Check for section header
48+ const sectionMatch = line . match ( sectionRegex ) ;
49+ if ( sectionMatch && currentVersion ) {
50+ // If we have general content, save it before starting a new section
51+ if ( generalContent . length > 0 ) {
52+ saveGeneralContent ( ) ;
53+ }
54+
55+ // Save previous section if exists
56+ saveCurrentSection ( ) ;
57+
58+ // Start new section
59+ currentSection = sectionMatch [ 1 ] . toLowerCase ( ) ;
60+ currentSectionContent = [ ] ;
61+ continue ;
62+ }
63+
64+ // Add content to current section or general content
65+ if ( currentVersion ) {
66+ if ( currentSection ) {
67+ currentSectionContent . push ( line ) ;
68+ } else {
69+ // Content without a section header goes to general
70+ generalContent . push ( line ) ;
71+ }
72+ }
73+ }
74+
75+ // Save final version and section
76+ if ( currentVersion ) {
77+ saveCurrentSection ( ) ;
78+ saveGeneralContent ( ) ;
79+ versions . push ( currentVersion ) ;
80+ }
81+
82+ // Helper function to save the current section
83+ function saveCurrentSection ( ) {
84+ if ( currentVersion && currentSection && currentSectionContent . length > 0 ) {
85+ // Trim empty lines from start and end
86+ while ( currentSectionContent . length > 0 && currentSectionContent [ 0 ] . trim ( ) === '' ) {
87+ currentSectionContent . shift ( ) ;
88+ }
89+ while ( currentSectionContent . length > 0 && currentSectionContent [ currentSectionContent . length - 1 ] . trim ( ) === '' ) {
90+ currentSectionContent . pop ( ) ;
91+ }
92+
93+ const content = currentSectionContent . join ( '\n' ) ;
94+ if ( content . trim ( ) ) {
95+ currentVersion . sections [ currentSection ] = content ;
96+ }
97+ }
98+ }
99+
100+ // Helper function to save general content (content without section headers)
101+ function saveGeneralContent ( ) {
102+ if ( currentVersion && generalContent . length > 0 ) {
103+ // Trim empty lines from start and end
104+ let trimmedContent = [ ...generalContent ] ;
105+ while ( trimmedContent . length > 0 && trimmedContent [ 0 ] . trim ( ) === '' ) {
106+ trimmedContent . shift ( ) ;
107+ }
108+ while ( trimmedContent . length > 0 && trimmedContent [ trimmedContent . length - 1 ] . trim ( ) === '' ) {
109+ trimmedContent . pop ( ) ;
110+ }
111+
112+ const content = trimmedContent . join ( '\n' ) ;
113+ if ( content . trim ( ) ) {
114+ currentVersion . sections [ 'general' ] = content ;
115+ }
116+ generalContent = [ ] ;
117+ }
118+ }
119+
120+ // Build contents for each version (all sections combined)
121+ for ( const ver of versions ) {
122+ const contentParts = [ ] ;
123+
124+ // Order sections in conventional order, with general at the end
125+ const sectionOrder = [ 'added' , 'changed' , 'deprecated' , 'removed' , 'fixed' , 'security' ] ;
126+
127+ for ( const sectionName of sectionOrder ) {
128+ if ( ver . sections [ sectionName ] ) {
129+ contentParts . push ( `### ${ sectionName . charAt ( 0 ) . toUpperCase ( ) + sectionName . slice ( 1 ) } \n\n${ ver . sections [ sectionName ] } ` ) ;
130+ }
131+ }
132+
133+ // Add any sections not in the standard order (except 'general')
134+ for ( const [ sectionName , sectionContent ] of Object . entries ( ver . sections ) ) {
135+ if ( ! sectionOrder . includes ( sectionName ) && sectionName !== 'general' ) {
136+ contentParts . push ( `### ${ sectionName . charAt ( 0 ) . toUpperCase ( ) + sectionName . slice ( 1 ) } \n\n${ sectionContent } ` ) ;
137+ }
138+ }
139+
140+ // Add general section last if it exists (without a header since it's raw content)
141+ if ( ver . sections [ 'general' ] ) {
142+ contentParts . push ( ver . sections [ 'general' ] ) ;
143+ }
144+
145+ ver . contents = contentParts . join ( '\n\n' ) ;
146+ }
147+
148+ // Filter by specific version if requested
149+ if ( version ) {
150+ const normalizedVersion = version . replace ( / ^ v / , '' ) ; // Remove 'v' prefix if present
151+ const filtered = versions . filter ( v => v . name === normalizedVersion ) ;
152+ return filtered ;
153+ }
154+
155+ // Return all versions by default
156+ return versions ;
157+ }
158+
159+
160+
161+
162+ const fs = require ( 'fs' ) ;
163+ const path = require ( 'path' ) ;
164+
165+
166+ /**
167+ * Main action entry point
168+ *
169+ * @param {import('@actions/core') } core
170+ */
171+ async function run ( core ) {
172+ try {
173+ // Get inputs
174+ const changelogPath = core . getInput ( 'changelog-path' ) || 'CHANGELOG.md' ;
175+ const versionInput = core . getInput ( 'version' ) ;
176+
177+ // Only pass version if explicitly specified, otherwise return all versions
178+ const version = versionInput || null ;
179+
180+ // Read changelog file
181+ const fullPath = path . resolve ( process . cwd ( ) , changelogPath ) ;
182+
183+ if ( ! fs . existsSync ( fullPath ) ) {
184+ throw new Error ( `Changelog file not found: ${ fullPath } ` ) ;
185+ }
186+
187+ const changelogContent = fs . readFileSync ( fullPath , 'utf8' ) ;
188+
189+ // Parse changelog
190+ const results = parseChangelog ( changelogContent , version ) ;
191+
192+ if ( results . length === 0 ) {
193+ core . warning ( `No changelog entries found${ version ? ` for version ${ version } ` : '' } ` ) ;
194+ }
195+
196+ // Set output as JSON string
197+ core . setOutput ( 'result' , JSON . stringify ( results ) ) ;
198+
199+ // Log summary
200+ core . info ( `Parsed ${ results . length } changelog ${ results . length === 1 ? 'entry' : 'entries' } ` ) ;
201+ for ( const entry of results ) {
202+ core . info ( ` - Version ${ entry . name } : ${ Object . keys ( entry . sections ) . length } sections` ) ;
203+ }
204+
205+ } catch ( error ) {
206+ core . setFailed ( error . message ) ;
207+ }
208+ }
209+
210+ // Only run if this is the main module (for GitHub Actions)
211+ if ( require . main === module ) {
212+ const core = require ( '@actions/core' ) ;
213+ run ( core ) ;
214+ }
215+
216+ module . exports = run ;
0 commit comments