mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 03:20:33 +08:00
Repos need not carry a `xyz.radicle.project` payload; the default branch can come from `xyz.radicle.crefs` alone. Such repos got no counts and no default branch, and failed schema validation in explorer, which broke the parse of a whole listing. httpd: - Add top-level `cobs` with issue, patch and bucketed release counts - Add top-level `defaultBranch`, qualified like the crefs payload - Omit a count the node cannot determine instead of reporting zero - Drop `head`: the tip is `refs[defaultBranch]` - Keep counts in the project payload for older clients until a major release - Keep the project payload when a COB read fails - Keep `meta.releases` a single count: delegate + other - Log and skip canonical ref rules and refs that fail to read, so one bad ref doesn't drop the default branch tip explorer: - Model `cobs` and `defaultBranch`, adapting older nodes to one shape - Resolve the default branch from the crefs HEAD on older nodes - Collect the shims for older httpd in http-client/lib/compat.ts, each naming the httpd release that the client can drop it with - Add `defaultBranchTip`, replacing `head`, with a fallback to the legacy `meta.head` - Make the project payload and its legacy meta optional - Fall back to the RID for the repo name, and show a placeholder for a missing name in repo cards and the header - Hide a counter instead of reading a missing count as zero - Fall back to a full-page check for "More" without counts - Add a `repo.sourceUnavailable` route for repos without a default branch or tip - Browse explicit revisions and offer the branch selector without a default branch - List these repos in repo grids and on the landing page - Say the node is older or built without releases - Scope release pages in the client, so views never call the shims - Read OG worker repos through the same compat shims - Share the repo name and default branch helpers with the OG worker CI: - Add check:open-graph, a wrangler deploy dry run - Run it in GitHub CI, Buildkite and check:ci - Install explorer's dependencies first: the worker resolves compat.ts imports, such as zod, from there Tests: - Test that a build without releases omits the count - Test that one failing rule keeps the others - Test the repo shape across httpd versions and the release shims - Test `hasMorePages` and `repoName` - Test repos without a project payload end to end
161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
// Shims that let this client read older httpd releases, so the app only ever
|
|
// sees one shape. See docs/adr/0001 and docs/api-changes.md.
|
|
//
|
|
// Each shim has a "Compat:" comment that says what older httpd does and the
|
|
// last httpd release that needs it: "Client: remove when explorer drops httpd
|
|
// 0.30". Fallbacks to an older endpoint stay in the client method that calls
|
|
// the endpoint, with the same comment.
|
|
|
|
import type { Release } from "./repo/release.js";
|
|
import type { ReleaseScope } from "./repo.js";
|
|
import type { Author } from "./shared.js";
|
|
import type { z } from "zod";
|
|
|
|
import { number, object, record, string } from "zod";
|
|
|
|
// Compat: older httpd reports the issue and patch counts and the default
|
|
// branch tip inside the project payload, and the release count as a single
|
|
// number. Client: remove when explorer drops httpd 0.30.
|
|
export const legacyMetaSchema = object({
|
|
head: string(),
|
|
patches: object({
|
|
open: number(),
|
|
draft: number(),
|
|
archived: number(),
|
|
merged: number(),
|
|
}),
|
|
issues: object({
|
|
open: number(),
|
|
closed: number(),
|
|
}),
|
|
releases: number().optional(),
|
|
});
|
|
|
|
// Compat: older httpd names the default branch only in the project payload,
|
|
// so a repo without one has none, even when the symbolic `HEAD` of its
|
|
// `xyz.radicle.crefs` payload names it. It sends that payload, so the client
|
|
// resolves `HEAD` itself. A malformed payload counts as missing, so that the
|
|
// repo still parses. Client: remove when explorer drops httpd 0.30.
|
|
export const crefsPayloadSchema = object({
|
|
data: object({ symbolic: record(string(), string()).optional() }),
|
|
})
|
|
.optional()
|
|
.catch(undefined);
|
|
|
|
type LegacyProject =
|
|
| {
|
|
data: { defaultBranch: string };
|
|
meta?: z.infer<typeof legacyMetaSchema>;
|
|
}
|
|
| undefined;
|
|
|
|
type LegacyRefs<T> =
|
|
{ tags: Record<string, T>; refs: Record<string, string> } | undefined;
|
|
|
|
// Compat: older httpd, see `legacyMetaSchema` and `crefsPayloadSchema`. It
|
|
// reports the counts and the default branch only in the project payload.
|
|
// Client: remove when explorer drops httpd 0.30.
|
|
export function legacyRepoFields<T>(
|
|
project: LegacyProject,
|
|
crefs: z.infer<typeof crefsPayloadSchema>,
|
|
refs: LegacyRefs<T>,
|
|
) {
|
|
const meta = project?.meta;
|
|
const defaultBranch = legacyDefaultBranch(project, crefs);
|
|
return {
|
|
cobs: meta
|
|
? { patches: meta.patches, issues: meta.issues, releases: undefined }
|
|
: undefined,
|
|
releaseCount: meta?.releases,
|
|
defaultBranch,
|
|
refs: legacyRefs(project, defaultBranch, refs),
|
|
};
|
|
}
|
|
|
|
// httpd before 0.25.0 sends no canonical refs, and 0.25.0 leaves the default
|
|
// branch out of them unless the repo has a crefs payload. Both report the tip
|
|
// as `meta.head`, so add it to the canonical refs.
|
|
function legacyRefs<T>(
|
|
project: LegacyProject,
|
|
defaultBranch: string | undefined,
|
|
refs: LegacyRefs<T>,
|
|
) {
|
|
const head = project?.meta?.head;
|
|
if (
|
|
head === undefined ||
|
|
defaultBranch === undefined ||
|
|
refs?.refs[defaultBranch] !== undefined ||
|
|
// `meta.head` is the tip of the project's default branch, which a crefs
|
|
// HEAD may name differently.
|
|
defaultBranch !== `refs/heads/${project?.data.defaultBranch}`
|
|
) {
|
|
return refs;
|
|
}
|
|
return {
|
|
tags: refs?.tags ?? {},
|
|
refs: { ...refs?.refs, [defaultBranch]: head },
|
|
};
|
|
}
|
|
|
|
// Like heartwood's `Doc::default_branch`: the crefs `HEAD`, which may point
|
|
// at another symbolic ref, else the project payload. Only a branch counts.
|
|
function legacyDefaultBranch(
|
|
project: { data: { defaultBranch: string } } | undefined,
|
|
crefs: z.infer<typeof crefsPayloadSchema>,
|
|
): string | undefined {
|
|
const symbolic = crefs?.data.symbolic;
|
|
if (symbolic?.HEAD !== undefined) {
|
|
const seen = new Set<string>();
|
|
let target: string | undefined = symbolic.HEAD;
|
|
while (target !== undefined && !seen.has(target)) {
|
|
if (target.startsWith("refs/")) {
|
|
return target.startsWith("refs/heads/") ? target : undefined;
|
|
}
|
|
seen.add(target);
|
|
target = symbolic[target];
|
|
}
|
|
return undefined;
|
|
}
|
|
return project ? `refs/heads/${project.data.defaultBranch}` : undefined;
|
|
}
|
|
|
|
// Compat: older httpd ignores `scope` and only knows `allAuthors`, which
|
|
// asks for every author. Client: remove when explorer drops httpd 0.30.
|
|
export function legacyReleasesQuery(
|
|
scope: ReleaseScope | undefined,
|
|
): Record<string, boolean> {
|
|
return scope === "untrusted" ? { allAuthors: true } : {};
|
|
}
|
|
|
|
// Compat: older httpd answers the untrusted scope with every author, so
|
|
// keep only the releases that no delegate created. A no-op on newer nodes.
|
|
// Client: remove when explorer drops httpd 0.30.
|
|
export function legacyScopeReleases(
|
|
releases: Release[],
|
|
delegates: Author[],
|
|
scope: ReleaseScope,
|
|
): Release[] {
|
|
if (scope === "trusted") {
|
|
return releases;
|
|
}
|
|
const delegateIds = new Set(delegates.map(d => d.id));
|
|
return releases.filter(r => !delegateIds.has(r.creator.id));
|
|
}
|
|
|
|
// Compat: older httpd reports no release buckets, so look for others'
|
|
// releases in the first page of every author. A full page means others may
|
|
// come later, and only the filter depends on this, so a full page or a failed
|
|
// fetch counts as having others. Client: remove when explorer drops httpd
|
|
// 0.30.
|
|
export async function legacyHasOtherReleases(
|
|
getPage: () => Promise<Release[]>,
|
|
delegates: Author[],
|
|
perPage: number,
|
|
): Promise<boolean> {
|
|
const everyAuthor = await getPage().catch(() => undefined);
|
|
if (everyAuthor === undefined || everyAuthor.length === perPage) {
|
|
return true;
|
|
}
|
|
return legacyScopeReleases(everyAuthor, delegates, "untrusted").length > 0;
|
|
}
|