Skip to content

Commit b9aeacd

Browse files
committed
feat: add version:info command to display changelog entries
Add new version:info command that displays changelog information for specific CLI versions or the most recent version. Features: - Takes an optional version argument (e.g., '11.0.0', '10.17.0') - When no version is provided, displays the most recent changelog entry - Smart summary extraction: if the version entry has a Summary section as the first content, only shows the summary with a link to the full changelog - Handles both ## and # markdown headers for version entries - Provides helpful error messages for non-existent versions Examples: heroku version:info # Show most recent version heroku version:info 11.0.0 # Show specific version heroku version:info 10.17.0 # Show older version Includes comprehensive unit tests (10 tests) covering: - Summary extraction logic - Most recent entry extraction - Specific version lookup - Edge cases (missing versions, empty changelog, etc.)
1 parent c6ab2eb commit b9aeacd

2 files changed

Lines changed: 424 additions & 0 deletions

File tree

src/commands/version/info.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {Args, Command, ux} from '@oclif/core'
2+
import {readFile} from 'node:fs/promises'
3+
import {join} from 'node:path'
4+
import {fileURLToPath} from 'node:url'
5+
6+
export default class VersionInfo extends Command {
7+
static args = {
8+
version: Args.string({
9+
description: 'version number to look up (e.g., 11.0.0, 10.17.0)',
10+
required: false,
11+
}),
12+
}
13+
14+
static description = 'display changelog information for a specific CLI version'
15+
16+
static examples = [
17+
'<%= config.bin %> <%= command.id %>',
18+
'<%= config.bin %> <%= command.id %> 11.0.0',
19+
'<%= config.bin %> <%= command.id %> 10.17.0',
20+
]
21+
22+
async run() {
23+
const {args} = await this.parse(VersionInfo)
24+
const {version} = args
25+
26+
try {
27+
// Find the CHANGELOG.md file relative to the CLI installation
28+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
29+
const changelogPath = join(__dirname, '..', '..', '..', 'CHANGELOG.md')
30+
31+
const changelogContent = await readFile(changelogPath, 'utf8')
32+
33+
let entry: string | null
34+
35+
if (version) {
36+
// Extract the entry for the specified version
37+
entry = this.extractVersionEntry(changelogContent, version)
38+
39+
if (!entry) {
40+
ux.error(`Version ${version} not found in CHANGELOG.md`, {exit: 1})
41+
}
42+
} else {
43+
// If no version specified, get the most recent entry
44+
entry = this.extractMostRecentEntry(changelogContent)
45+
46+
if (!entry) {
47+
ux.error('No version entries found in CHANGELOG.md', {exit: 1})
48+
}
49+
}
50+
51+
// If the entry has a Summary section as the first content, only show that
52+
const summaryOnly = this.extractSummaryIfFirst(entry)
53+
if (summaryOnly) {
54+
ux.stdout(summaryOnly)
55+
ux.stdout('')
56+
ux.stdout('For the full changelog, visit: https://github.com/heroku/cli/blob/main/CHANGELOG.md')
57+
} else {
58+
ux.stdout(entry)
59+
}
60+
} catch (error: any) {
61+
if (error.code === 'ENOENT') {
62+
ux.error('CHANGELOG.md not found', {exit: 1})
63+
}
64+
65+
throw error
66+
}
67+
}
68+
69+
private extractSummaryIfFirst(entry: string): string | null {
70+
const lines = entry.split('\n')
71+
let summaryStartIndex = -1
72+
let summaryEndIndex = -1
73+
74+
// Find if "### Summary" appears early in the entry (within first few lines after header)
75+
for (let i = 0; i < Math.min(5, lines.length); i++) {
76+
if (lines[i].trim() === '### Summary') {
77+
summaryStartIndex = i
78+
break
79+
}
80+
}
81+
82+
if (summaryStartIndex === -1) {
83+
return null
84+
}
85+
86+
// Find the end of the summary section (next ### header or end of entry)
87+
for (let i = summaryStartIndex + 1; i < lines.length; i++) {
88+
if (lines[i].match(/^###\s+/)) {
89+
summaryEndIndex = i
90+
break
91+
}
92+
}
93+
94+
if (summaryEndIndex === -1) {
95+
// Summary goes to the end
96+
summaryEndIndex = lines.length
97+
}
98+
99+
// Extract header + summary section
100+
const header = lines.slice(0, summaryStartIndex).join('\n')
101+
const summary = lines.slice(summaryStartIndex, summaryEndIndex).join('\n')
102+
103+
return (header + '\n' + summary).trim()
104+
}
105+
106+
private extractMostRecentEntry(changelog: string): string | null {
107+
const lines = changelog.split('\n')
108+
let startIndex = -1
109+
let endIndex = -1
110+
111+
// Find the first version header (most recent)
112+
for (const [i, line] of lines.entries()) {
113+
const match = line.match(/^##? \[([^\]]+)\]/)
114+
if (match) {
115+
startIndex = i
116+
break
117+
}
118+
}
119+
120+
if (startIndex === -1) {
121+
return null
122+
}
123+
124+
// Find the end of the version entry (start of next version or end of file)
125+
for (let i = startIndex + 1; i < lines.length; i++) {
126+
if (lines[i].match(/^##? \[/)) {
127+
endIndex = i
128+
break
129+
}
130+
}
131+
132+
if (endIndex === -1) {
133+
endIndex = lines.length
134+
}
135+
136+
// Extract the entry
137+
const entry = lines.slice(startIndex, endIndex).join('\n').trim()
138+
139+
return entry
140+
}
141+
142+
private extractVersionEntry(changelog: string, version: string): string | null {
143+
// Handle different version formats (with or without 'v' prefix)
144+
const versionPattern = version.startsWith('v') ? version : `v${version}`
145+
const versionWithoutV = version.startsWith('v') ? version.slice(1) : version
146+
147+
// Split the changelog by version headers
148+
// Headers can be like:
149+
// ## [11.0.0-beta.0](link) (date)
150+
// # [10.17.0](link) (date)
151+
const versionHeaderRegex = /^##? \[([^\]]+)\]/gm
152+
153+
const lines = changelog.split('\n')
154+
let startIndex = -1
155+
let endIndex = -1
156+
let foundVersion = ''
157+
158+
// Find the start of the version entry
159+
for (const [i, line] of lines.entries()) {
160+
const match = line.match(/^##? \[([^\]]+)\]/)
161+
if (match) {
162+
const versionInHeader = match[1]
163+
if (versionInHeader === versionWithoutV || versionInHeader === versionPattern) {
164+
startIndex = i
165+
foundVersion = versionInHeader
166+
break
167+
}
168+
}
169+
}
170+
171+
if (startIndex === -1) {
172+
return null
173+
}
174+
175+
// Find the end of the version entry (start of next version or end of file)
176+
for (let i = startIndex + 1; i < lines.length; i++) {
177+
if (lines[i].match(/^##? \[/)) {
178+
endIndex = i
179+
break
180+
}
181+
}
182+
183+
if (endIndex === -1) {
184+
endIndex = lines.length
185+
}
186+
187+
// Extract the entry
188+
const entry = lines.slice(startIndex, endIndex).join('\n').trim()
189+
190+
return entry
191+
}
192+
}

0 commit comments

Comments
 (0)