Skip to content

Commit 0fbd528

Browse files
committed
feat(upgrade): show changelog summary during CLI upgrade
Show inline changelog summaries when running `sentry cli upgrade`. When upgrading across multiple versions, displays a flat summary of new features, bug fixes, and performance improvements from all intermediate releases. - Parse GitHub Release bodies via `marked.lexer()` AST for stable channel - Parse conventional commit messages via GitHub Commits API for nightly - Fetch runs in parallel with binary download (zero added latency) - Filter to features/fixes/performance only (skip docs, internal changes) - Clamp output to ~1.3x terminal height to keep it scannable - Works with --check mode (preview what you would get) - Graceful degradation: offline, rate-limited, or network errors → no changelog Closes #421
1 parent 8c2f8b3 commit 0fbd528

File tree

7 files changed

+1385
-40
lines changed

7 files changed

+1385
-40
lines changed

AGENTS.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -893,9 +893,6 @@ mock.module("./some-module", () => ({
893893
894894
### Architecture
895895
896-
<!-- lore:019d2d10-671c-77d8-9dbc-c32d1604dcf7 -->
897-
* **AsyncIterable streaming for SDK implemented via AsyncChannel push/pull pattern**: \`src/lib/async-channel.ts\` provides a dual-queue channel: producer calls \`push()\`/\`close()\`/\`error()\`, consumer iterates via \`for await...of\`. \`break\` triggers \`onReturn\` callback for cleanup. \`executeWithStream()\` in \`sdk-invoke.ts\` runs the command in background, pipes \`captureObject\` calls to the channel, and returns the channel immediately. Streaming detection: \`hasStreamingFlag()\` checks for \`--refresh\`/\`--follow\`/\`-f\`. \`buildInvoker\` accepts \`meta.streaming\` flag; \`buildRunner\` auto-detects from args. Abort wiring: \`AbortController\` created per stream, signal placed on fake \`process.abortSignal\`, \`channel.onReturn\` calls \`controller.abort()\`. Both \`log/list.ts\` and \`dashboard/view.ts\` check \`this.process?.abortSignal\` alongside SIGINT. Codegen generates callable interface overloads for streaming commands.
898-
899896
<!-- lore:019cbeba-e4d3-748c-ad50-fe3c3d5c0a0d -->
900897
* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval.
901898

src/commands/cli/upgrade.ts

Lines changed: 99 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import { formatUpgradeResult } from "../../lib/formatters/human.js";
3535
import { CommandOutput } from "../../lib/formatters/output.js";
3636
import { logger } from "../../lib/logger.js";
3737
import { withProgress } from "../../lib/polling.js";
38+
import {
39+
type ChangelogSummary,
40+
fetchChangelog,
41+
} from "../../lib/release-notes.js";
3842
import {
3943
detectInstallationMethod,
4044
executeUpgrade,
@@ -76,6 +80,8 @@ export type UpgradeResult = {
7680
offline?: boolean;
7781
/** Warnings to display (e.g., PATH shadowing from old package manager install) */
7882
warnings?: string[];
83+
/** Changelog summary for the version range. Absent for offline or on fetch failure. */
84+
changelog?: ChangelogSummary;
7985
};
8086

8187
type UpgradeFlags = {
@@ -592,6 +598,46 @@ function persistChannel(
592598
}
593599
}
594600

601+
/**
602+
* Start a best-effort changelog fetch in parallel with the binary download.
603+
*
604+
* Returns a promise that resolves to the changelog or undefined. Never
605+
* throws — errors are swallowed so the upgrade is not blocked.
606+
*/
607+
function startChangelogFetch(
608+
channel: ReleaseChannel,
609+
currentVersion: string,
610+
targetVersion: string,
611+
offline: boolean
612+
): Promise<ChangelogSummary | undefined> {
613+
if (offline || currentVersion === targetVersion) {
614+
return Promise.resolve(undefined);
615+
}
616+
return fetchChangelog(channel, currentVersion, targetVersion)
617+
.then((result) => result ?? undefined)
618+
.catch(() => undefined as undefined);
619+
}
620+
621+
/**
622+
* Build a check-only result with optional changelog, ready to yield.
623+
*/
624+
async function buildCheckResultWithChangelog(opts: {
625+
target: string;
626+
versionArg: string | undefined;
627+
method: InstallationMethod;
628+
channel: ReleaseChannel;
629+
flags: UpgradeFlags;
630+
offline: boolean;
631+
changelogPromise: Promise<ChangelogSummary | undefined>;
632+
}): Promise<UpgradeResult> {
633+
const result = buildCheckResult(opts);
634+
if (opts.offline) {
635+
result.offline = true;
636+
}
637+
result.changelog = await opts.changelogPromise;
638+
return result;
639+
}
640+
595641
export const upgradeCommand = buildCommand({
596642
docs: {
597643
brief: "Update the Sentry CLI to the latest version",
@@ -672,25 +718,48 @@ export const upgradeCommand = buildCommand({
672718
persistChannel(channel, channelChanged, version),
673719
})
674720
);
721+
// Early exit for check-only (online) and up-to-date results.
675722
if (resolved.kind === "done") {
676-
return yield new CommandOutput(resolved.result);
723+
const result = resolved.result;
724+
// For --check with a version diff, fetch changelog before returning.
725+
if (
726+
result.action === "checked" &&
727+
result.currentVersion !== result.targetVersion
728+
) {
729+
result.changelog = await startChangelogFetch(
730+
channel,
731+
CLI_VERSION,
732+
result.targetVersion,
733+
false
734+
);
735+
}
736+
return yield new CommandOutput(result);
677737
}
678738

679739
const { target, offline } = resolved;
680740

681-
// --check with offline just reports the cached version
741+
// Start changelog fetch early — it runs in parallel with the download.
742+
const changelogPromise = startChangelogFetch(
743+
channel,
744+
CLI_VERSION,
745+
target,
746+
offline
747+
);
748+
749+
// --check with offline fallback: resolveTargetWithFallback returns
750+
// kind: "target" for offline check, so guard against actual upgrade.
682751
if (flags.check) {
683-
const checkResult = buildCheckResult({
684-
target,
685-
versionArg,
686-
method,
687-
channel,
688-
flags,
689-
});
690-
if (offline) {
691-
checkResult.offline = true;
692-
}
693-
return yield new CommandOutput(checkResult);
752+
return yield new CommandOutput(
753+
await buildCheckResultWithChangelog({
754+
target,
755+
versionArg,
756+
method,
757+
channel,
758+
flags,
759+
offline,
760+
changelogPromise,
761+
})
762+
);
694763
}
695764

696765
// Skip if already on target — unless forced or switching channels
@@ -708,38 +777,30 @@ export const upgradeCommand = buildCommand({
708777
const downgrade = isDowngrade(CLI_VERSION, target);
709778
log.debug(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}`);
710779

711-
// Nightly is GitHub-only. If the current install method is not curl,
712-
// migrate to a standalone binary first then return — the migration
713-
// handles setup internally.
780+
// Perform the actual upgrade
781+
let warnings: string[] | undefined;
714782
if (channel === "nightly" && method !== "curl") {
715-
const warnings = await migrateToStandaloneForNightly(
783+
// Nightly is GitHub-only. If the current install method is not curl,
784+
// migrate to a standalone binary — the migration handles setup internally.
785+
warnings = await migrateToStandaloneForNightly(
716786
method,
717787
target,
718788
versionArg,
719789
flags.json
720790
);
721-
yield new CommandOutput({
722-
action: downgrade ? "downgraded" : "upgraded",
723-
currentVersion: CLI_VERSION,
724-
targetVersion: target,
725-
channel,
791+
} else {
792+
await executeStandardUpgrade({
726793
method,
727-
forced: flags.force,
728-
warnings,
729-
} satisfies UpgradeResult);
730-
return;
794+
channel,
795+
versionArg,
796+
target,
797+
execPath: this.process.execPath,
798+
offline,
799+
json: flags.json,
800+
});
731801
}
732802

733-
await executeStandardUpgrade({
734-
method,
735-
channel,
736-
versionArg,
737-
target,
738-
execPath: this.process.execPath,
739-
offline,
740-
json: flags.json,
741-
});
742-
803+
const changelog = await changelogPromise;
743804
yield new CommandOutput({
744805
action: downgrade ? "downgraded" : "upgraded",
745806
currentVersion: CLI_VERSION,
@@ -748,6 +809,8 @@ export const upgradeCommand = buildCommand({
748809
method,
749810
forced: flags.force,
750811
offline: offline || undefined,
812+
warnings,
813+
changelog,
751814
} satisfies UpgradeResult);
752815
return;
753816
},

src/lib/delta-upgrade.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ export type GitHubAsset = {
146146
export type GitHubRelease = {
147147
tag_name: string;
148148
assets: GitHubAsset[];
149+
/** Markdown release notes body. May be empty or absent for pre-releases. */
150+
body?: string;
149151
};
150152

151153
/**

src/lib/formatters/human.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2080,6 +2080,101 @@ export function formatFixResult(data: FixResult): string {
20802080
/** Structured upgrade result (imported from the command module) */
20812081
type UpgradeResult = import("../../commands/cli/upgrade.js").UpgradeResult;
20822082

2083+
/** Category → markdown heading for changelog sections */
2084+
type ChangeCategory = import("../release-notes.js").ChangeCategory;
2085+
2086+
/** Heading text for each changelog category */
2087+
const CATEGORY_HEADINGS: Record<ChangeCategory, string> = {
2088+
features: "#### New Features ✨",
2089+
fixes: "#### Bug Fixes 🐛",
2090+
performance: "#### Performance ⚡",
2091+
};
2092+
2093+
/**
2094+
* Max terminal height multiplier for changelog clamping.
2095+
*
2096+
* When the total rendered changelog would exceed this fraction of the
2097+
* terminal height, items are truncated to keep output scannable.
2098+
*/
2099+
const CHANGELOG_HEIGHT_FACTOR = 1.3;
2100+
2101+
/** Lines consumed by the upgrade status header/metadata above the changelog */
2102+
const CHANGELOG_HEADER_OVERHEAD = 6;
2103+
2104+
/** Minimum number of changelog items to show even on tiny terminals */
2105+
const MIN_CHANGELOG_ITEMS = 5;
2106+
2107+
/** Default max items for non-TTY output (no terminal height available) */
2108+
const DEFAULT_MAX_CHANGELOG_ITEMS = 30;
2109+
2110+
/**
2111+
* Compute the maximum number of changelog lines based on terminal height.
2112+
*
2113+
* Uses ~1.3x the terminal height minus header overhead. Returns a generous
2114+
* default for non-TTY output where terminal height is unknown.
2115+
*/
2116+
function getMaxChangelogLines(): number {
2117+
// process.stdout.rows is allowed in formatters (not in command files)
2118+
const termHeight = process.stdout.rows;
2119+
if (!termHeight) {
2120+
return DEFAULT_MAX_CHANGELOG_ITEMS;
2121+
}
2122+
return Math.max(
2123+
MIN_CHANGELOG_ITEMS,
2124+
Math.floor(termHeight * CHANGELOG_HEIGHT_FACTOR) - CHANGELOG_HEADER_OVERHEAD
2125+
);
2126+
}
2127+
2128+
/**
2129+
* Format the changelog section as markdown for rendering.
2130+
*
2131+
* Re-serializes the filtered section markdown with category headings so
2132+
* that `renderMarkdown()` applies consistent heading/list styling.
2133+
* Clamps the rendered output to fit ~1.3x the terminal height.
2134+
*
2135+
* @param data - Upgrade result with changelog
2136+
* @returns Rendered changelog string, or empty string if no changelog
2137+
*/
2138+
function formatChangelog(data: UpgradeResult): string {
2139+
if (!data.changelog || data.changelog.sections.length === 0) {
2140+
return "";
2141+
}
2142+
2143+
const { changelog } = data;
2144+
const lines: string[] = ["", "### What's new", ""];
2145+
2146+
for (const section of changelog.sections) {
2147+
lines.push(CATEGORY_HEADINGS[section.category]);
2148+
lines.push(section.markdown);
2149+
}
2150+
2151+
if (changelog.truncated) {
2152+
const more = changelog.originalCount - changelog.totalItems;
2153+
lines.push(
2154+
`<muted>...and ${more} more changes — https://github.com/getsentry/cli/releases</muted>`
2155+
);
2156+
}
2157+
2158+
// Render through the markdown pipeline, then clamp to terminal height
2159+
const rendered = renderMarkdown(lines.join("\n"));
2160+
const renderedLines = rendered.split("\n");
2161+
const maxLines = getMaxChangelogLines();
2162+
2163+
if (renderedLines.length <= maxLines) {
2164+
return rendered;
2165+
}
2166+
2167+
// Truncate and add a "more" indicator
2168+
const clamped = renderedLines.slice(0, maxLines);
2169+
const remaining = changelog.originalCount - changelog.totalItems;
2170+
const moreText =
2171+
remaining > 0
2172+
? "...and more — https://github.com/getsentry/cli/releases"
2173+
: "...truncated — https://github.com/getsentry/cli/releases";
2174+
clamped.push(isPlainOutput() ? moreText : muted(moreText));
2175+
return clamped.join("\n");
2176+
}
2177+
20832178
/** Action descriptions for human-readable output */
20842179
const ACTION_DESCRIPTIONS: Record<UpgradeResult["action"], string> = {
20852180
upgraded: "Upgraded",
@@ -2159,7 +2254,15 @@ export function formatUpgradeResult(data: UpgradeResult): string {
21592254
}
21602255
}
21612256

2162-
return renderMarkdown(lines.join("\n"));
2257+
const result = renderMarkdown(lines.join("\n"));
2258+
2259+
// Append changelog if available
2260+
const changelogOutput = formatChangelog(data);
2261+
if (changelogOutput) {
2262+
return `${result}\n${changelogOutput}`;
2263+
}
2264+
2265+
return result;
21632266
}
21642267

21652268
// Dashboard formatters

0 commit comments

Comments
 (0)