@alleyinteractive/changelog-extractor 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,15 +1,24 @@
1
1
  {
2
2
  "name": "@alleyinteractive/changelog-extractor",
3
- "version": "0.0.1",
3
+ "version": "0.3.0",
4
+ "type": "module",
4
5
  "description": "Parse and extract structured information from Keep a Changelog formatted changelogs",
5
6
  "main": "src/parser.js",
6
7
  "bin": {
7
- "changelog-extractor": "./cli.js"
8
+ "changelog-extractor": "./src/cli.js"
8
9
  },
9
10
  "scripts": {
10
- "test": "jest",
11
- "test:watch": "jest --watch",
12
- "test:coverage": "jest --coverage"
11
+ "build": "npm run prettier && npm run package",
12
+ "fix-and-build": "npm run prettier:fix && npm run package",
13
+ "package:watch": "npm run package -- --watch",
14
+ "package": "npx rollup --config rollup.config.js",
15
+ "prepublishOnly": "npm test",
16
+ "prettier:fix": "npx prettier --write .",
17
+ "prettier": "npx prettier --check .",
18
+ "release:major": "npm version major && npm publish --access public && git push --follow-tags",
19
+ "release:minor": "npm version minor && npm publish --access public && git push --follow-tags",
20
+ "release:patch": "npm version patch && npm publish --access public && git push --follow-tags",
21
+ "test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest"
13
22
  },
14
23
  "keywords": [
15
24
  "changelog",
@@ -31,26 +40,27 @@
31
40
  "homepage": "https://github.com/alleyinteractive/action-changelog-extractor#readme",
32
41
  "files": [
33
42
  "src/",
43
+ "dist/",
34
44
  "cli.js",
45
+ "action.yml",
35
46
  "README.md",
36
47
  "LICENSE"
37
48
  ],
49
+ "exports": {
50
+ ".": "./dist/index.js"
51
+ },
38
52
  "devDependencies": {
53
+ "@jest/globals": "^30.2.0",
54
+ "@rollup/plugin-commonjs": "^29.0.0",
55
+ "@rollup/plugin-node-resolve": "^16.0.3",
39
56
  "@types/jest": "^29.5.12",
40
57
  "@types/node": "^20.11.24",
41
- "jest": "^29.7.0"
58
+ "jest": "^30.2.0",
59
+ "prettier": "^3.6.2",
60
+ "prettier-eslint": "^16.4.2",
61
+ "rollup": "^4.53.3"
42
62
  },
43
63
  "dependencies": {
44
64
  "@actions/core": "^1.10.1"
45
- },
46
- "jest": {
47
- "testEnvironment": "node",
48
- "coverageDirectory": "coverage",
49
- "collectCoverageFrom": [
50
- "src/**/*.js"
51
- ],
52
- "testMatch": [
53
- "**/__tests__/**/*.test.js"
54
- ]
55
65
  }
56
66
  }
package/src/cli.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Changelog Extractor CLI
5
+ *
6
+ * Parse and extract structured information from Keep a Changelog formatted files.
7
+ * Can be run via npx or installed globally.
8
+ *
9
+ * @package @alleyinteractive/changelog-extractor
10
+ * @author Alley Interactive
11
+ * @license GPL-3.0
12
+ */
13
+
14
+ import fs from 'fs'
15
+ import path from 'path'
16
+ import { parseChangelog } from './parser.js'
17
+
18
+ function printUsage() {
19
+ console.log(`
20
+ Usage: npx @alleyinteractive/changelog-extractor [options]
21
+
22
+ Options:
23
+ -f, --file <path> Path to changelog file (default: CHANGELOG.md)
24
+ -v, --version <ver> Specific version to extract (e.g., "1.14.0" or "v1.14.0")
25
+ Leave empty to return all versions
26
+ -l, --list List the count of versions found and exit
27
+ -h, --help Show this help message
28
+
29
+ Examples:
30
+ npx @alleyinteractive/changelog-extractor # Parse CHANGELOG.md, all versions
31
+ npx @alleyinteractive/changelog-extractor -l # Count versions in CHANGELOG.md
32
+ npx @alleyinteractive/changelog-extractor -v 1.14.0 # Extract specific version
33
+ npx @alleyinteractive/changelog-extractor -f /tmp/test.md # Parse specific file
34
+ npx @alleyinteractive/changelog-extractor -f CHANGELOG.md -v 1.14.0 # Both options
35
+ changelog-extractor -l # If installed globally
36
+ `)
37
+ }
38
+
39
+ // Parse command line arguments
40
+ const args = process.argv.slice(2)
41
+
42
+ if (args.includes('--help') || args.includes('-h')) {
43
+ printUsage()
44
+ process.exit(0)
45
+ }
46
+
47
+ const listMode = args.includes('-l') || args.includes('--list')
48
+
49
+ // Parse file option
50
+ let changelogFile = 'CHANGELOG.md'
51
+ const fileIndex = args.findIndex((arg) => arg === '-f' || arg === '--file')
52
+ if (fileIndex !== -1 && args[fileIndex + 1]) {
53
+ changelogFile = args[fileIndex + 1]
54
+ }
55
+
56
+ // Parse version option
57
+ let versionArg = null
58
+ const versionIndex = args.findIndex(
59
+ (arg) => arg === '-v' || arg === '--version'
60
+ )
61
+ if (versionIndex !== -1 && args[versionIndex + 1]) {
62
+ versionArg = args[versionIndex + 1]
63
+ }
64
+
65
+ const version = versionArg
66
+
67
+ // Read changelog file
68
+ const changelogPath = path.resolve(process.cwd(), changelogFile)
69
+
70
+ if (!fs.existsSync(changelogPath)) {
71
+ console.error(`❌ Error: Changelog file not found: ${changelogPath}`)
72
+ console.error(
73
+ '\nTip: You can create a test changelog at /tmp/test-changelog.md'
74
+ )
75
+ process.exit(1)
76
+ }
77
+
78
+ const changelogContent = fs.readFileSync(changelogPath, 'utf8')
79
+
80
+ try {
81
+ const results = parseChangelog(changelogContent, version)
82
+
83
+ // List mode: just display count and exit
84
+ if (listMode) {
85
+ console.log(`${results.length}`)
86
+ process.exit(0)
87
+ }
88
+
89
+ // Parse changelog
90
+ console.log(`📖 Parsing: ${changelogPath}`)
91
+ console.log(`🎯 Version: ${versionArg || 'all versions'}`)
92
+ console.log('')
93
+
94
+ if (results.length === 0) {
95
+ console.log('⚠️ No changelog entries found')
96
+ process.exit(1)
97
+ }
98
+
99
+ console.log(`✅ Found ${results.length} version(s)\n`)
100
+ console.log('─'.repeat(80))
101
+
102
+ // Pretty print results
103
+ results.forEach((entry, index) => {
104
+ console.log(`\n📦 Version: ${entry.name}`)
105
+ console.log(
106
+ `📝 Sections: ${Object.keys(entry.sections).join(', ') || 'none'}`
107
+ )
108
+ console.log('')
109
+
110
+ // Print sections
111
+ for (const [sectionName, sectionContent] of Object.entries(
112
+ entry.sections
113
+ )) {
114
+ console.log(
115
+ ` ### ${sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}`
116
+ )
117
+ console.log('')
118
+ // Indent content
119
+ const lines = sectionContent.split('\n')
120
+ lines.forEach((line) => console.log(` ${line}`))
121
+ console.log('')
122
+ }
123
+
124
+ if (index < results.length - 1) {
125
+ console.log('─'.repeat(80))
126
+ }
127
+ })
128
+
129
+ // Output JSON at the end
130
+ console.log('\n' + '═'.repeat(80))
131
+ console.log('\n📋 JSON Output:\n')
132
+ console.log(JSON.stringify(results, null, 2))
133
+ } catch (error) {
134
+ console.error('❌ Error parsing changelog:', error.message)
135
+ process.exit(1)
136
+ }
package/src/index.js CHANGED
@@ -1,47 +1,7 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { parseChangelog } = require('./parser');
4
-
5
1
  /**
6
- * Main action entry point
2
+ * The entrypoint for the action. This file simply imports and runs the action's
3
+ * main logic.
7
4
  */
8
- async function run(core) {
9
- try {
10
- // Get inputs
11
- const changelogPath = core.getInput('changelog-path') || 'CHANGELOG.md';
12
- const versionInput = core.getInput('version');
13
-
14
- // Only pass version if explicitly specified, otherwise return all versions
15
- const version = versionInput || null;
16
-
17
- // Read changelog file
18
- const fullPath = path.resolve(process.cwd(), changelogPath);
19
-
20
- if (!fs.existsSync(fullPath)) {
21
- throw new Error(`Changelog file not found: ${fullPath}`);
22
- }
23
-
24
- const changelogContent = fs.readFileSync(fullPath, 'utf8');
25
-
26
- // Parse changelog
27
- const results = parseChangelog(changelogContent, version);
28
-
29
- if (results.length === 0) {
30
- core.warning(`No changelog entries found${version ? ` for version ${version}` : ''}`);
31
- }
32
-
33
- // Set output as JSON string
34
- core.setOutput('result', JSON.stringify(results));
35
-
36
- // Log summary
37
- core.info(`Parsed ${results.length} changelog ${results.length === 1 ? 'entry' : 'entries'}`);
38
- for (const entry of results) {
39
- core.info(` - Version ${entry.name}: ${Object.keys(entry.sections).length} sections`);
40
- }
41
-
42
- } catch (error) {
43
- core.setFailed(error.message);
44
- }
45
- }
5
+ import { run } from './main.js'
46
6
 
47
- module.exports = run;
7
+ run()
package/src/main.js ADDED
@@ -0,0 +1,50 @@
1
+ import * as core from '@actions/core'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { parseChangelog } from './parser.js'
5
+
6
+ /**
7
+ * Main action entry point
8
+ */
9
+ export async function run() {
10
+ try {
11
+ const changelogPath = core.getInput('changelog-path') || 'CHANGELOG.md'
12
+ const versionInput = core.getInput('version')
13
+
14
+ // Only pass version if explicitly specified, otherwise return all versions
15
+ const version = versionInput || null
16
+
17
+ // Read changelog file
18
+ const fullPath = path.resolve(process.cwd(), changelogPath)
19
+
20
+ if (!fs.existsSync(fullPath)) {
21
+ throw new Error(`Changelog file not found: ${fullPath}`)
22
+ }
23
+
24
+ const changelogContent = fs.readFileSync(fullPath, 'utf8')
25
+
26
+ // Parse changelog
27
+ const results = parseChangelog(changelogContent, version)
28
+
29
+ if (results.length === 0) {
30
+ core.warning(
31
+ `No changelog entries found${version ? ` for version ${version}` : ''}`
32
+ )
33
+ }
34
+
35
+ // Set output as JSON string
36
+ core.setOutput('result', JSON.stringify(results))
37
+
38
+ // Log summary
39
+ core.info(
40
+ `Parsed ${results.length} changelog ${results.length === 1 ? 'entry' : 'entries'}`
41
+ )
42
+ for (const entry of results) {
43
+ core.info(
44
+ ` - Version ${entry.name}: ${Object.keys(entry.sections).length} sections`
45
+ )
46
+ }
47
+ } catch (error) {
48
+ core.setFailed(error.message)
49
+ }
50
+ }
package/src/parser.js CHANGED
@@ -1,3 +1,83 @@
1
+ /**
2
+ * Unwrap text that has been wrapped across multiple lines.
3
+ * This fixes changelog text that has been hard-wrapped for readability
4
+ * but should flow as continuous text.
5
+ *
6
+ * @param {string} text - The text content to unwrap
7
+ * @returns {string} The unwrapped text
8
+ */
9
+ function unwrapText(text) {
10
+ const lines = text.split('\n')
11
+ const unwrappedLines = []
12
+
13
+ for (let i = 0; i < lines.length; i++) {
14
+ let currentLine = lines[i]
15
+
16
+ // Skip empty lines - preserve them as-is
17
+ if (currentLine.trim() === '') {
18
+ unwrappedLines.push(currentLine)
19
+ continue
20
+ }
21
+
22
+ // Collect continuation lines
23
+ let j = i + 1
24
+ while (j < lines.length) {
25
+ const nextLine = lines[j]
26
+
27
+ // Stop if next line is empty
28
+ if (nextLine.trim() === '') {
29
+ break
30
+ }
31
+
32
+ const trimmedCurrent = currentLine.trim()
33
+ const trimmedNext = nextLine.trim()
34
+
35
+ // Don't unwrap if current line looks like it ends a sentence/item
36
+ const endsWithPunctuation = /[.!?:;]$/.test(trimmedCurrent)
37
+ const endsWithCode = /`$/.test(trimmedCurrent)
38
+ const endsWithBullet = /[-*+]\s*$/.test(trimmedCurrent)
39
+
40
+ // Don't unwrap if next line starts a new bullet point, section, or sentence
41
+ const startsWithBullet = /^[-*+]\s/.test(trimmedNext)
42
+ const startsWithNumber = /^\d+\.\s/.test(trimmedNext)
43
+ const startsWithMarkdown = /^[#>]/.test(trimmedNext)
44
+
45
+ // More nuanced check for new sentences
46
+ const prevEndsWithContinuation =
47
+ /[,&]$/.test(trimmedCurrent) ||
48
+ /\b(and|or|but|with|for|to|from|in|on|at|by)$/.test(trimmedCurrent)
49
+ const startsWithCapitalNewSentence =
50
+ /^[A-Z]/.test(trimmedNext) &&
51
+ !prevEndsWithContinuation &&
52
+ endsWithPunctuation
53
+
54
+ // Determine if we should unwrap this line with the next
55
+ const shouldUnwrap =
56
+ !endsWithPunctuation &&
57
+ !endsWithCode &&
58
+ !endsWithBullet &&
59
+ !startsWithBullet &&
60
+ !startsWithNumber &&
61
+ !startsWithCapitalNewSentence &&
62
+ !startsWithMarkdown
63
+
64
+ if (shouldUnwrap) {
65
+ // Join the lines, preserving the original indentation of the first line
66
+ // but removing indentation from continuation lines
67
+ currentLine = currentLine + ' ' + trimmedNext
68
+ j++ // Move to next line
69
+ } else {
70
+ break // Stop unwrapping
71
+ }
72
+ }
73
+
74
+ unwrappedLines.push(currentLine)
75
+ i = j - 1 // Skip the lines we've consumed (j-1 because for loop will increment)
76
+ }
77
+
78
+ return unwrappedLines.join('\n')
79
+ }
80
+
1
81
  /**
2
82
  * Parse a Keep a Changelog formatted changelog file.
3
83
  *
@@ -5,31 +85,31 @@
5
85
  * @param {string|null} version - Specific version to extract (e.g., "v1.14.0" or "1.14.0"), or null/undefined for all versions
6
86
  * @returns {Array<{name: string, sections: Object, contents: string}>} Array of version objects
7
87
  */
8
- function parseChangelog(changelogContent, version = null) {
9
- const versions = [];
10
- const lines = changelogContent.split('\n');
88
+ export function parseChangelog(changelogContent, version = null) {
89
+ const versions = []
90
+ const lines = changelogContent.split('\n')
11
91
 
12
- let currentVersion = null;
13
- let currentSection = null;
14
- let currentSectionContent = [];
15
- let generalContent = []; // Content without section headers
92
+ let currentVersion = null
93
+ let currentSection = null
94
+ let currentSectionContent = []
95
+ let generalContent = [] // Content without section headers
16
96
 
17
97
  // 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*(.*))?$/;
98
+ const versionRegex = /^##\s+v?(\d+\.\d+\.\d+(?:-[^\s]+)?)\s*(?:-\s*(.*))?$/
19
99
  // Regex to match section headers: ### Added, ### Changed, etc.
20
- const sectionRegex = /^###\s+(.+)$/;
100
+ const sectionRegex = /^###\s+(.+)$/
21
101
 
22
102
  for (let i = 0; i < lines.length; i++) {
23
- const line = lines[i];
103
+ const line = lines[i]
24
104
 
25
105
  // Check for version header
26
- const versionMatch = line.match(versionRegex);
106
+ const versionMatch = line.match(versionRegex)
27
107
  if (versionMatch) {
28
108
  // Save previous version if exists
29
109
  if (currentVersion) {
30
- saveCurrentSection();
31
- saveGeneralContent();
32
- versions.push(currentVersion);
110
+ saveCurrentSection()
111
+ saveGeneralContent()
112
+ versions.push(currentVersion)
33
113
  }
34
114
 
35
115
  // Start new version
@@ -37,62 +117,70 @@ function parseChangelog(changelogContent, version = null) {
37
117
  name: versionMatch[1], // Version without 'v' prefix
38
118
  sections: {},
39
119
  contents: ''
40
- };
41
- currentSection = null;
42
- currentSectionContent = [];
43
- generalContent = [];
44
- continue;
120
+ }
121
+ currentSection = null
122
+ currentSectionContent = []
123
+ generalContent = []
124
+ continue
45
125
  }
46
126
 
47
127
  // Check for section header
48
- const sectionMatch = line.match(sectionRegex);
128
+ const sectionMatch = line.match(sectionRegex)
49
129
  if (sectionMatch && currentVersion) {
50
130
  // If we have general content, save it before starting a new section
51
131
  if (generalContent.length > 0) {
52
- saveGeneralContent();
132
+ saveGeneralContent()
53
133
  }
54
134
 
55
135
  // Save previous section if exists
56
- saveCurrentSection();
136
+ saveCurrentSection()
57
137
 
58
138
  // Start new section
59
- currentSection = sectionMatch[1].toLowerCase();
60
- currentSectionContent = [];
61
- continue;
139
+ currentSection = sectionMatch[1].toLowerCase()
140
+ currentSectionContent = []
141
+ continue
62
142
  }
63
143
 
64
144
  // Add content to current section or general content
65
145
  if (currentVersion) {
66
146
  if (currentSection) {
67
- currentSectionContent.push(line);
147
+ currentSectionContent.push(line)
68
148
  } else {
69
149
  // Content without a section header goes to general
70
- generalContent.push(line);
150
+ generalContent.push(line)
71
151
  }
72
152
  }
73
153
  }
74
154
 
75
155
  // Save final version and section
76
156
  if (currentVersion) {
77
- saveCurrentSection();
78
- saveGeneralContent();
79
- versions.push(currentVersion);
157
+ saveCurrentSection()
158
+ saveGeneralContent()
159
+ versions.push(currentVersion)
80
160
  }
81
161
 
82
162
  // Helper function to save the current section
83
163
  function saveCurrentSection() {
84
164
  if (currentVersion && currentSection && currentSectionContent.length > 0) {
85
165
  // Trim empty lines from start and end
86
- while (currentSectionContent.length > 0 && currentSectionContent[0].trim() === '') {
87
- currentSectionContent.shift();
166
+ while (
167
+ currentSectionContent.length > 0 &&
168
+ currentSectionContent[0].trim() === ''
169
+ ) {
170
+ currentSectionContent.shift()
88
171
  }
89
- while (currentSectionContent.length > 0 && currentSectionContent[currentSectionContent.length - 1].trim() === '') {
90
- currentSectionContent.pop();
172
+ while (
173
+ currentSectionContent.length > 0 &&
174
+ currentSectionContent[currentSectionContent.length - 1].trim() === ''
175
+ ) {
176
+ currentSectionContent.pop()
91
177
  }
92
178
 
93
- const content = currentSectionContent.join('\n');
179
+ const content = currentSectionContent.join('\n')
94
180
  if (content.trim()) {
95
- currentVersion.sections[currentSection] = content;
181
+ // Unwrap any text that has been hard-wrapped across lines
182
+ const unwrappedContent = unwrapText(content)
183
+ currentVersion.sections[currentSection] = unwrappedContent
96
184
  }
97
185
  }
98
186
  }
@@ -101,59 +189,73 @@ function parseChangelog(changelogContent, version = null) {
101
189
  function saveGeneralContent() {
102
190
  if (currentVersion && generalContent.length > 0) {
103
191
  // Trim empty lines from start and end
104
- let trimmedContent = [...generalContent];
192
+ let trimmedContent = [...generalContent]
105
193
  while (trimmedContent.length > 0 && trimmedContent[0].trim() === '') {
106
- trimmedContent.shift();
194
+ trimmedContent.shift()
107
195
  }
108
- while (trimmedContent.length > 0 && trimmedContent[trimmedContent.length - 1].trim() === '') {
109
- trimmedContent.pop();
196
+ while (
197
+ trimmedContent.length > 0 &&
198
+ trimmedContent[trimmedContent.length - 1].trim() === ''
199
+ ) {
200
+ trimmedContent.pop()
110
201
  }
111
202
 
112
- const content = trimmedContent.join('\n');
203
+ const content = trimmedContent.join('\n')
113
204
  if (content.trim()) {
114
- currentVersion.sections['general'] = content;
205
+ // Unwrap any text that has been hard-wrapped across lines
206
+ const unwrappedContent = unwrapText(content)
207
+ currentVersion.sections['general'] = unwrappedContent
115
208
  }
116
- generalContent = [];
209
+ generalContent = []
117
210
  }
118
211
  }
119
212
 
120
213
  // Build contents for each version (all sections combined)
121
214
  for (const ver of versions) {
122
- const contentParts = [];
215
+ const contentParts = []
123
216
 
124
217
  // Order sections in conventional order, with general at the end
125
- const sectionOrder = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'];
218
+ const sectionOrder = [
219
+ 'added',
220
+ 'changed',
221
+ 'deprecated',
222
+ 'removed',
223
+ 'fixed',
224
+ 'security'
225
+ ]
126
226
 
127
227
  for (const sectionName of sectionOrder) {
128
228
  if (ver.sections[sectionName]) {
129
- contentParts.push(`### ${sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}\n\n${ver.sections[sectionName]}`);
229
+ contentParts.push(
230
+ `### ${sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}\n\n${ver.sections[sectionName]}`
231
+ )
130
232
  }
131
233
  }
132
234
 
133
235
  // Add any sections not in the standard order (except 'general')
134
236
  for (const [sectionName, sectionContent] of Object.entries(ver.sections)) {
135
237
  if (!sectionOrder.includes(sectionName) && sectionName !== 'general') {
136
- contentParts.push(`### ${sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}\n\n${sectionContent}`);
238
+ contentParts.push(
239
+ `### ${sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}\n\n${sectionContent}`
240
+ )
137
241
  }
138
242
  }
139
243
 
140
244
  // Add general section last if it exists (without a header since it's raw content)
141
245
  if (ver.sections['general']) {
142
- contentParts.push(ver.sections['general']);
246
+ contentParts.push(ver.sections['general'])
143
247
  }
144
248
 
145
- ver.contents = contentParts.join('\n\n');
249
+ ver.contents = contentParts.join('\n\n')
146
250
  }
147
251
 
148
252
  // Filter by specific version if requested
149
253
  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;
254
+ const normalizedVersion = version.replace(/^v/, '') // Remove 'v' prefix if present
255
+ const filtered = versions.filter((v) => v.name === normalizedVersion)
256
+ return filtered
153
257
  }
154
258
 
155
259
  // Return all versions by default
156
- return versions;
260
+ return versions
157
261
  }
158
-
159
- module.exports = { parseChangelog };