upstream: track prereleases per row; rename gitea adapter to forgejo
Most forges hand back a "latest stable" that ignores prereleases. Fine until something ships only prereleases for a stretch — us, through alpha, where every tag under v0.3.0 goes out as a prerelease. Against that, /releases/latest either freezes or returns nothing, and the dashboard reads like nothing's moving. Add a per-row track_prereleases flag. When it's on, the forgejo adapter walks the full release list and considers prereleases when picking the highest version; off (the default) keeps stable-only behavior, so the other adapters don't change. Seed our own self-row on so we stop looking frozen. Renamed gitea_releases to forgejo_releases while in there — the wire format is Forgejo's, Codeberg runs it, and the old name was a misnomer. Legacy source="gitea" rows still resolve through an alias.
This commit is contained in:
parent
a859fabedf
commit
9e4d59695f
18 changed files with 814 additions and 142 deletions
|
|
@ -13,6 +13,9 @@ const StackDriftPanel: React.FC = () => {
|
|||
|
||||
const drift: TrackedSoftware[] = drifted ?? [];
|
||||
const eolCount = drift.filter((s) => s.eol_at && new Date(s.eol_at) < new Date()).length;
|
||||
const redflagDrift = drift.find(
|
||||
(s) => s.source === 'forgejo' && s.source_ref === 'codeberg.org/Fimeg/RedFlag',
|
||||
);
|
||||
const worst3 = drift.slice(0, 3);
|
||||
|
||||
return (
|
||||
|
|
@ -53,6 +56,28 @@ const StackDriftPanel: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{redflagDrift && (
|
||||
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">RedFlag update available</p>
|
||||
<p className="text-xs text-amber-800 font-mono mt-0.5">
|
||||
{redflagDrift.current_version ?? '?'} → {redflagDrift.latest_version ?? '?'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/upstream"
|
||||
className="text-xs text-amber-800 hover:text-amber-950 font-medium whitespace-nowrap"
|
||||
>
|
||||
View row
|
||||
</Link>
|
||||
</div>
|
||||
<code className="mt-2 block overflow-x-auto rounded bg-white/80 px-2 py-1 text-xs text-amber-950">
|
||||
git checkout {redflagDrift.latest_version ?? 'v...'} && docker compose build server && docker compose up -d server
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{worst3.map((s) => {
|
||||
const eolPassed = s.eol_at && new Date(s.eol_at) < new Date();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import { POLL } from '@/lib/polling';
|
||||
import { CreateTrackedSoftwareRequest } from '@/types';
|
||||
import { CreateTrackedSoftwareRequest, UpdateTrackedSoftwareSettingsRequest } from '@/types';
|
||||
|
||||
export const upstreamKeys = {
|
||||
all: ['upstream'] as const,
|
||||
|
|
@ -66,6 +66,20 @@ export const useRemoveTrackedSoftware = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useUpdateTrackedSoftwareSettings = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: UpdateTrackedSoftwareSettingsRequest }) =>
|
||||
adminApi.upstream.updateSettings(id, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: upstreamKeys.all });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to update tracked software');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallations = (softwareId: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: upstreamKeys.installations(softwareId),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
RateLimitStatsResponse,
|
||||
TrackedSoftware,
|
||||
CreateTrackedSoftwareRequest,
|
||||
UpdateTrackedSoftwareSettingsRequest,
|
||||
UpstreamDriftEvent,
|
||||
AgentTrackedSoftware,
|
||||
AgentTrackedSoftwareView,
|
||||
|
|
@ -903,6 +904,11 @@ export const adminApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
updateSettings: async (id: string, input: UpdateTrackedSoftwareSettingsRequest): Promise<TrackedSoftware> => {
|
||||
const response = await api.patch(`/admin/upstream/${id}`, input);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/upstream/${id}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
useAddTrackedSoftware,
|
||||
useRemoveTrackedSoftware,
|
||||
useSyncTrackedSoftware,
|
||||
useUpdateTrackedSoftwareSettings,
|
||||
useInstallations,
|
||||
} from '@/hooks/useUpstream';
|
||||
import { CreateTrackedSoftwareRequest, UpstreamSource } from '@/types';
|
||||
|
|
@ -50,6 +51,7 @@ const sourceLabel = (s: UpstreamSource): string => {
|
|||
case 'endoflife': return 'endoflife.date';
|
||||
case 'anitya': return 'Anitya';
|
||||
case 'github': return 'GitHub Releases';
|
||||
case 'forgejo': return 'Forgejo Releases';
|
||||
case 'gitea': return 'Gitea Releases';
|
||||
case 'gitlab': return 'GitLab Releases';
|
||||
case 'bitbucket': return 'Bitbucket Tags';
|
||||
|
|
@ -68,7 +70,8 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
|
|||
case 'endoflife': return { placeholder: 'postgresql', help: 'endoflife.date product slug' };
|
||||
case 'anitya': return { placeholder: 'nginx', help: 'Anitya project name' };
|
||||
case 'github': return { placeholder: 'kubernetes/kubernetes', help: 'GitHub owner/repo. Set REDFLAG_GITHUB_TOKEN for higher rate limit.' };
|
||||
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo. REDFLAG_GITEA_HOST + REDFLAG_GITEA_TOKEN required.' };
|
||||
case 'forgejo': return { placeholder: 'codeberg.org/Fimeg/RedFlag', help: 'Forgejo host/owner/repo, such as codeberg.org/Fimeg/RedFlag.' };
|
||||
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo, or host/owner/repo. REDFLAG_GITEA_HOST used for two-part refs.' };
|
||||
case 'gitlab': return { placeholder: 'gitlab-org/gitlab', help: 'GitLab group/project path. REDFLAG_GITLAB_TOKEN for private projects.' };
|
||||
case 'bitbucket': return { placeholder: 'atlassian/atlassian-sdk', help: 'Bitbucket workspace/repo. Highest tag picked via semver compare.' };
|
||||
case 'git': return { placeholder: 'https://git.kernel.org/….git', help: 'Any Git clone URL. Anonymous ls-remote; private repos use the dedicated adapters.' };
|
||||
|
|
@ -81,15 +84,26 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
|
|||
// "Source" link in the actions column. Falls back to the row's
|
||||
// stored source_url when an adapter doesn't have a predictable URL
|
||||
// pattern (or when host is env-configurable).
|
||||
const forgejoHref = (ref: string): string | null => {
|
||||
const clean = ref.trim().replace(/\/+$/, '');
|
||||
if (/^https?:\/\//.test(clean)) return `${clean}/releases`;
|
||||
const parts = clean.split('/');
|
||||
if (parts.length === 3 && parts.every(Boolean)) {
|
||||
return `https://${parts[0]}/${parts[1]}/${parts[2]}/releases`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string | null => {
|
||||
switch (s.source) {
|
||||
case 'repology': return `https://repology.org/project/${s.source_ref}/versions`;
|
||||
case 'endoflife': return `https://endoflife.date/${s.source_ref}`;
|
||||
case 'github': return `https://github.com/${s.source_ref}/releases`;
|
||||
case 'forgejo': return forgejoHref(s.source_ref);
|
||||
case 'gitlab': return `https://gitlab.com/${s.source_ref}/-/releases`;
|
||||
case 'bitbucket': return `https://bitbucket.org/${s.source_ref}/downloads/?tab=tags`;
|
||||
case 'git': return s.source_ref; // already a URL
|
||||
case 'gitea':
|
||||
case 'gitea': return forgejoHref(s.source_ref);
|
||||
case 'anitya':
|
||||
case 'npm':
|
||||
case 'pypi':
|
||||
|
|
@ -97,6 +111,9 @@ const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string |
|
|||
}
|
||||
};
|
||||
|
||||
const supportsPrereleaseToggle = (source: UpstreamSource): boolean =>
|
||||
source === 'forgejo' || source === 'gitea';
|
||||
|
||||
// InstallationsRow lazy-fetches the agent list for a tracked_software entry
|
||||
// when the operator expands the row. Kept inside this file because it's only
|
||||
// the table's expanded-row affordance, not a reusable component.
|
||||
|
|
@ -150,6 +167,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
const add = useAddTrackedSoftware();
|
||||
const remove = useRemoveTrackedSoftware();
|
||||
const sync = useSyncTrackedSoftware();
|
||||
const updateSettings = useUpdateTrackedSoftwareSettings();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
|
@ -169,6 +187,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
source: 'endoflife',
|
||||
source_ref: '',
|
||||
current_version: '',
|
||||
track_prereleases: false,
|
||||
});
|
||||
|
||||
const software = data?.software ?? [];
|
||||
|
|
@ -178,7 +197,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
if (!form.name || !form.source_ref) return;
|
||||
add.mutate({ ...form, current_version: form.current_version || undefined }, {
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '' });
|
||||
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '', track_prereleases: false });
|
||||
setShowAddForm(false);
|
||||
},
|
||||
});
|
||||
|
|
@ -205,8 +224,8 @@ const UpstreamTracking: React.FC = () => {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Upstream Version Tracking</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Compare your deployed versions against canonical upstream releases. Pluggable sources:
|
||||
Repology (cross-distro) and endoflife.date (release lines + EOL dates).
|
||||
Compare your deployed versions against canonical upstream releases across package indexes,
|
||||
lifecycle feeds, forge releases, and repository tags.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -248,7 +267,14 @@ const UpstreamTracking: React.FC = () => {
|
|||
<label className="block text-sm font-medium text-gray-700 mb-1">Source</label>
|
||||
<select
|
||||
value={form.source}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value as UpstreamSource })}
|
||||
onChange={(e) => {
|
||||
const nextSource = e.target.value as UpstreamSource;
|
||||
setForm({
|
||||
...form,
|
||||
source: nextSource,
|
||||
track_prereleases: supportsPrereleaseToggle(nextSource) ? form.track_prereleases : false,
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{availableSources.map((s) => (
|
||||
|
|
@ -280,6 +306,17 @@ const UpstreamTracking: React.FC = () => {
|
|||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
{supportsPrereleaseToggle(form.source) && (
|
||||
<label className="md:col-span-2 inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.track_prereleases}
|
||||
onChange={(e) => setForm({ ...form, track_prereleases: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Track prereleases
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<button
|
||||
|
|
@ -385,6 +422,9 @@ const UpstreamTracking: React.FC = () => {
|
|||
<td className="px-6 py-3 text-sm text-gray-600">
|
||||
{sourceLabel(s.source)}
|
||||
<div className="text-xs text-gray-400 font-mono">{s.source_ref}</div>
|
||||
{s.track_prereleases && (
|
||||
<div className="text-xs text-blue-600">prereleases included</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.current_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.latest_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
|
|
@ -408,6 +448,21 @@ const UpstreamTracking: React.FC = () => {
|
|||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right space-x-2 whitespace-nowrap">
|
||||
{supportsPrereleaseToggle(s.source) && (
|
||||
<label className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.track_prereleases}
|
||||
disabled={updateSettings.isPending}
|
||||
onChange={(e) => updateSettings.mutate({
|
||||
id: s.id,
|
||||
input: { track_prereleases: e.target.checked },
|
||||
})}
|
||||
className="h-3 w-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Prereleases
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={() => sync.mutate(s.id)}
|
||||
disabled={sync.isPending}
|
||||
|
|
|
|||
|
|
@ -574,6 +574,7 @@ export type UpstreamSource =
|
|||
| 'endoflife'
|
||||
| 'anitya'
|
||||
| 'github'
|
||||
| 'forgejo'
|
||||
| 'gitea'
|
||||
| 'gitlab'
|
||||
| 'bitbucket'
|
||||
|
|
@ -595,6 +596,7 @@ export interface TrackedSoftware {
|
|||
last_synced_at?: string | null;
|
||||
last_error?: string | null;
|
||||
enabled: boolean;
|
||||
track_prereleases: boolean;
|
||||
repology_slug?: string | null;
|
||||
container_image_pattern?: string | null;
|
||||
binary_probe?: string | null;
|
||||
|
|
@ -609,8 +611,15 @@ export interface CreateTrackedSoftwareRequest {
|
|||
source_ref: string;
|
||||
current_version?: string;
|
||||
enabled?: boolean;
|
||||
track_prereleases?: boolean;
|
||||
repology_slug?: string;
|
||||
container_image_pattern?: string;
|
||||
binary_probe?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTrackedSoftwareSettingsRequest {
|
||||
enabled?: boolean;
|
||||
track_prereleases?: boolean;
|
||||
}
|
||||
|
||||
export interface UpstreamDriftEvent {
|
||||
|
|
|
|||
Loading…
Reference in a new issue