diff --git a/src/gui/UpdateModal/UpdateModal.test.ts b/src/gui/UpdateModal/UpdateModal.test.ts index cb3e18c24..bcd38d75c 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,61 @@ 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.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; +}