mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-05-02 15:22:30 +00:00
feat: rework release notes around GitHub releases (#981)
* feat: rework release notes around GitHub releases - switch /release-notes from upstream changelog parsing to OpenClaude GitHub release data - add a public build version helper and use it for release URL/seen-version tracking - render release notes in-app with section headers like Features and Bug Fixes - cache serialized GitHub release notes locally for startup and command fallback paths - preserve snake_case identifiers while sanitizing markdown content - keep LogoV2 whats-new output within its display budget - add focused tests for parsing, formatting, version tags, and display slicing * Normalize release-please changelog versions Use normalizePublicVersion when parsing cached changelog headings so release-please markdown headings like ## [0.8.0](...) (2026-05-02) map to the expected version key. Also normalize direct version lookups consistently and add a regression test covering getReleaseNotesForVersion and getRecentReleaseNotes against the new CHANGELOG.md format.
This commit is contained in:
@@ -1,50 +1,52 @@
|
||||
import type { LocalCommandResult } from '../../types/command.js'
|
||||
import {
|
||||
CHANGELOG_URL,
|
||||
fetchReleaseNotesForVersion,
|
||||
fetchAndStoreChangelog,
|
||||
getAllReleaseNotes,
|
||||
formatReleaseNotesForDisplay,
|
||||
getReleaseNotesForVersion,
|
||||
getStoredChangelog,
|
||||
} from '../../utils/releaseNotes.js'
|
||||
import { getReleaseTagUrl, publicBuildVersion } from '../../utils/version.js'
|
||||
|
||||
function formatReleaseNotes(notes: Array<[string, string[]]>): string {
|
||||
return notes
|
||||
.map(([version, notes]) => {
|
||||
const header = `Version ${version}:`
|
||||
const bulletPoints = notes.map(note => `· ${note}`).join('\n')
|
||||
return `${header}\n${bulletPoints}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export async function call(): Promise<LocalCommandResult> {
|
||||
// Try to fetch the latest changelog with a 500ms timeout
|
||||
let freshNotes: Array<[string, string[]]> = []
|
||||
async function getCurrentReleaseNotes(): Promise<string[]> {
|
||||
try {
|
||||
const freshNotes = await fetchReleaseNotesForVersion(publicBuildVersion)
|
||||
if (freshNotes.length > 0) {
|
||||
return freshNotes
|
||||
}
|
||||
} catch {
|
||||
// Fall back to cached notes below.
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
setTimeout(rej => rej(new Error('Timeout')), 500, reject)
|
||||
setTimeout(rej => rej(new Error('Timeout')), 1500, reject)
|
||||
})
|
||||
|
||||
await Promise.race([fetchAndStoreChangelog(), timeoutPromise])
|
||||
freshNotes = getAllReleaseNotes(await getStoredChangelog())
|
||||
} catch {
|
||||
// Either fetch failed or timed out - just use cached notes
|
||||
// Fall back to cached notes below.
|
||||
}
|
||||
|
||||
// If we have fresh notes from the quick fetch, use those
|
||||
if (freshNotes.length > 0) {
|
||||
return { type: 'text', value: formatReleaseNotes(freshNotes) }
|
||||
return getReleaseNotesForVersion(
|
||||
publicBuildVersion,
|
||||
await getStoredChangelog(),
|
||||
)
|
||||
}
|
||||
|
||||
export async function call(): Promise<LocalCommandResult> {
|
||||
const url = getReleaseTagUrl(publicBuildVersion)
|
||||
const notes = await getCurrentReleaseNotes()
|
||||
|
||||
if (notes.length > 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Release notes for ${publicBuildVersion}:\n${formatReleaseNotesForDisplay(notes)}\n\nFull release page: ${url}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise check cached notes
|
||||
const cachedNotes = getAllReleaseNotes(await getStoredChangelog())
|
||||
if (cachedNotes.length > 0) {
|
||||
return { type: 'text', value: formatReleaseNotes(cachedNotes) }
|
||||
}
|
||||
|
||||
// Nothing available, show link
|
||||
return {
|
||||
type: 'text',
|
||||
value: `See the full changelog at: ${CHANGELOG_URL}`,
|
||||
value: `Release notes: ${url}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCo
|
||||
import { CondensedLogo } from './CondensedLogo.js';
|
||||
import { OffscreenFreeze } from '../OffscreenFreeze.js';
|
||||
import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js';
|
||||
import { publicBuildVersion } from '../../utils/version.js';
|
||||
import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js';
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js';
|
||||
import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js';
|
||||
@@ -94,7 +95,7 @@ export function LogoV2() {
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t2 = () => {
|
||||
const currentConfig = getGlobalConfig();
|
||||
if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) {
|
||||
if (currentConfig.lastReleaseNotesSeen === publicBuildVersion) {
|
||||
return;
|
||||
}
|
||||
saveGlobalConfig(_temp3);
|
||||
@@ -528,12 +529,12 @@ export function LogoV2() {
|
||||
return t41;
|
||||
}
|
||||
function _temp3(current) {
|
||||
if (current.lastReleaseNotesSeen === MACRO.VERSION) {
|
||||
if (current.lastReleaseNotesSeen === publicBuildVersion) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
lastReleaseNotesSeen: MACRO.VERSION
|
||||
lastReleaseNotesSeen: publicBuildVersion
|
||||
};
|
||||
}
|
||||
function _temp2(s_0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/
|
||||
import type { LogOption } from '../../types/logs.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { formatRelativeTimeAgo } from '../../utils/format.js';
|
||||
import { getReleaseSectionHeaderTitle, isReleaseSectionHeader } from '../../utils/releaseNotes.js';
|
||||
import type { FeedConfig, FeedLine } from './Feed.js';
|
||||
export function createRecentActivityFeed(activities: LogOption[]): FeedConfig {
|
||||
const lines: FeedLine[] = activities.map(log => {
|
||||
@@ -35,6 +36,11 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isReleaseSectionHeader(note)) {
|
||||
return {
|
||||
text: `${getReleaseSectionHeaderTitle(note)}:`
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: note
|
||||
};
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
truncateToWidth,
|
||||
truncateToWidthNoEllipsis,
|
||||
} from './format.js'
|
||||
import { getStoredChangelogFromMemory, parseChangelog } from './releaseNotes.js'
|
||||
import {
|
||||
getStoredChangelogFromMemory,
|
||||
parseChangelog,
|
||||
sliceReleaseNotesForDisplay,
|
||||
} from './releaseNotes.js'
|
||||
import { gt } from './semver.js'
|
||||
import { loadMessageLogs } from './sessionStorage.js'
|
||||
import { getInitialSettings } from './settings/settings.js'
|
||||
@@ -345,6 +349,5 @@ export function getRecentReleaseNotesSync(maxItems: number): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Return raw notes without filtering or premature truncation
|
||||
return allNotes.slice(0, maxItems)
|
||||
return sliceReleaseNotesForDisplay(allNotes, maxItems)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
formatReleaseNotesForDisplay,
|
||||
getRecentReleaseNotes,
|
||||
getReleaseNotesForVersion,
|
||||
parseGitHubReleaseBody,
|
||||
sliceReleaseNotesForDisplay,
|
||||
serializeGitHubReleasesAsChangelog,
|
||||
} from './releaseNotes.js'
|
||||
import { getReleaseTagUrl } from './version.js'
|
||||
|
||||
test('parseGitHubReleaseBody strips markdown links and trailing refs', () => {
|
||||
expect(
|
||||
parseGitHubReleaseBody(`### Features
|
||||
* add thing ([#1](https://example.com)) ([abc1234](https://example.com))
|
||||
### Bug Fixes
|
||||
* **api:** fix bug`),
|
||||
).toEqual([
|
||||
'__section__:Features',
|
||||
'add thing',
|
||||
'__section__:Bug Fixes',
|
||||
'api: fix bug',
|
||||
])
|
||||
})
|
||||
|
||||
test('parseGitHubReleaseBody preserves snake_case identifiers', () => {
|
||||
expect(
|
||||
parseGitHubReleaseBody(
|
||||
'* add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress reminders',
|
||||
),
|
||||
).toEqual([
|
||||
'add OPENCLAUDE_DISABLE_TOOL_REMINDERS env var to suppress reminders',
|
||||
])
|
||||
})
|
||||
|
||||
test('serializeGitHubReleasesAsChangelog keeps versioned notes accessible', () => {
|
||||
const changelog = serializeGitHubReleasesAsChangelog([
|
||||
{
|
||||
tag_name: 'v0.8.0',
|
||||
body: `* add thing ([#1](https://example.com)) ([abc1234](https://example.com))
|
||||
* fix another thing`,
|
||||
},
|
||||
])
|
||||
|
||||
expect(getReleaseNotesForVersion('0.8.0', changelog)).toEqual([
|
||||
'add thing',
|
||||
'fix another thing',
|
||||
])
|
||||
})
|
||||
|
||||
test('getRecentReleaseNotes treats legacy internal seen versions as unseen', () => {
|
||||
expect(
|
||||
getRecentReleaseNotes('0.8.0', '99.0.0', '## 0.8.0\n- latest change'),
|
||||
).toEqual(['latest change'])
|
||||
})
|
||||
|
||||
test('release-please changelog headings are normalized for version lookups', () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/Gitlawb/openclaude/compare/v0.7.0...v0.8.0) (2026-05-02)
|
||||
|
||||
### Features
|
||||
|
||||
* add thing
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/openclaude/compare/v0.6.0...v0.7.0) (2026-04-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix thing`
|
||||
|
||||
expect(getReleaseNotesForVersion('0.8.0', changelog)).toEqual(['add thing'])
|
||||
expect(getRecentReleaseNotes('0.8.0', '0.7.0', changelog)).toEqual([
|
||||
'add thing',
|
||||
])
|
||||
})
|
||||
|
||||
test('getReleaseTagUrl normalizes build metadata to the public tag', () => {
|
||||
expect(getReleaseTagUrl('0.8.0+abc123')).toBe(
|
||||
'https://github.com/Gitlawb/openclaude/releases/tag/v0.8.0',
|
||||
)
|
||||
})
|
||||
|
||||
test('formatReleaseNotesForDisplay renders section headers and bullets', () => {
|
||||
expect(
|
||||
formatReleaseNotesForDisplay([
|
||||
'__section__:Features',
|
||||
'add thing',
|
||||
'__section__:Bug Fixes',
|
||||
'fix bug',
|
||||
]),
|
||||
).toBe('Features:\n- add thing\n\nBug Fixes:\n- fix bug')
|
||||
})
|
||||
|
||||
test('sliceReleaseNotesForDisplay preserves headers without counting them', () => {
|
||||
expect(
|
||||
sliceReleaseNotesForDisplay(
|
||||
[
|
||||
'__section__:Features',
|
||||
'add thing',
|
||||
'__section__:Bug Fixes',
|
||||
'fix bug',
|
||||
],
|
||||
1,
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('sliceReleaseNotesForDisplay keeps total rendered lines within budget', () => {
|
||||
expect(
|
||||
sliceReleaseNotesForDisplay(
|
||||
[
|
||||
'__section__:Features',
|
||||
'add thing',
|
||||
'__section__:Bug Fixes',
|
||||
'fix bug',
|
||||
],
|
||||
3,
|
||||
),
|
||||
).toEqual(['__section__:Features', 'add thing'])
|
||||
})
|
||||
+279
-42
@@ -9,26 +9,39 @@ import { toError } from './errors.js'
|
||||
import { logError } from './log.js'
|
||||
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
||||
import { gt } from './semver.js'
|
||||
import {
|
||||
normalizePublicVersion,
|
||||
OPENCLAUDE_RELEASES_URL,
|
||||
publicBuildVersion,
|
||||
} from './version.js'
|
||||
|
||||
const MAX_RELEASE_NOTES_SHOWN = 5
|
||||
const RELEASES_API_URL =
|
||||
'https://api.github.com/repos/Gitlawb/openclaude/releases?per_page=10'
|
||||
const SECTION_HEADER_PREFIX = '__section__:'
|
||||
|
||||
type GitHubRelease = {
|
||||
body?: string | null
|
||||
draft?: boolean
|
||||
prerelease?: boolean
|
||||
tag_name?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* We fetch the changelog from GitHub instead of bundling it with the build.
|
||||
* We fetch OpenClaude release notes from GitHub instead of bundling them with
|
||||
* the build.
|
||||
*
|
||||
* This is necessary because Ink's static rendering makes it difficult to
|
||||
* dynamically update/show components after initial render. By storing the
|
||||
* changelog in config, we ensure it's available on the next startup without
|
||||
* requiring a full re-render of the current UI.
|
||||
* fetched notes in config, we ensure they're available on the next startup
|
||||
* without requiring a full re-render of the current UI.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. User updates to a new version
|
||||
* 2. We fetch the changelog in the background and store it in config
|
||||
* 3. Next time the user starts Claude, the cached changelog is available immediately
|
||||
* 2. We fetch GitHub release notes in the background and store them in config
|
||||
* 3. Next startup, the cached release notes are available immediately
|
||||
*/
|
||||
export const CHANGELOG_URL =
|
||||
'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md'
|
||||
const RAW_CHANGELOG_URL =
|
||||
'https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/CHANGELOG.md'
|
||||
export const RELEASES_URL = OPENCLAUDE_RELEASES_URL
|
||||
|
||||
/**
|
||||
* Get the path for the cached changelog file.
|
||||
@@ -47,6 +60,156 @@ export function _resetChangelogCacheForTesting(): void {
|
||||
changelogMemoryCache = null
|
||||
}
|
||||
|
||||
function sanitizeReleaseNote(note: string): string {
|
||||
let sanitized = note
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/(^|[\s([{])_([^_\s][^_]*?[^_\s])_(?=$|[\s)\]}:;,.!?])/g, '$1$2')
|
||||
.trim()
|
||||
|
||||
while (true) {
|
||||
const next = sanitized
|
||||
.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '')
|
||||
.trim()
|
||||
if (next === sanitized) {
|
||||
break
|
||||
}
|
||||
sanitized = next
|
||||
}
|
||||
|
||||
return sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim()
|
||||
}
|
||||
|
||||
function encodeSectionHeader(title: string): string {
|
||||
return `${SECTION_HEADER_PREFIX}${title}`
|
||||
}
|
||||
|
||||
export function isReleaseSectionHeader(note: string): boolean {
|
||||
return note.startsWith(SECTION_HEADER_PREFIX)
|
||||
}
|
||||
|
||||
export function getReleaseSectionHeaderTitle(note: string): string {
|
||||
return isReleaseSectionHeader(note)
|
||||
? note.slice(SECTION_HEADER_PREFIX.length)
|
||||
: note
|
||||
}
|
||||
|
||||
export function parseGitHubReleaseBody(body: string): string[] {
|
||||
const notes: string[] = []
|
||||
let pendingSection: string | null = null
|
||||
|
||||
for (const rawLine of body.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
const title = sanitizeReleaseNote(line.slice(4))
|
||||
pendingSection = title || null
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.startsWith('- ') && !line.startsWith('* ')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const note = sanitizeReleaseNote(line.slice(2).trim())
|
||||
if (!note) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (pendingSection) {
|
||||
notes.push(encodeSectionHeader(pendingSection))
|
||||
pendingSection = null
|
||||
}
|
||||
|
||||
notes.push(note)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
function releaseTagToVersion(tagName: string): string {
|
||||
return normalizePublicVersion(tagName)
|
||||
}
|
||||
|
||||
export function serializeGitHubReleasesAsChangelog(
|
||||
releases: GitHubRelease[],
|
||||
): string {
|
||||
return releases
|
||||
.filter(release => !release.draft && !release.prerelease)
|
||||
.map(release => {
|
||||
const version = release.tag_name
|
||||
? releaseTagToVersion(release.tag_name)
|
||||
: ''
|
||||
const notes = parseGitHubReleaseBody(release.body ?? '')
|
||||
if (!version || notes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [`## ${version}`, ...notes.map(note => `- ${note}`)].join('\n')
|
||||
})
|
||||
.filter((section): section is string => section !== null)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function getReleaseNotesForVersionFromReleases(
|
||||
version: string,
|
||||
releases: GitHubRelease[],
|
||||
): string[] {
|
||||
const normalizedVersion = normalizePublicVersion(version)
|
||||
const release = releases.find(candidate => {
|
||||
if (!candidate.tag_name || candidate.draft || candidate.prerelease) {
|
||||
return false
|
||||
}
|
||||
return releaseTagToVersion(candidate.tag_name) === normalizedVersion
|
||||
})
|
||||
|
||||
return release ? parseGitHubReleaseBody(release.body ?? '') : []
|
||||
}
|
||||
|
||||
async function fetchGitHubReleases(): Promise<GitHubRelease[]> {
|
||||
const response = await axios.get<GitHubRelease[]>(RELEASES_API_URL, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'openclaude',
|
||||
},
|
||||
})
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.status === 200 ? response.data : []
|
||||
}
|
||||
|
||||
async function storeSerializedChangelog(changelogContent: string): Promise<void> {
|
||||
// Skip write if content unchanged — writing Date.now() defeats the
|
||||
// dirty-check in saveGlobalConfig since the timestamp always differs.
|
||||
if (changelogContent === changelogMemoryCache) {
|
||||
return
|
||||
}
|
||||
|
||||
const cachePath = getChangelogCachePath()
|
||||
|
||||
// Ensure cache directory exists
|
||||
await mkdir(dirname(cachePath), { recursive: true })
|
||||
|
||||
// Write changelog to cache file
|
||||
await writeFile(cachePath, changelogContent, { encoding: 'utf-8' })
|
||||
changelogMemoryCache = changelogContent
|
||||
|
||||
// Update timestamp in config
|
||||
const changelogLastFetched = Date.now()
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
changelogLastFetched,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate changelog from old config-based storage to file-based storage.
|
||||
* This should be called once at startup to ensure the migration happens
|
||||
@@ -90,32 +253,29 @@ export async function fetchAndStoreChangelog(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.get(RAW_CHANGELOG_URL)
|
||||
if (response.status === 200) {
|
||||
const changelogContent = response.data
|
||||
const releases = await fetchGitHubReleases()
|
||||
await storeSerializedChangelog(serializeGitHubReleasesAsChangelog(releases))
|
||||
}
|
||||
|
||||
// Skip write if content unchanged — writing Date.now() defeats the
|
||||
// dirty-check in saveGlobalConfig since the timestamp always differs.
|
||||
if (changelogContent === changelogMemoryCache) {
|
||||
return
|
||||
}
|
||||
|
||||
const cachePath = getChangelogCachePath()
|
||||
|
||||
// Ensure cache directory exists
|
||||
await mkdir(dirname(cachePath), { recursive: true })
|
||||
|
||||
// Write changelog to cache file
|
||||
await writeFile(cachePath, changelogContent, { encoding: 'utf-8' })
|
||||
changelogMemoryCache = changelogContent
|
||||
|
||||
// Update timestamp in config
|
||||
const changelogLastFetched = Date.now()
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
changelogLastFetched,
|
||||
}))
|
||||
export async function fetchReleaseNotesForVersion(
|
||||
version: string,
|
||||
): Promise<string[]> {
|
||||
if (getIsNonInteractiveSession()) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (isEssentialTrafficOnly()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const releases = await fetchGitHubReleases()
|
||||
const notes = getReleaseNotesForVersionFromReleases(version, releases)
|
||||
|
||||
if (notes.length > 0) {
|
||||
await storeSerializedChangelog(serializeGitHubReleasesAsChangelog(releases))
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +309,7 @@ export function getStoredChangelogFromMemory(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a changelog string in markdown format into a structured format
|
||||
* Parses a cached release-notes string into a structured format.
|
||||
* @param content - The changelog content string
|
||||
* @returns Record mapping version numbers to arrays of release notes
|
||||
*/
|
||||
@@ -167,19 +327,21 @@ export function parseChangelog(content: string): Record<string, string[]> {
|
||||
const lines = section.trim().split('\n')
|
||||
if (lines.length === 0) continue
|
||||
|
||||
// Extract version from the first line
|
||||
// Handle both "1.2.3" and "1.2.3 - YYYY-MM-DD" formats
|
||||
// Normalize public versions so plain headings, dated headings, and
|
||||
// release-please markdown links all map to the same lookup key.
|
||||
const versionLine = lines[0]
|
||||
if (!versionLine) continue
|
||||
|
||||
// First part before any dash is the version
|
||||
const version = versionLine.split(' - ')[0]?.trim() || ''
|
||||
const version = normalizePublicVersion(versionLine)
|
||||
if (!version) continue
|
||||
|
||||
// Extract bullet points
|
||||
const notes = lines
|
||||
.slice(1)
|
||||
.filter(line => line.trim().startsWith('- '))
|
||||
.filter(line => {
|
||||
const trimmed = line.trim()
|
||||
return trimmed.startsWith('- ') || trimmed.startsWith('* ')
|
||||
})
|
||||
.map(line => line.trim().substring(2).trim())
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -214,7 +376,18 @@ export function getRecentReleaseNotes(
|
||||
|
||||
// Strip SHA from both versions to compare only the base versions
|
||||
const baseCurrentVersion = coerce(currentVersion)
|
||||
const basePreviousVersion = previousVersion ? coerce(previousVersion) : null
|
||||
let basePreviousVersion = previousVersion ? coerce(previousVersion) : null
|
||||
|
||||
// Older OpenClaude builds stored the internal compatibility version
|
||||
// (e.g. 99.0.0) as the "seen" marker. Treat that as unseen so users
|
||||
// can start receiving release notes keyed to the public version.
|
||||
if (
|
||||
baseCurrentVersion &&
|
||||
basePreviousVersion &&
|
||||
gt(basePreviousVersion.version, baseCurrentVersion.version)
|
||||
) {
|
||||
basePreviousVersion = null
|
||||
}
|
||||
|
||||
if (
|
||||
!basePreviousVersion ||
|
||||
@@ -239,6 +412,70 @@ export function getRecentReleaseNotes(
|
||||
return []
|
||||
}
|
||||
|
||||
export function getReleaseNotesForVersion(
|
||||
version: string,
|
||||
changelogContent: string = getStoredChangelogFromMemory(),
|
||||
): string[] {
|
||||
try {
|
||||
const releaseNotes = parseChangelog(changelogContent)
|
||||
return releaseNotes[normalizePublicVersion(version)] ?? []
|
||||
} catch (error) {
|
||||
logError(toError(error))
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReleaseNotesForDisplay(notes: string[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const note of notes) {
|
||||
if (isReleaseSectionHeader(note)) {
|
||||
if (lines.length > 0) {
|
||||
lines.push('')
|
||||
}
|
||||
lines.push(`${getReleaseSectionHeaderTitle(note)}:`)
|
||||
continue
|
||||
}
|
||||
|
||||
lines.push(`- ${note}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function sliceReleaseNotesForDisplay(
|
||||
notes: string[],
|
||||
maxItems: number,
|
||||
): string[] {
|
||||
if (maxItems <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
|
||||
for (const note of notes) {
|
||||
if (result.length >= maxItems) {
|
||||
break
|
||||
}
|
||||
|
||||
if (isReleaseSectionHeader(note)) {
|
||||
if (result.length + 1 >= maxItems) {
|
||||
break
|
||||
}
|
||||
result.push(note)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(note)
|
||||
}
|
||||
|
||||
while (result.length > 0 && isReleaseSectionHeader(result[result.length - 1]!)) {
|
||||
result.pop()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all release notes as an array of [version, notes] arrays.
|
||||
* Versions are sorted with oldest first.
|
||||
@@ -286,7 +523,7 @@ export function getAllReleaseNotes(
|
||||
*/
|
||||
export async function checkForReleaseNotes(
|
||||
lastSeenVersion: string | null | undefined,
|
||||
currentVersion: string = MACRO.VERSION,
|
||||
currentVersion: string = publicBuildVersion,
|
||||
): Promise<{ hasReleaseNotes: boolean; releaseNotes: string[] }> {
|
||||
// For Ant builds, use VERSION_CHANGELOG bundled at build time
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
@@ -334,7 +571,7 @@ export async function checkForReleaseNotes(
|
||||
*/
|
||||
export function checkForReleaseNotesSync(
|
||||
lastSeenVersion: string | null | undefined,
|
||||
currentVersion: string = MACRO.VERSION,
|
||||
currentVersion: string = publicBuildVersion,
|
||||
): { hasReleaseNotes: boolean; releaseNotes: string[] } {
|
||||
// For Ant builds, use VERSION_CHANGELOG bundled at build time
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { coerce } from 'semver'
|
||||
|
||||
export const OPENCLAUDE_RELEASES_URL =
|
||||
'https://github.com/Gitlawb/openclaude/releases'
|
||||
|
||||
export function normalizePublicVersion(version: string): string {
|
||||
const trimmedVersion = version.trim()
|
||||
const coercedVersion = coerce(trimmedVersion)
|
||||
if (coercedVersion) {
|
||||
return coercedVersion.version
|
||||
}
|
||||
return trimmedVersion.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
function readPackageVersionFromDisk(): string | null {
|
||||
let currentDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
while (true) {
|
||||
const packageJsonPath = join(currentDir, 'package.json')
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
|
||||
version?: unknown
|
||||
}
|
||||
if (typeof pkg.version === 'string' && pkg.version.trim()) {
|
||||
return pkg.version
|
||||
}
|
||||
} catch {
|
||||
// Keep walking upward until we find the package root.
|
||||
}
|
||||
|
||||
const parentDir = dirname(currentDir)
|
||||
if (parentDir === currentDir) {
|
||||
return null
|
||||
}
|
||||
currentDir = parentDir
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackBuildVersion = (() => {
|
||||
try {
|
||||
return MACRO.VERSION
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
})()
|
||||
|
||||
export const publicBuildVersion = normalizePublicVersion(
|
||||
readPackageVersionFromDisk() ?? fallbackBuildVersion,
|
||||
)
|
||||
|
||||
export function getReleaseTagUrl(version: string = publicBuildVersion): string {
|
||||
return `${OPENCLAUDE_RELEASES_URL}/tag/v${normalizePublicVersion(version)}`
|
||||
}
|
||||
Reference in New Issue
Block a user