diff --git a/src/commands/release-notes/release-notes.ts b/src/commands/release-notes/release-notes.ts index dfd7aec2..80fc7f6d 100644 --- a/src/commands/release-notes/release-notes.ts +++ b/src/commands/release-notes/release-notes.ts @@ -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 { - // Try to fetch the latest changelog with a 500ms timeout - let freshNotes: Array<[string, string[]]> = [] +async function getCurrentReleaseNotes(): Promise { + try { + const freshNotes = await fetchReleaseNotesForVersion(publicBuildVersion) + if (freshNotes.length > 0) { + return freshNotes + } + } catch { + // Fall back to cached notes below. + } try { const timeoutPromise = new Promise((_, 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 { + 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}`, } } diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index b42e8dac..5aa5dd1b 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -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) { diff --git a/src/components/LogoV2/feedConfigs.tsx b/src/components/LogoV2/feedConfigs.tsx index 97d22d94..0e664971 100644 --- a/src/components/LogoV2/feedConfigs.tsx +++ b/src/components/LogoV2/feedConfigs.tsx @@ -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 }; diff --git a/src/utils/logoV2Utils.ts b/src/utils/logoV2Utils.ts index 985b81ba..cfc7bf1f 100644 --- a/src/utils/logoV2Utils.ts +++ b/src/utils/logoV2Utils.ts @@ -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) } diff --git a/src/utils/releaseNotes.test.ts b/src/utils/releaseNotes.test.ts new file mode 100644 index 00000000..16246240 --- /dev/null +++ b/src/utils/releaseNotes.test.ts @@ -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']) +}) diff --git a/src/utils/releaseNotes.ts b/src/utils/releaseNotes.ts index 3f0448e6..b347b214 100644 --- a/src/utils/releaseNotes.ts +++ b/src/utils/releaseNotes.ts @@ -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 { + const response = await axios.get(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 { + // 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 { 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 { + 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 { 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') { diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 00000000..448fe102 --- /dev/null +++ b/src/utils/version.ts @@ -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)}` +}