Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-15 12:04:48 -04:00
commit 9e4d59695f
18 changed files with 814 additions and 142 deletions

View file

@ -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();

View file

@ -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),

View file

@ -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}`);
},

View file

@ -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}

View file

@ -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 {