diff --git a/apps/api/src/lib/release-fetch.ts b/apps/api/src/lib/release-fetch.ts index 1eb5b4e..25936e6 100644 --- a/apps/api/src/lib/release-fetch.ts +++ b/apps/api/src/lib/release-fetch.ts @@ -1,6 +1,7 @@ import type { NormalizedRelease } from './webhook' import type { RepoRef } from './providers' import { UpstreamUnauthorizedError } from './oauth-tokens' +import { compareSemver } from './semver' // Pulls the newest release straight from the provider API, in the same shape // the webhook handler would have ingested. Used wherever we need to materialize @@ -17,6 +18,20 @@ export async function fetchLatestRelease(accessToken: string, ref: RepoRef): Pro return fetchGitlab(instance.baseUrl, accessToken, ref) } +type GithubRelease = { + tag_name?: string + draft?: boolean + prerelease?: boolean + html_url?: string + assets?: Array<{ name: string; browser_download_url: string }> +} + +// Lists releases instead of asking for /releases/latest: that endpoint skips +// every prerelease, so a plugin that only ships betas looks like it has no +// releases at all. Listing also keeps this in step with persistRelease, which +// ranks releases by semver — /releases/latest would pin a stable 1.0.0 as +// "latest" even after 1.1.0-beta.1 shipped, and the two would disagree about +// which version the registry considers current. async function fetchGithubFlavored( apiBase: string, accessToken: string, @@ -28,44 +43,54 @@ async function fetchGithubFlavored( Accept: 'application/vnd.github+json', } if (userAgent) headers['User-Agent'] = userAgent - const res = await fetch(`${apiBase}/repos/${ref.owner}/${ref.repo}/releases/latest`, { headers }) + const res = await fetch(`${apiBase}/repos/${ref.owner}/${ref.repo}/releases?per_page=100`, { headers }) if (res.status === 404) return null - if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases/latest') + if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases') if (!res.ok) throw new Error(`Provider API ${res.status}`) - const data = (await res.json()) as { - tag_name?: string - draft?: boolean - prerelease?: boolean - html_url?: string - assets?: Array<{ name: string; browser_download_url: string }> - } - if (!data.tag_name) return null + const body = await res.json() + const list: GithubRelease[] = Array.isArray(body) ? body : [] + const candidates = list.filter((r) => r.tag_name && !r.draft) + if (candidates.length === 0) return null + // Highest semver wins; equal or unparseable tags keep the provider's own + // ordering, which is newest-first. + const newest = candidates.reduce((best, r) => + compareSemver(stripV(r.tag_name as string), stripV(best.tag_name as string)) > 0 ? r : best, + ) return { - repoUrl: data.html_url?.replace(/\/releases\/.*$/, '') ?? `${ref.instance.baseUrl}/${ref.owner}/${ref.repo}`, - published: !data.draft, - tag: data.tag_name, - assets: (data.assets ?? []).map((a) => ({ name: a.name, url: a.browser_download_url })), + repoUrl: newest.html_url?.replace(/\/releases\/.*$/, '') ?? `${ref.instance.baseUrl}/${ref.owner}/${ref.repo}`, + published: !newest.draft, + tag: newest.tag_name as string, + assets: (newest.assets ?? []).map((a) => ({ name: a.name, url: a.browser_download_url })), } } +function stripV(tag: string): string { + return tag.replace(/^v/, '') +} + async function fetchGitlab(baseUrl: string, accessToken: string, ref: RepoRef): Promise { const projectId = encodeURIComponent(ref.fullName) - const res = await fetch(`${baseUrl}/api/v4/projects/${projectId}/releases?per_page=1`, { + const res = await fetch(`${baseUrl}/api/v4/projects/${projectId}/releases?per_page=100`, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (res.status === 401) throw new UpstreamUnauthorizedError(ref.instance.id, 'releases') if (!res.ok) throw new Error(`GitLab API ${res.status}`) - const list = (await res.json()) as Array<{ + const body = await res.json() + const list = (Array.isArray(body) ? body : []) as Array<{ tag_name?: string upcoming_release?: boolean assets?: { links?: Array<{ name: string; url: string }> } }> - const latest = list[0] - if (!latest?.tag_name) return null + const tagged = list.filter((r) => r.tag_name) + if (tagged.length === 0) return null + // Same semver ranking as the GitHub-flavored path above. + const latest = tagged.reduce((best, r) => + compareSemver(stripV(r.tag_name as string), stripV(best.tag_name as string)) > 0 ? r : best, + ) return { repoUrl: `${baseUrl}/${ref.fullName}`, published: !latest.upcoming_release, - tag: latest.tag_name, + tag: latest.tag_name as string, assets: (latest.assets?.links ?? []).map((l) => ({ name: l.name, url: l.url })), } } diff --git a/apps/api/tests/lib/release-fetch.test.ts b/apps/api/tests/lib/release-fetch.test.ts new file mode 100644 index 0000000..b09a14f --- /dev/null +++ b/apps/api/tests/lib/release-fetch.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, spyOn, afterEach } from 'bun:test' +import { fetchLatestRelease } from '../../src/lib/release-fetch' +import type { RepoRef } from '../../src/lib/providers' +import type { ProviderInstance } from '../../src/lib/provider-instance' + +function refFor(kind: ProviderInstance['kind'], baseUrl: string): RepoRef { + return { + instance: { + id: kind, + kind, + displayName: kind, + baseUrl, + clientId: '', + clientSecret: '', + logoUrl: null, + enabled: true, + }, + owner: 'alice', + repo: 'my-plugin', + fullName: 'alice/my-plugin', + } +} + +const githubRef = refFor('github', 'https://github.com') + +function ghRelease(tag: string, extra: Record = {}) { + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + html_url: `https://github.com/alice/my-plugin/releases/tag/${tag}`, + assets: [{ name: 'plugin-linux-x64.zip', browser_download_url: `https://example.test/${tag}/linux.zip` }], + ...extra, + } +} + +let spy: ReturnType | null = null + +function mockList(releases: unknown[], status = 200) { + spy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { + lastUrl = String(url) + return new Response(JSON.stringify(releases), { status }) + }) as unknown as typeof fetch) +} + +let lastUrl = '' + +afterEach(() => { + spy?.mockRestore() + spy = null +}) + +describe('fetchLatestRelease (github)', () => { + // Regression: this used to call /releases/latest, which GitHub defines as + // the newest NON-prerelease. A plugin shipping only betas 404'd there and + // looked like it had no releases at all — submit, rehash and the admin + // replay all silently gave up on it. + it('finds the newest release when every release is a prerelease', async () => { + mockList([ghRelease('v1.0.0-beta.5'), ghRelease('v1.0.0-beta.7'), ghRelease('v1.0.0-beta.6')]) + const got = await fetchLatestRelease('token', githubRef) + expect(got?.tag).toBe('v1.0.0-beta.7') + expect(lastUrl).toContain('/releases?per_page=100') + expect(lastUrl).not.toContain('/releases/latest') + }) + + it('ranks by semver, not by the order the provider returns', async () => { + mockList([ghRelease('v0.2.0'), ghRelease('v0.10.0'), ghRelease('v0.9.0')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v0.10.0') + }) + + it('prefers a stable release over its own prerelease', async () => { + mockList([ghRelease('v2.0.0-rc.1'), ghRelease('v2.0.0')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v2.0.0') + }) + + it('picks a newer prerelease over an older stable release', async () => { + mockList([ghRelease('v1.0.0'), ghRelease('v1.1.0-beta.1')]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v1.1.0-beta.1') + }) + + it('skips drafts', async () => { + mockList([ghRelease('v1.0.0'), ghRelease('v2.0.0', { draft: true })]) + expect((await fetchLatestRelease('token', githubRef))?.tag).toBe('v1.0.0') + }) + + it('returns null when the repo has no releases', async () => { + mockList([]) + expect(await fetchLatestRelease('token', githubRef)).toBeNull() + }) + + it('returns null on 404', async () => { + mockList({ message: 'Not Found' } as unknown as unknown[], 404) + expect(await fetchLatestRelease('token', githubRef)).toBeNull() + }) + + it('carries assets from the winning release', async () => { + mockList([ghRelease('v1.0.0-beta.5'), ghRelease('v1.0.0-beta.7')]) + const got = await fetchLatestRelease('token', githubRef) + expect(got?.assets[0]?.url).toContain('v1.0.0-beta.7') + expect(got?.published).toBe(true) + }) +}) + +describe('fetchLatestRelease (gitlab)', () => { + const gitlabRef = refFor('gitlab', 'https://gitlab.com') + + it('ranks by semver across the listing', async () => { + mockList([ + { tag_name: 'v1.0.0-beta.5', assets: { links: [] } }, + { tag_name: 'v1.0.0-beta.7', assets: { links: [] } }, + ]) + expect((await fetchLatestRelease('token', gitlabRef))?.tag).toBe('v1.0.0-beta.7') + }) + + it('returns null on an empty listing', async () => { + mockList([]) + expect(await fetchLatestRelease('token', gitlabRef)).toBeNull() + }) +}) diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index cf76de2..7eeac91 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release geyankt", "plugin_detail_yank_title": "Release v{version} yanken", "plugin_detail_yanked_badge": "geyankt", + "plugin_prerelease_badge": "Vorabversion", "plugins_list_categories": "Kategorien", "plugins_list_clear_filters": "Filter zurücksetzen", "plugins_list_clear_tag": "Tag entfernen", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index a40f22f..181cece 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release yanked", "plugin_detail_yank_title": "Yank release v{version}", "plugin_detail_yanked_badge": "yanked", + "plugin_prerelease_badge": "pre-release", "plugins_list_categories": "Categories", "plugins_list_clear_filters": "Clear filters", "plugins_list_clear_tag": "Clear tag", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index d3cb94b..8df0fc8 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release retirado", "plugin_detail_yank_title": "Retirar release v{version}", "plugin_detail_yanked_badge": "retirado", + "plugin_prerelease_badge": "preversión", "plugins_list_categories": "Categorías", "plugins_list_clear_filters": "Limpiar filtros", "plugins_list_clear_tag": "Quitar etiqueta", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index a3600bf..0ec552b 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release retirée", "plugin_detail_yank_title": "Retirer la release v{version}", "plugin_detail_yanked_badge": "retirée", + "plugin_prerelease_badge": "préversion", "plugins_list_categories": "Catégories", "plugins_list_clear_filters": "Effacer les filtres", "plugins_list_clear_tag": "Retirer le tag", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index affd9ab..1bb1f58 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "Release ritirata", "plugin_detail_yank_title": "Ritira release v{version}", "plugin_detail_yanked_badge": "ritirata", + "plugin_prerelease_badge": "preversione", "plugins_list_categories": "Categorie", "plugins_list_clear_filters": "Pulisci filtri", "plugins_list_clear_tag": "Rimuovi tag", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 3d0fb12..3bae6a3 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -847,6 +847,7 @@ "plugin_detail_yank_success": "已撤回发布版本", "plugin_detail_yank_title": "撤回发布版本 v{version}", "plugin_detail_yanked_badge": "已撤回", + "plugin_prerelease_badge": "预发布", "plugins_list_categories": "分类", "plugins_list_clear_filters": "清除筛选", "plugins_list_clear_tag": "清除 tag", diff --git a/apps/frontend/src/lib/components/PluginCard.svelte b/apps/frontend/src/lib/components/PluginCard.svelte index 3887860..5061579 100644 --- a/apps/frontend/src/lib/components/PluginCard.svelte +++ b/apps/frontend/src/lib/components/PluginCard.svelte @@ -1,8 +1,9 @@