From 99ab5375452971e472f492ae6b7b3a90dfdf4136 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 17:24:55 +0200 Subject: [PATCH 1/2] fix(update-modal): show release notes when upgrading from older versions The What's new modal only fetched GitHub's 30 newest releases and looked for an exact tag match on the previous version. Anyone upgrading from a version outside that page got an error and a modal stuck on 'Fetching release notes...'. Fetch 100 releases, show every stable release newer than the previous version, and close the modal when the fetch fails. --- src/gui/UpdateModal/UpdateModal.test.ts | 65 +++++++++++++++++++++++-- src/gui/UpdateModal/UpdateModal.ts | 42 ++++++++-------- src/gui/apiModernization.test.ts | 2 +- src/utils/semver.ts | 8 +++ 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/gui/UpdateModal/UpdateModal.test.ts b/src/gui/UpdateModal/UpdateModal.test.ts index cb3e18c24..a2e886ba8 100644 --- a/src/gui/UpdateModal/UpdateModal.test.ts +++ b/src/gui/UpdateModal/UpdateModal.test.ts @@ -1,5 +1,10 @@ +import { App } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getReleaseNotesAfter, renderVideoAttachments } from "./UpdateModal"; +import { + getReleaseNotesAfter, + renderVideoAttachments, + UpdateModal, +} from "./UpdateModal"; const requestUrlMock = vi.hoisted(() => vi.fn()); @@ -185,10 +190,62 @@ describe("getReleaseNotesAfter", () => { ); }); - it("throws a clear error when the start tag is absent from the releases array", async () => { + it("returns every stable release newer than a version missing from the list", async () => { + // A user upgrading from a version older than the fetched page (e.g. 1.18.1) + // must still get notes, not a "could not find release" failure. + const release = (tag_name: string, extra = {}) => ({ + tag_name, + body: tag_name, + draft: false, + prerelease: false, + ...extra, + }); + mockResponse(200, [ + release("2.10.0"), + release("2.9.5", { prerelease: true }), + release("2.9.4", { draft: true }), + release("2.9.0"), + release("not-a-version"), + release("1.18.1"), + release("1.18.0"), + ]); + + const releases = await getReleaseNotesAfter("chhoumann", "quickadd", "1.18.1"); + + expect(releases.map((r) => r.tag_name)).toEqual(["2.10.0", "2.9.0"]); + }); + + it("compares versions numerically, not as strings", async () => { + mockResponse(200, [ + { tag_name: "2.10.0", body: "", draft: false, prerelease: false }, + { tag_name: "2.9.0", body: "", draft: false, prerelease: false }, + ]); + + const releases = await getReleaseNotesAfter("chhoumann", "quickadd", "2.9.0"); + + expect(releases.map((r) => r.tag_name)).toEqual(["2.10.0"]); + }); + + it("rejects a previous version that is not valid semver", async () => { mockResponse(200, [{ tag_name: "9.9.9", body: "", draft: false, prerelease: false }]); - await expect(getReleaseNotesAfter("chhoumann", "quickadd", "1.0.0")).rejects.toThrow( - "Could not find release with tag 1.0.0", + await expect(getReleaseNotesAfter("chhoumann", "quickadd", "garbage")).rejects.toThrow( + "Invalid version garbage", ); }); }); + +describe("UpdateModal", () => { + beforeEach(() => { + requestUrlMock.mockReset(); + }); + + it("closes instead of staying on the loading state when the fetch fails", async () => { + mockResponse(403, { message: "API rate limit exceeded" }); + + const modal = new UpdateModal(new App() as never, "2.0.0"); + modal.open(); + expect(modal.contentEl.textContent).toContain("Fetching release notes..."); + + await vi.waitFor(() => expect(modal.containerEl.isConnected).toBe(false)); + }); +}); diff --git a/src/gui/UpdateModal/UpdateModal.ts b/src/gui/UpdateModal/UpdateModal.ts index 9a5529901..af763382b 100644 --- a/src/gui/UpdateModal/UpdateModal.ts +++ b/src/gui/UpdateModal/UpdateModal.ts @@ -7,6 +7,7 @@ import { requestUrl, } from "obsidian"; import { log } from "src/logger/logManager"; +import { compareSemver, parseSemver } from "src/utils/semver"; type Release = { tag_name: string; @@ -17,15 +18,14 @@ type Release = { }; /** - * Fetches the releases for a repository on GitHub and returns the release notes for every release - * that comes after a specific release. + * Fetches the releases for a repository on GitHub and returns every stable release + * whose version is newer than the given one, newest first. * * @param repoOwner The owner of the repository. * @param repoName The name of the repository. - * @param releaseTagName The tag name of the release to start getting release notes from. + * @param releaseTagName The version the user is upgrading from. * @returns An array of Release objects, each containing the tag name and release notes for a single release. - * @throws An error if there was an error fetching the releases or if the release with the specified tag name - * could not be found. + * @throws An error if the releases could not be fetched or the given version is not valid semver. */ export async function getReleaseNotesAfter( repoOwner: string, @@ -33,7 +33,9 @@ export async function getReleaseNotesAfter( releaseTagName: string ): Promise { const response = await requestUrl({ - url: `https://api.github.com/repos/${repoOwner}/${repoName}/releases`, + // The newest 100 releases (GitHub's page-size cap). Anyone further behind + // sees the latest 100. + url: `https://api.github.com/repos/${repoOwner}/${repoName}/releases?per_page=100`, throw: false, }); @@ -58,19 +60,16 @@ export async function getReleaseNotesAfter( throw new Error(`Failed to fetch releases: ${message}`); } - const releases = body as Release[]; - - const startReleaseIdx = releases.findIndex( - (release) => release.tag_name === releaseTagName - ); - - if (startReleaseIdx === -1) { - throw new Error(`Could not find release with tag ${releaseTagName}`); + const previous = parseSemver(releaseTagName); + if (!previous) { + throw new Error(`Invalid version ${releaseTagName}`); } - return releases - .slice(0, startReleaseIdx) - .filter((release) => !release.draft && !release.prerelease); + return (body as Release[]).filter((release) => { + if (release.draft || release.prerelease) return false; + const version = parseSemver(release.tag_name); + return version !== null && compareSemver(version, previous) > 0; + }); } const USER_ATTACHMENT_VIDEO_URL = @@ -204,15 +203,16 @@ export class UpdateModal extends Modal { this.releases = releases; - if (this.releases.length === 0) { - this.close(); - return; - } + if (this.releases.length === 0) { + this.close(); + return; + } this.display(); }) .catch((err) => { log.logError(`Failed to fetch release notes: ${err as string}`); + if (!this.isClosed) this.close(); }); } diff --git a/src/gui/apiModernization.test.ts b/src/gui/apiModernization.test.ts index d615001c4..c2d7c7269 100644 --- a/src/gui/apiModernization.test.ts +++ b/src/gui/apiModernization.test.ts @@ -44,7 +44,7 @@ describe("API modernization", () => { }, ]); expect(requestUrlSpy).toHaveBeenCalledWith({ - url: "https://api.github.com/repos/owner/repo/releases", + url: "https://api.github.com/repos/owner/repo/releases?per_page=100", throw: false, }); diff --git a/src/utils/semver.ts b/src/utils/semver.ts index 76526f5b7..4030465e1 100644 --- a/src/utils/semver.ts +++ b/src/utils/semver.ts @@ -89,3 +89,11 @@ export function isMajorUpdate( return current.major > previous.major; } + +/** + * Orders two parsed versions: negative if `a` is older than `b`, positive if + * newer, 0 if equal. + */ +export function compareSemver(a: ParsedVersion, b: ParsedVersion): number { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} From 2952a66b2e948b8ec622e6db6b3b62246fa367e4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 17:33:46 +0200 Subject: [PATCH 2/2] test(update-modal): leave the previous version out of the missing-tag fixture --- src/gui/UpdateModal/UpdateModal.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/UpdateModal/UpdateModal.test.ts b/src/gui/UpdateModal/UpdateModal.test.ts index a2e886ba8..bcd38d75c 100644 --- a/src/gui/UpdateModal/UpdateModal.test.ts +++ b/src/gui/UpdateModal/UpdateModal.test.ts @@ -206,7 +206,6 @@ describe("getReleaseNotesAfter", () => { release("2.9.4", { draft: true }), release("2.9.0"), release("not-a-version"), - release("1.18.1"), release("1.18.0"), ]);