From 64792decb8011c420da2b00b1c8ad50df0307a57 Mon Sep 17 00:00:00 2001 From: shashank-100 Date: Mon, 7 Sep 2026 11:50:51 +0530 Subject: [PATCH] feat(cli): link release notes after hunk update --- .changeset/update-release-notes-link.md | 5 ++ .../hunk/src/core/install/selfUpdate.test.ts | 74 ++++++++++++++++++ packages/hunk/src/core/install/selfUpdate.ts | 20 +++++ .../hunk/src/core/run/releaseNotes.test.ts | 77 +++++++++++++++++++ packages/hunk/src/core/run/releaseNotes.ts | 49 ++++++++++++ 5 files changed, 225 insertions(+) create mode 100644 .changeset/update-release-notes-link.md create mode 100644 packages/hunk/src/core/run/releaseNotes.test.ts create mode 100644 packages/hunk/src/core/run/releaseNotes.ts diff --git a/.changeset/update-release-notes-link.md b/.changeset/update-release-notes-link.md new file mode 100644 index 000000000..0005ff9ec --- /dev/null +++ b/.changeset/update-release-notes-link.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Link the matching hunk.dev release notes after `hunk update` installs a version, reports one as already current, or checks for a newer one. diff --git a/packages/hunk/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts index c0ac160e2..c7939fa6d 100644 --- a/packages/hunk/src/core/install/selfUpdate.test.ts +++ b/packages/hunk/src/core/install/selfUpdate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; +import { UNKNOWN_CLI_VERSION } from "../run/version"; import type { InstallSource } from "./installSource"; import { parseUpdateMethod, @@ -371,4 +372,77 @@ describe("hunk update", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Hunk was installed with mise."); }); + + test("links the release notes for the version it installed", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.0.0", + latestVersion: "1.1.0", + }); + + expect(result.stdout).toContain("Updated hunk to 1.1.0."); + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0"); + }); + + test("links the release notes for a version that is already current", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.1.0", + latestVersion: "1.1.0", + }); + + expect(result.stdout).toContain("hunk 1.1.0 is already up to date."); + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0"); + }); + + test("links the release notes for a prerelease it installed", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.0.0", + latestVersion: "1.1.0-beta.0", + input: { version: "1.1.0-beta.0" }, + }); + + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0-beta-0"); + }); + + test("links the release notes for an explicit downgrade", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.1.0", + latestVersion: "1.1.0", + input: { version: "1.0.5" }, + }); + + expect(result.stdout).toContain("Updated hunk to 1.0.5."); + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.0/#v1-0-5"); + }); + + test("links the latest available release notes for --check", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.0.0", + latestVersion: "1.1.0", + input: { check: true }, + }); + + expect(result.stdout).toContain("An update is available."); + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0"); + }); + + test("omits the release notes link for a version with no changelog page", async () => { + // `--check` reports the installed version too, and a local build reports `0.0.0-unknown`. + // Only the latest release gets a link; printing a plausible-looking URL to a 404 for the + // unparseable one would be worse than printing nothing. + const result = await runUpdate({ + installSource: "npm", + installedVersion: UNKNOWN_CLI_VERSION, + latestVersion: "1.1.0", + input: { check: true }, + }); + + expect(result.stdout).toContain(UNKNOWN_CLI_VERSION); + expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0"); + expect(result.stdout).not.toContain(`#v0-0-0-unknown`); + }); }); diff --git a/packages/hunk/src/core/install/selfUpdate.ts b/packages/hunk/src/core/install/selfUpdate.ts index 429a14b67..2dfac1c63 100644 --- a/packages/hunk/src/core/install/selfUpdate.ts +++ b/packages/hunk/src/core/install/selfUpdate.ts @@ -1,6 +1,7 @@ import { HunkUserError } from "../run/errors"; import { detectInstallSource, detectNpmClient, type InstallSource } from "./installSource"; import { fetchChannelVersions, type FetchImpl } from "./latestRelease"; +import { releaseNotesUrl } from "../run/releaseNotes"; import { isComparableVersion, isNewerVersion, resolveCliVersion } from "../run/version"; /** @@ -42,6 +43,20 @@ function listUpdateMethods() { return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`; } +/** + * Print the release-notes link for one version, when that version has a changelog page. + * + * A plain URL rather than an OSC-8 hyperlink: this output is piped and captured in CI as often as + * it is read in a terminal, and an escape-wrapped link is invisible to everything downstream. + * Silent when the version has no page, so a dev build prints its normal output and no dead link. + */ +function reportReleaseNotes(io: SelfUpdateIo, version: string) { + const url = releaseNotesUrl(version); + if (url) { + io.stdout(`Release notes: ${url}\n`); + } +} + export interface SelfUpdateInput { /** Version to install; the channel's newest release when omitted. */ version?: string; @@ -329,6 +344,9 @@ export async function runSelfUpdateCommand( ? "An update is available. Run `hunk update` to install it.\n" : "Hunk is up to date.\n", ); + // Link whichever version this run reported on: an explicit `--version` request is what the + // user asked about, otherwise the channel's latest is what the lines above describe. + reportReleaseNotes(io, input.version ?? latestVersion); return 0; } @@ -345,6 +363,7 @@ export async function runSelfUpdateCommand( : !isComparableVersion(installedVersion) || !isNewerVersion(installedVersion, targetVersion); if (alreadyCurrent) { io.stdout(`hunk ${installedVersion} is already up to date.\n`); + reportReleaseNotes(io, installedVersion); return 0; } @@ -375,5 +394,6 @@ export async function runSelfUpdateCommand( } io.stdout(`Updated hunk to ${targetVersion}.\n`); + reportReleaseNotes(io, targetVersion); return 0; } diff --git a/packages/hunk/src/core/run/releaseNotes.test.ts b/packages/hunk/src/core/run/releaseNotes.test.ts new file mode 100644 index 000000000..956c15640 --- /dev/null +++ b/packages/hunk/src/core/run/releaseNotes.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { releaseNotesUrl } from "./releaseNotes"; +import { UNKNOWN_CLI_VERSION } from "./version"; + +const CHANGELOG_CONTENT_DIR = join( + import.meta.dir, + "..", + "..", + "..", + "..", + "..", + "website", + "src", + "content", + "docs", + "changelog", +); + +describe("releaseNotesUrl", () => { + test("addresses one stable release on its series page", () => { + expect(releaseNotesUrl("0.21.0")).toBe("https://hunk.dev/changelog/0.21/#v0-21-0"); + expect(releaseNotesUrl("0.18.2")).toBe("https://hunk.dev/changelog/0.18/#v0-18-2"); + }); + + test("keeps a prerelease on the series page of the release it leads to", () => { + expect(releaseNotesUrl("0.21.0-beta.0")).toBe( + "https://hunk.dev/changelog/0.21/#v0-21-0-beta-0", + ); + }); + + test("carries a major version into the series path", () => { + expect(releaseNotesUrl("1.4.0")).toBe("https://hunk.dev/changelog/1.4/#v1-4-0"); + }); + + test("returns nothing for a version with no changelog page", () => { + // A link is only useful if it resolves. Anything that is not a normalized semver — a dev + // build, a distro-patched string, the unknown-version sentinel — has no page to point at. + for (const version of [UNKNOWN_CLI_VERSION, "", "dev", "0.21", "v0.21.0", "0.21.0+local"]) { + expect(releaseNotesUrl(version)).toBeUndefined(); + } + }); + + test("resolves against the changelog pages the website actually publishes", () => { + // The generated pages own the real addressing: each carries its series slug in frontmatter and + // one anchor per release. Reading them keeps this URL builder honest even though the generator + // lives in build tooling the shipped CLI cannot import. + const anchorPattern = /id="(v[0-9][0-9a-z-]*)"/g; + let checked = 0; + + for (const entry of readdirSync(CHANGELOG_CONTENT_DIR)) { + if (!entry.endsWith(".md")) continue; + + const page = readFileSync(join(CHANGELOG_CONTENT_DIR, entry), "utf8"); + const slug = page.match(/^slug:\s*(\S+)$/m)?.[1]; + if (!slug) continue; + + for (const [, anchor] of page.matchAll(anchorPattern)) { + if (!anchor) continue; + + // Recover the version this anchor addresses, then confirm the builder reproduces both + // halves of the published URL from that version alone. + const version = anchor + .slice(1) + .replace(/-(?=(?:beta|alpha|rc)\b)/, "@") + .replaceAll("-", ".") + .replace("@", "-"); + expect(releaseNotesUrl(version)).toBe(`https://hunk.dev/${slug}/#${anchor}`); + checked += 1; + } + } + + // Guard the guard: a glob that silently matched nothing would prove nothing. + expect(checked).toBeGreaterThan(20); + }); +}); diff --git a/packages/hunk/src/core/run/releaseNotes.ts b/packages/hunk/src/core/run/releaseNotes.ts new file mode 100644 index 000000000..46387a601 --- /dev/null +++ b/packages/hunk/src/core/run/releaseNotes.ts @@ -0,0 +1,49 @@ +/** + * Builds the hunk.dev changelog URL for one released version. + * + * The changelog groups releases into per-minor series pages and marks each release with a stable + * anchor, so an exact version addresses one entry rather than the top of a page. Both halves of + * that address are decided by `scripts/generate/generate-changelog.ts`, which renders the pages: + * the series slug from `minorSeriesOf` and the anchor from `versionAnchor`. The rules are restated + * here rather than imported because `scripts/` is build tooling the shipped CLI cannot depend on; + * `releaseNotes.test.ts` pins them against the generator so the two cannot drift apart silently. + */ +import { isComparableVersion } from "./version"; + +const CHANGELOG_BASE_URL = "https://hunk.dev/changelog"; + +/** + * The per-minor series page one version's notes live on: `1.2.3-beta.0` -> `1.2`. + * + * Prerelease identifiers are dropped first, so a prerelease lands on the same series page as the + * stable release it leads to — which is where the generator puts it. + */ +function minorSeriesOf(version: string) { + const [major = "0", minor = "0"] = version.split("-", 1)[0]?.split(".") ?? []; + return `${major}.${minor}`; +} + +/** + * The in-page anchor for one release: `0.21.0-beta.0` -> `v0-21-0-beta-0`. + * + * Every dot becomes a dash, including those inside a prerelease identifier, and the `v` prefix + * keeps the id from starting with a digit. + */ +function versionAnchor(version: string) { + return `v${version.replaceAll(".", "-")}`; +} + +/** + * Return the release-notes URL for one version, or undefined when it has no page to point at. + * + * Fails closed on anything that is not a normalized semver — a dev build, a distro-patched string, + * `0.0.0-unknown` — because a plausible-looking URL to a page that does not exist is worse than + * printing no link at all. + */ +export function releaseNotesUrl(version: string): string | undefined { + if (!isComparableVersion(version)) { + return undefined; + } + + return `${CHANGELOG_BASE_URL}/${minorSeriesOf(version)}/#${versionAnchor(version)}`; +}