Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 44 additions & 19 deletions apps/api/src/lib/release-fetch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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<NormalizedRelease | null> {
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 })),
}
}
119 changes: 119 additions & 0 deletions apps/api/tests/lib/release-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<typeof spyOn> | 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()
})
})
1 change: 1 addition & 0 deletions apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions apps/frontend/src/lib/components/PluginCard.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import { cn } from '$lib/utils'
import { cn, isPrerelease } from '$lib/utils'
import Badge from '$components/ui/Badge.svelte'
import VerifiedBadge from '$components/ui/VerifiedBadge.svelte'
import Boxes from '@lucide/svelte/icons/boxes'
import { m } from '$lib/paraglide/messages'
import type { Plugin } from '$lib/types'

let { plugin, class: className }: { plugin: Plugin; class?: string } = $props()
Expand Down Expand Up @@ -36,7 +37,14 @@
{/if}
</div>
{#if plugin.latestVersion}
<Badge variant="secondary" class="font-mono text-[10px] flex-shrink-0">v{plugin.latestVersion}</Badge>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Badge variant="secondary" class="font-mono text-[10px]">v{plugin.latestVersion}</Badge>
{#if isPrerelease(plugin.latestVersion)}
<Badge variant="secondary" class="font-mono text-[10px] bg-warning/15 text-warning border-warning/30"
>{m.plugin_prerelease_badge()}</Badge
>
{/if}
</div>
{/if}
</div>
<p class="text-sm text-muted-foreground line-clamp-2">{plugin.description}</p>
Expand Down
8 changes: 8 additions & 0 deletions apps/frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

// A strict-semver version is a prerelease exactly when it carries a "-suffix"
// ahead of any "+build" metadata (1.0.0-beta.7). Ingest rejects anything that
// is not strict semver, so a hyphen cannot show up anywhere else.
export function isPrerelease(version: string | null | undefined): boolean {
if (!version) return false
return version.split('+')[0].includes('-')
}
18 changes: 18 additions & 0 deletions apps/frontend/src/routes/plugins/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import VerifiedBadge from '$components/ui/VerifiedBadge.svelte'
import ConfirmDialog from '$components/ui/ConfirmDialog.svelte'
import YankDialog from '$components/ui/YankDialog.svelte'
import { isPrerelease } from '$lib/utils'
import { eden } from '$lib/eden'
import { auth } from '$lib/stores/auth.svelte'
import { branding } from '$lib/stores/branding.svelte'
Expand Down Expand Up @@ -476,6 +477,12 @@
class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-mono bg-primary/10 text-primary border border-primary/20"
>v{plugin.latestVersion}</span
>
{#if isPrerelease(plugin.latestVersion)}
<span
class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-mono bg-warning/15 text-warning border border-warning/30"
>{m.plugin_prerelease_badge()}</span
>
{/if}
{/if}
{#if plugin.license}
<span
Expand Down Expand Up @@ -552,6 +559,11 @@
{/if}
{#if latestRelease}
<span class="text-xs font-mono text-muted-foreground">v{latestRelease.version}</span>
{#if isPrerelease(latestRelease.version)}
<span class="font-mono text-[10px] px-2 py-0.5 rounded-full bg-warning/15 text-warning tracking-wide"
>{m.plugin_prerelease_badge()}</span
>
{/if}
{/if}
</div>
</div>
Expand Down Expand Up @@ -757,6 +769,12 @@
>runtime ≥ {release.minRuntimeVersion}</span
>
{/if}
{#if isPrerelease(release.version)}
<span
class="font-mono text-[10px] px-2 py-0.5 rounded-full bg-warning/15 text-warning tracking-wide"
>{m.plugin_prerelease_badge()}</span
>
{/if}
{#if release.yankedAt}
<span
class="font-mono text-[10px] px-2 py-0.5 rounded-full bg-destructive/15 text-destructive tracking-wide"
Expand Down
Loading