Move repo facts outside the project payload

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
This commit is contained in:
Daniel Norman
2026-10-29 18:21:09 +01:00
parent 1d760abe96
commit 0dc1e557a5
42 changed files with 1716 additions and 450 deletions
+3
View File
@@ -27,6 +27,7 @@
import Release from "@app/views/repos/Release.svelte";
import Releases from "@app/views/repos/Releases.svelte";
import Source from "@app/views/repos/Source.svelte";
import SourceUnavailable from "@app/views/repos/SourceUnavailable.svelte";
import Users from "@app/views/users/View.svelte";
import Error from "@app/views/error/View.svelte";
@@ -90,6 +91,8 @@
<Users {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.source"}
<Source {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.sourceUnavailable"}
<SourceUnavailable {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.history"}
<History {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commit"}
+38 -18
View File
@@ -6,6 +6,7 @@
import {
absoluteTimestamp,
defaultBranchTip,
formatTimestamp,
formatRepositoryId,
repoSegment,
@@ -28,6 +29,7 @@
$: project = repoInfo.repo.payloads["xyz.radicle.project"];
$: baseUrl = repoInfo.baseUrl;
$: isPrivate = repo.visibility.type === "private";
$: tip = defaultBranchTip(repo);
</script>
<style>
@@ -126,6 +128,14 @@
padding: 0.25rem;
}
.name-placeholder {
align-self: center;
height: 1rem;
width: 40%;
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
}
.stats-row {
position: relative;
display: flex;
@@ -162,11 +172,15 @@
<div class="headline-and-badges txt-overflow">
<div class="icon">
<RepoAvatar
rid={repoInfo.repo.rid}
name={project.data.name}
rid={repo.rid}
name={project?.data.name ?? ""}
styleWidth="1.5rem" />
</div>
<div class="txt-body-l-semibold" use:twemoji>{project.data.name}</div>
{#if project}
<div class="txt-body-l-semibold" use:twemoji>{project.data.name}</div>
{:else}
<div class="name-placeholder" title="This repository has no name"></div>
{/if}
<div class="badges" style:margin-left="auto">
{#if isPrivate}
<div
@@ -182,7 +196,7 @@
<div class="content-and-activity">
<div class="content">
<div class="txt-body-m-regular txt-overflow" use:twemoji>
{project.data.description}
{project?.data.description ?? ""}
</div>
<span style:text-overflow="ellipsis">
<Id id={repo.rid} title={repo.rid}>
@@ -207,20 +221,26 @@
class="stats-row txt-code-regular"
style:color="var(--color-text-tertiary)">
<Icon name="seed" />
{repoInfo.repo.seeding} ·
<Icon name="issue" />
{project.meta.issues.open} ·
<Icon name="patch" />
{project.meta.patches.open}
{#await api.repo.getCommitBySha(repo.rid, project.meta.head) then { commit }}
<span
class="txt-body-m-regular"
style:margin-left="auto"
style:overflow="hidden"
style:text-overflow="ellipsis"
title={absoluteTimestamp(commit.committer.time)}>
Updated {formatTimestamp(commit.committer.time)}
</span>
{repo.seeding}
{#if repo.cobs?.issues && repo.cobs.patches}
·
<Icon name="issue" />
{repo.cobs.issues.open} ·
<Icon name="patch" />
{repo.cobs.patches.open}
{/if}
{#await tip && api.repo.getCommitBySha(repo.rid, tip) then result}
{#if result}
{@const commit = result.commit}
<span
class="txt-body-m-regular"
style:margin-left="auto"
style:overflow="hidden"
style:text-overflow="ellipsis"
title={absoluteTimestamp(commit.committer.time)}>
Updated {formatTimestamp(commit.committer.time)}
</span>
{/if}
{/await}
</div>
</div>
+10 -12
View File
@@ -24,18 +24,16 @@ export async function fetchRepoInfos(
repos = await api.repo.getAll(query);
}
return repos
.filter(r => Boolean(r.payloads["xyz.radicle.project"]))
.map(repo => ({
repo,
baseUrl,
activity: loadRepoActivity(repo.rid, baseUrl, activitySignal).catch(e => {
if (import.meta.env.DEV && (e as Error)?.name !== "AbortError") {
console.warn("loadRepoActivity failed for", repo.rid, e);
}
return [];
}),
}));
return repos.map(repo => ({
repo,
baseUrl,
activity: loadRepoActivity(repo.rid, baseUrl, activitySignal).catch(e => {
if (import.meta.env.DEV && (e as Error)?.name !== "AbortError") {
console.warn("loadRepoActivity failed for", repo.rid, e);
}
return [];
}),
}));
}
export async function sortRepoInfosByActivity(
+3 -2
View File
@@ -46,8 +46,9 @@ export function artifactView(
trusted: visible(byScope.trusted).length,
untrusted: visible(byScope.untrusted).length,
},
// Others is one click away whenever it holds something.
showFilters: byScope.untrusted.length > 0,
// Others is one click away whenever it holds something, and Delegates is
// one click back from it.
showFilters: scope === "untrusted" || byScope.untrusted.length > 0,
};
}
+2 -1
View File
@@ -299,7 +299,8 @@ function setTitle(loadedRoute: LoadedRoute) {
loadedRoute.resource === "repo.patches" ||
loadedRoute.resource === "repo.patch" ||
loadedRoute.resource === "repo.releases" ||
loadedRoute.resource === "repo.release"
loadedRoute.resource === "repo.release" ||
loadedRoute.resource === "repo.sourceUnavailable"
) {
title.push(...repoTitle(loadedRoute));
} else if (loadedRoute.resource === "nodes") {
+26 -17
View File
@@ -6,25 +6,14 @@ import twemojiModule from "twemoji";
export const REFS_HEADS = "refs/heads/";
// Reads the repository's default branch. Heartwood no longer requires the
// project payload, so this is the single place to change once the API stops
// carrying the default branch there.
export function defaultBranch(repo: Repo): string {
return repo.payloads["xyz.radicle.project"].data.defaultBranch;
// The commit the default branch resolves to.
export function defaultBranchTip(repo: Repo): string | undefined {
return repo.defaultBranch ? repo.refs?.refs[repo.defaultBranch] : undefined;
}
// Counts the releases a user can browse: the two unredacted buckets, or the
// single count older nodes report in place of the buckets. Undefined means the
// node doesn't report releases at all, which is how the release API is
// detected.
export function visibleReleaseCount(repo: Repo): number | undefined {
const releases = repo.payloads["xyz.radicle.project"].meta.releases;
if (releases === undefined) {
return undefined;
}
return typeof releases === "number"
? releases
: releases.delegate + releases.other;
// The default branch in the unqualified form that routes use.
export function defaultBranchName(repo: Repo): string | undefined {
return repo.defaultBranch ? unqualifyBranch(repo.defaultBranch) : undefined;
}
export function unqualifyBranch(refname: string): string {
@@ -105,6 +94,26 @@ export function truncateId(pubkey: string): string {
return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`;
}
// Whether a paged list may have more items. With a total, compare against it;
// without one, offer more while every loaded page came back full.
export function hasMorePages(
loaded: number,
pages: number,
perPage: number,
total: number | undefined,
): boolean {
return total === undefined ? loaded === pages * perPage : loaded < total;
}
// A repo only has a name if it carries a project payload. Fall back to the RID,
// which every repo has.
export function repoName(repo: Repo): string {
return (
repo.payloads["xyz.radicle.project"]?.data.name ??
formatRepositoryId(repo.rid)
);
}
// The URL path segment to use for a repo: its configured alias when the node
// advertises one, otherwise the canonical RID. Keeps alias-based URLs sticky
// across in-app navigation, since the backend resolves either form.
+41 -22
View File
@@ -14,12 +14,12 @@
import { renderRepoAvatar } from "@radicle-gui/ui/lib/avatar";
interface CarouselRepo {
name: string;
name: string | undefined;
description: string;
rid: string;
seeding: number;
issues: number;
patches: number;
issues: number | undefined;
patches: number | undefined;
route: Route;
avatar: string;
}
@@ -66,21 +66,19 @@
if (token !== loadToken) {
return;
}
repos = list
.filter(r => r.payloads["xyz.radicle.project"])
.map(r => {
const project = r.payloads["xyz.radicle.project"];
return {
name: project.data.name,
description: project.data.description,
rid: r.rid,
seeding: r.seeding,
issues: project.meta.issues.open,
patches: project.meta.patches.open,
route: { resource: "repo.source", repo: r.rid, node: seed },
avatar: renderRepoAvatar(project.data.name),
};
});
repos = list.map(r => {
const project = r.payloads["xyz.radicle.project"];
return {
name: project?.data.name,
description: project?.data.description ?? "",
rid: r.rid,
seeding: r.seeding,
issues: r.cobs?.issues?.open,
patches: r.cobs?.patches?.open,
route: { resource: "repo.source", repo: r.rid, node: seed },
avatar: renderRepoAvatar(project?.data.name ?? r.rid),
};
});
} catch (error) {
if (token === loadToken && import.meta.env.DEV) {
console.warn(
@@ -361,6 +359,13 @@
white-space: nowrap;
}
.name-placeholder {
height: 1.125rem;
width: 40%;
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
}
.id-link {
font: var(--txt-code-regular);
color: var(--color-accent-blue-500);
@@ -623,21 +628,35 @@
class="repo-card">
<div class="repo-image">
{#if repo.avatar}
<img src={repo.avatar} alt={repo.name} class="repo-avatar" />
<img
src={repo.avatar}
alt={repo.name ?? repo.rid}
class="repo-avatar" />
{/if}
</div>
<div class="repo-body">
<div class="repo-info">
<h3 class="txt-bold-18">{repo.name}</h3>
{#if repo.name}
<h3 class="txt-bold-18">{repo.name}</h3>
{:else}
<div
class="name-placeholder"
title="This repository has no name">
</div>
{/if}
<p class="description txt-medium-14">{repo.description}</p>
<span class="id-link">{shortenRid(repo.rid)}</span>
</div>
<div class="meta-row">
<div class="stats txt-code-regular">
<span class="stat">{repo.issues} issues</span>
<span class="stat">{repo.patches} patches</span>
{#if repo.issues !== undefined}
<span class="stat">{repo.issues} issues</span>
{/if}
{#if repo.patches !== undefined}
<span class="stat">{repo.patches} patches</span>
{/if}
</div>
<span class="time txt-medium-14">{repo.seeding} seeds</span>
</div>
+1 -2
View File
@@ -39,8 +39,7 @@
.filter(
(r): r is PromiseFulfilledResult<Repo> => r.status === "fulfilled",
)
.map(r => r.value)
.filter(r => Boolean(r.payloads["xyz.radicle.project"]));
.map(r => r.value);
return repos.map(repo => ({
repo,
+8 -6
View File
@@ -48,7 +48,7 @@
export let revisionReactions: Comment["reactions"];
export let revisionAuthor: Author;
export let revisionDescription: string;
export let targetBranch: string;
export let targetBranch: string | undefined = undefined;
export let timelines: Timeline[];
export let previousRevBase: string | undefined = undefined;
export let previousRevId: string | undefined = undefined;
@@ -532,11 +532,13 @@
merged revision
<Id id={element.inner.revision} />
into
<span class="target-branch">
<Icon name="branch" />
{targetBranch}
</span>
{#if targetBranch}
into
<span class="target-branch">
<Icon name="branch" />
{targetBranch}
</span>
{/if}
at commit
<Id id={element.inner.commit} />
<span
+20 -17
View File
@@ -9,7 +9,7 @@
import config from "@app/lib/config";
import debounce from "lodash/debounce";
import { routeToPath } from "@app/lib/router";
import { toClipboard, visibleReleaseCount } from "@app/lib/utils";
import { toClipboard } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
@@ -21,8 +21,8 @@
export let repo: Repo;
export let repoId: string;
// Undefined on older nodes that don't report release counts.
$: releaseCount = visibleReleaseCount(repo);
// Undefined on nodes that cannot serve releases.
$: releaseCount = repo.releaseCount;
let shareIcon: "link" | "checkmark" = "link";
@@ -133,12 +133,14 @@
<Icon name="issue" />
<div class="title-counter">
Issues
<span
class="counter"
class:selected={activeTab === "issues"}
class:hover={hover && activeTab !== "issues"}>
{repo.payloads["xyz.radicle.project"].meta.issues.open}
</span>
{#if repo.cobs?.issues}
<span
class="counter"
class:selected={activeTab === "issues"}
class:hover={hover && activeTab !== "issues"}>
{repo.cobs.issues.open}
</span>
{/if}
</div>
</Button>
</Link>
@@ -153,18 +155,19 @@
<Icon name="patch" />
<div class="title-counter">
Patches
<span
class="counter"
class:hover={hover && activeTab !== "patches"}
class:selected={activeTab === "patches"}>
{repo.payloads["xyz.radicle.project"].meta.patches.open}
</span>
{#if repo.cobs?.patches}
<span
class="counter"
class:hover={hover && activeTab !== "patches"}
class:selected={activeTab === "patches"}>
{repo.cobs.patches.open}
</span>
{/if}
</div>
</Button>
</Link>
<!-- Only nodes that report release counts support the release API; hide the
tab on older nodes where the field is absent. -->
<!-- Only nodes that report a release count support the release API. -->
{#if releaseCount !== undefined}
<Link
route={{
+4 -2
View File
@@ -11,8 +11,10 @@
import config from "@app/lib/config";
import { HttpdClient } from "@http-client";
import {
defaultBranchName,
baseUrlToString,
formatQualifiedRefname,
repoName,
safeDecodeURIComponent,
} from "@app/lib/utils";
import { groupCommits } from "@app/lib/commit";
@@ -47,7 +49,7 @@
$: currentRefname = formatQualifiedRefname(
revision
? safeDecodeURIComponent(revision)
: repo.payloads["xyz.radicle.project"].data.defaultBranch,
: (defaultBranchName(repo) ?? ""),
peer,
);
@@ -128,7 +130,7 @@
{currentRefname}
id={repo.rid}
alias={repo.alias}
name={repo.payloads["xyz.radicle.project"].data.name} />
name={repoName(repo)} />
</svelte:fragment>
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
+2 -6
View File
@@ -216,9 +216,7 @@
<Markdown
breaks
content={issue.discussion[0].body}
rawPath={rawPath(
repo.payloads["xyz.radicle.project"].meta.head,
)} />
rawPath={rawPath(utils.defaultBranchTip(repo))} />
{:else}
<span style:color="var(--color-text-tertiary)">No description</span>
{/if}
@@ -237,9 +235,7 @@
<ThreadComponent
{baseUrl}
{thread}
rawPath={rawPath(
repo.payloads["xyz.radicle.project"].meta.head,
)} />
rawPath={rawPath(utils.defaultBranchTip(repo))} />
{#if i < threads.length - 1}
<div class="connector"></div>
{/if}
+18 -9
View File
@@ -3,7 +3,7 @@
import { HttpdClient } from "@http-client";
import { ISSUES_PER_PAGE } from "./router";
import { baseUrlToString } from "@app/lib/utils";
import { baseUrlToString, hasMorePages } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import ErrorMessage from "@app/components/ErrorMessage.svelte";
@@ -57,7 +57,12 @@
$: showMoreButton =
!loading &&
!error &&
allIssues.length < repo.payloads["xyz.radicle.project"].meta.issues[status];
hasMorePages(
allIssues.length,
page + 1,
ISSUES_PER_PAGE,
repo.cobs?.issues?.[status],
);
</script>
<style>
@@ -128,9 +133,11 @@
<Icon name="issue" />
<div class="title-counter">
Open
<span class="counter" class:selected={status === "open"}>
{repo.payloads["xyz.radicle.project"].meta.issues.open}
</span>
{#if repo.cobs?.issues}
<span class="counter" class:selected={status === "open"}>
{repo.cobs.issues.open}
</span>
{/if}
</div>
</Button>
</Link>
@@ -145,9 +152,11 @@
<Icon name="issue-closed" />
<div class="title-counter">
Closed
<span class="counter" class:selected={status === "closed"}>
{repo.payloads["xyz.radicle.project"].meta.issues.closed}
</span>
{#if repo.cobs?.issues}
<span class="counter" class:selected={status === "closed"}>
{repo.cobs.issues.closed}
</span>
{/if}
</div>
</Button>
</Link>
@@ -165,7 +174,7 @@
{error} />
{/if}
{#if repo.payloads["xyz.radicle.project"].meta.issues[status] === 0}
{#if repo.cobs?.issues?.[status] === 0}
<div class="placeholder">
<Placeholder iconName="issue" caption={`No ${status} issues`} />
</div>
+11 -5
View File
@@ -5,6 +5,7 @@
import { onMount } from "svelte";
import { activeRouteStore } from "@app/lib/router";
import { repoName } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Header from "@app/components/Header.svelte";
@@ -26,6 +27,7 @@
export let nodeAvatarUrl: string | undefined;
$: route = $activeRouteStore;
$: project = repo.payloads["xyz.radicle.project"];
$: isRepoHome = route.resource === "repo.source" && route.params.path === "/";
let trail: HTMLElement | undefined = undefined;
@@ -217,10 +219,10 @@
}}>
<div class="breadcrumb repo-crumb">
<RepoAvatar
name={repo.payloads["xyz.radicle.project"].data.name}
name={project?.data.name ?? ""}
rid={repo.rid}
styleWidth="1rem" />
{repo.payloads["xyz.radicle.project"].data.name}
{repoName(repo)}
</div>
</Link>
</span>
@@ -270,7 +272,9 @@
<div style:width="100%">
<Link
title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`}
title={repo.cobs?.issues
? `${repo.cobs.issues.open} Issues`
: "Issues"}
route={{
resource: "repo.issues",
repo: repoId,
@@ -286,7 +290,9 @@
<div style:width="100%">
<Link
title={`${repo.payloads["xyz.radicle.project"].meta.patches.open} Patches`}
title={repo.cobs?.patches
? `${repo.cobs.patches.open} Patches`
: "Patches"}
route={{
resource: "repo.patches",
repo: repoId,
@@ -300,7 +306,7 @@
</Link>
</div>
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
{#if repo.releaseCount !== undefined}
<div style:width="100%">
<Link
title="Releases"
+37 -30
View File
@@ -47,10 +47,12 @@
// The branch a patch is opened against, unqualified. The `delegates` target
// leaves the repository default implied.
function resolveTargetBranch(patch: Patch, repo: Repo): string {
return patch.target === "delegates"
? utils.defaultBranch(repo)
: utils.unqualifyBranch(patch.target.branch);
function resolveTargetBranch(patch: Patch, repo: Repo): string | undefined {
if (patch.target !== "delegates") {
return utils.unqualifyBranch(patch.target.branch);
}
return utils.defaultBranchName(repo);
}
// Nothing validates a patch's target when it is opened, so it can name a
@@ -58,7 +60,7 @@
// such a branch, so it is shown without a link rather than one that errors.
function isBrowsableBranch(branch: string, repo: Repo): boolean {
return (
branch === utils.defaultBranch(repo) ||
`${utils.REFS_HEADS}${branch}` === repo.defaultBranch ||
repo.refs?.refs[`${utils.REFS_HEADS}${branch}`] !== undefined
);
}
@@ -254,7 +256,8 @@
]);
$: targetBranch = resolveTargetBranch(patch, repo);
$: targetBranchCaption = `This patch merges into ${targetBranch}`;
$: targetBranchBrowsable = isBrowsableBranch(targetBranch, repo);
$: targetBranchBrowsable =
targetBranch !== undefined && isBrowsableBranch(targetBranch, repo);
$: firstRevision = timelineTuple[0][0];
$: latestRevision = patch.revisions[patch.revisions.length - 1];
</script>
@@ -418,30 +421,34 @@
<span class="code-chip">
<Id id={patch.id} />
</span>
<span class="global-flex-item" title={targetBranchCaption}>
<Icon name="arrow-right" />
</span>
<span class="code-chip">
{#if targetBranchBrowsable}
<Link
styleHoverState
title={targetBranchCaption}
route={{
resource: "repo.source",
repo: repoId,
node: baseUrl,
revision: targetBranch,
}}>
<Icon name="branch" />
<span class="txt-overflow">{targetBranch}</span>
</Link>
{:else}
<span title={targetBranchCaption}>
<Icon name="branch" />
<span class="txt-overflow">{targetBranch}</span>
</span>
{/if}
</span>
<!-- A `delegates` target implies the repository default, which a
repo without a default branch cannot name. -->
{#if targetBranch}
<span class="global-flex-item" title={targetBranchCaption}>
<Icon name="arrow-right" />
</span>
<span class="code-chip">
{#if targetBranchBrowsable}
<Link
styleHoverState
title={targetBranchCaption}
route={{
resource: "repo.source",
repo: repoId,
node: baseUrl,
revision: targetBranch,
}}>
<Icon name="branch" />
<span class="txt-overflow">{targetBranch}</span>
</Link>
{:else}
<span title={targetBranchCaption}>
<Icon name="branch" />
<span class="txt-overflow">{targetBranch}</span>
</span>
{/if}
</span>
{/if}
<span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
{utils.formatTimestamp(patch.revisions[0].timestamp)}
</span>
+28 -16
View File
@@ -4,7 +4,7 @@
import { HttpdClient } from "@http-client";
import { PATCHES_PER_PAGE } from "./router";
import { baseUrlToString } from "@app/lib/utils";
import { baseUrlToString, hasMorePages } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import ErrorMessage from "@app/components/ErrorMessage.svelte";
@@ -58,8 +58,12 @@
$: showMoreButton =
!loading &&
!error &&
allPatches.length <
repo.payloads["xyz.radicle.project"].meta.patches[status];
hasMorePages(
allPatches.length,
page + 1,
PATCHES_PER_PAGE,
repo.cobs?.patches?.[status],
);
</script>
<style>
@@ -133,9 +137,11 @@
<Icon name="patch" />
<div class="title-counter" class:active={status === "open"}>
Open
<span class="counter" class:selected={status === "open"}>
{repo.payloads["xyz.radicle.project"].meta.patches.open}
</span>
{#if repo.cobs?.patches}
<span class="counter" class:selected={status === "open"}>
{repo.cobs.patches.open}
</span>
{/if}
</div>
</Button>
</Link>
@@ -150,9 +156,11 @@
<Icon name="patch-draft" />
<div class="title-counter" class:active={status === "draft"}>
Draft
<span class="counter" class:selected={status === "draft"}>
{repo.payloads["xyz.radicle.project"].meta.patches.draft}
</span>
{#if repo.cobs?.patches}
<span class="counter" class:selected={status === "draft"}>
{repo.cobs.patches.draft}
</span>
{/if}
</div>
</Button>
</Link>
@@ -167,9 +175,11 @@
<Icon name="patch-archived" />
<div class="title-counter" class:active={status === "archived"}>
Archived
<span class="counter" class:selected={status === "archived"}>
{repo.payloads["xyz.radicle.project"].meta.patches.archived}
</span>
{#if repo.cobs?.patches}
<span class="counter" class:selected={status === "archived"}>
{repo.cobs.patches.archived}
</span>
{/if}
</div>
</Button>
</Link>
@@ -184,9 +194,11 @@
<Icon name="patch-merged" />
<div class="title-counter" class:active={status === "merged"}>
Merged
<span class="counter" class:selected={status === "merged"}>
{repo.payloads["xyz.radicle.project"].meta.patches.merged}
</span>
{#if repo.cobs?.patches}
<span class="counter" class:selected={status === "merged"}>
{repo.cobs.patches.merged}
</span>
{/if}
</div>
</Button>
</Link>
@@ -204,7 +216,7 @@
{error} />
{/if}
{#if repo.payloads["xyz.radicle.project"].meta.patches[status] === 0}
{#if repo.cobs?.patches?.[status] === 0}
<div class="placeholder">
<Placeholder iconName="patch" caption={`No ${status} patches`} />
</div>
+7 -7
View File
@@ -2,7 +2,7 @@
import type { BaseUrl, Release, ReleaseScope, Repo } from "@http-client";
import { HttpdClient } from "@http-client";
import { RELEASES_PER_PAGE, releasesQuery, scopeReleases } from "./router";
import { releasesQuery } from "./router";
import { baseUrlToString } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
@@ -42,9 +42,8 @@
$: delegateIds = new Set(repo.delegates.map(d => d.id));
// Older nodes report a single count, so their segments go without counters.
$: releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
$: counts = typeof releasesMeta === "object" ? releasesMeta : undefined;
// Without counts per scope, the segments go without counters.
$: counts = repo.cobs?.releases;
const api = new HttpdClient(baseUrl);
@@ -54,12 +53,13 @@
// doesn't leave a gap behind on retry.
const next = page + 1;
try {
const response = await api.repo.getAllReleases(
const response = await api.repo.getReleasesPage(
repo.rid,
repo.delegates,
releasesQuery(scope, next),
);
allReleases = [...allReleases, ...scopeReleases(response, repo, scope)];
morePages = response.length === RELEASES_PER_PAGE;
allReleases = [...allReleases, ...response.releases];
morePages = response.morePages;
page = next;
} catch (e) {
error = e;
+4 -2
View File
@@ -5,7 +5,9 @@
import { HttpdClient } from "@http-client";
import {
defaultBranchName,
formatQualifiedRefname,
repoName,
safeDecodeURIComponent,
} from "@app/lib/utils";
@@ -57,7 +59,7 @@
$: currentRefname = formatQualifiedRefname(
revision
? safeDecodeURIComponent(revision)
: repo.payloads["xyz.radicle.project"].data.defaultBranch,
: (defaultBranchName(repo) ?? ""),
peer,
);
@@ -146,7 +148,7 @@
{currentRefname}
id={repo.rid}
alias={repo.alias}
name={repo.payloads["xyz.radicle.project"].data.name} />
name={repoName(repo)} />
</svelte:fragment>
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
@@ -12,7 +12,7 @@
// with many peers. Going through `cached` shares a single request across
// every Header instance for the same repo, including instances that mount
// while the request is still in flight.
const getPeers = cached(
export const getPeers = cached(
async (baseUrl: BaseUrl, rid: string) => {
const remotes = await new HttpdClient(baseUrl).repo.getAllRemotes(rid);
return remotes.map(remoteToPeerRefs).filter(peerHasBranches);
@@ -26,6 +26,8 @@
import type { Repo, Tree } from "@http-client";
import type { ComponentProps } from "svelte";
import { defaultBranchName as getDefaultBranchName } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import CommitButton from "../components/CommitButton.svelte";
import Icon from "@app/components/Icon.svelte";
@@ -96,20 +98,19 @@
let selectedBranch: string | undefined;
let commitButtonVariant: ComponentProps<CommitButton>["variant"] | undefined;
// Route revisions are unqualified, so compare against the short form.
$: defaultBranchName = getDefaultBranchName(repo);
// Revision may be a commit ID, a branch name or `undefined` which means the
// default branch. We assign `selectedBranch` accordingly.
$: if (revision === lastCommit.id) {
selectedBranch = undefined;
} else {
selectedBranch =
revision || repo.payloads["xyz.radicle.project"].data.defaultBranch;
selectedBranch = revision || defaultBranchName;
}
$: lastCommit = tree.lastCommit;
$: onCanonical = Boolean(
!peer &&
selectedBranch === repo.payloads["xyz.radicle.project"].data.defaultBranch,
);
$: onCanonical = Boolean(!peer && selectedBranch === defaultBranchName);
$: if (onCanonical) {
commitButtonVariant = "right";
} else if (!selectedBranch) {
@@ -9,6 +9,8 @@
formatCommit,
formatNodeId,
formatTimestamp,
defaultBranchName as getDefaultBranchName,
defaultBranchTip,
getBranchesFromRefs,
getTagsFromRefs,
gravatarURL,
@@ -53,6 +55,8 @@
type: "branch" | "tag";
};
$: defaultBranchName = getDefaultBranchName(repo);
$: tip = defaultBranchTip(repo);
$: peerList = peers ?? [];
$: canonicalBranchesMap = getBranchesFromRefs(repo.refs?.refs ?? {});
$: canonicalTagsInfo = Object.fromEntries(
@@ -63,18 +67,20 @@
);
$: branchElements = [
{
peer: undefined,
revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
head: repo.payloads["xyz.radicle.project"].meta.head,
type: "branch",
},
// Only listed separately when it resolves; otherwise the canonical refs
// below already carry every branch there is.
...(defaultBranchName && tip
? [
{
peer: undefined,
revision: defaultBranchName,
head: tip,
type: "branch",
},
]
: []),
...Object.entries(canonicalBranchesMap)
.filter(
([branchName]) =>
branchName !==
repo.payloads["xyz.radicle.project"].data.defaultBranch,
)
.filter(([branchName]) => branchName !== defaultBranchName)
.map(([name, head]) => ({
peer: undefined,
revision: name,
@@ -131,8 +137,7 @@
info => info.tagger?.timestamp ?? 0,
).flatMap(group => group.tags);
$: canonicalBranches = Object.entries(canonicalBranchesMap).filter(
([branchName]) =>
branchName !== repo.payloads["xyz.radicle.project"].data.defaultBranch,
([branchName]) => branchName !== defaultBranchName,
);
$: hasTags =
Object.keys(canonicalTagsInfo).length > 0 ||
@@ -381,6 +386,9 @@
Canonical
</Badge>
{/if}
{:else if !displayPeer}
<Icon name="branch" />
<span class="txt-overflow">Select branch</span>
{/if}
<Icon name={expanded ? "chevron-up" : "chevron-down"} />
</Button>
@@ -532,26 +540,30 @@
{/if}
{/each}
{:else if selectedTab === "branches"}
<Link
style={subgridStyle}
route={{ ...baseRoute, revision: undefined }}
on:afterNavigate={() => {
searchInput = "";
toggle();
}}>
<DropdownListItem selected={onCanonical} style={subgridStyle}>
<div class="global-flex-item">
<Icon name="branch" />
{repo.payloads["xyz.radicle.project"].data.defaultBranch}
<Badge title="Canonical branch" variant="foreground-emphasized">
Canonical
</Badge>
</div>
<div class="txt-id">
{formatCommit(repo.payloads["xyz.radicle.project"].meta.head)}
</div>
</DropdownListItem>
</Link>
{#if defaultBranchName && tip}
<Link
style={subgridStyle}
route={{ ...baseRoute, revision: undefined }}
on:afterNavigate={() => {
searchInput = "";
toggle();
}}>
<DropdownListItem selected={onCanonical} style={subgridStyle}>
<div class="global-flex-item">
<Icon name="branch" />
{defaultBranchName}
<Badge
title="Canonical branch"
variant="foreground-emphasized">
Canonical
</Badge>
</div>
<div class="txt-id">
{formatCommit(tip)}
</div>
</DropdownListItem>
</Link>
{/if}
{#each canonicalBranches as [branchName, branchHead]}
<Link
style={subgridStyle}
@@ -60,6 +60,13 @@
text-align: left;
text-overflow: ellipsis;
}
.name-placeholder {
display: block;
width: 12rem;
height: 1.5rem;
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
}
.repo-name:hover {
color: inherit;
}
@@ -108,7 +115,10 @@
<div class="header-layout">
<div class="avatar">
<RepoAvatar name={project.data.name} rid={repo.rid} styleWidth="10rem" />
<RepoAvatar
name={project?.data.name ?? ""}
rid={repo.rid}
styleWidth="10rem" />
</div>
<div class="meta">
<div class="title">
@@ -119,9 +129,12 @@
repo: repoId,
node: baseUrl,
}}>
<span class="repo-name">
{project.data.name}
</span>
{#if project}
<span class="repo-name">{project.data.name}</span>
{:else}
<span class="name-placeholder" title="This repository has no name">
</span>
{/if}
</Link>
</span>
{#if repo.visibility.type === "private"}
@@ -136,19 +149,23 @@
{formatRepositoryId(repo.rid)}
</Id>
</div>
{#if project}
<div
class="description global-hide-on-mobile-down"
title={project.data.description}
use:twemoji>
{@html render(project.data.description)}
</div>
{/if}
</div>
{#if project}
<div
class="description global-hide-on-mobile-down"
class="description mobile-description global-hide-on-small-desktop-up"
title={project.data.description}
use:twemoji>
{@html render(project.data.description)}
</div>
</div>
<div
class="description mobile-description global-hide-on-small-desktop-up"
title={project.data.description}
use:twemoji>
{@html render(project.data.description)}
</div>
{/if}
<div class="info">
<ContextRepo
{baseUrl}
@@ -0,0 +1,74 @@
<script lang="ts">
import type { BaseUrl, PeerRefs, Repo, SeedingPolicy } from "@http-client";
import { getPeers } from "./Source/Header.svelte";
import { defaultBranchName } from "@app/lib/utils";
import Layout from "./Layout.svelte";
import PeerBranchSelector from "./Source/PeerBranchSelector.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import RepoNameHeader from "./Source/RepoNameHeader.svelte";
export let baseUrl: BaseUrl;
export let repo: Repo;
export let repoId: string;
export let seedingPolicy: SeedingPolicy;
export let peer: string | undefined = undefined;
export let nodeId: string;
export let nodeAvatarUrl: string | undefined;
let peers: PeerRefs[] | undefined = undefined;
function fetchPeers(node: BaseUrl, rid: string) {
peers = undefined;
void getPeers(node, rid)
.then(result => {
peers = result;
})
.catch(error => console.error("Failed to load repo remotes", error));
}
$: fetchPeers(baseUrl, repo.rid);
$: branch = defaultBranchName(repo);
$: reason = !branch
? "This repository has no default branch."
: peer
? `This peer has not pushed the ${branch} branch.`
: `The default branch ${branch} has no canonical commit.`;
$: caption = `${reason} Select a branch or tag to browse.`;
</script>
<style>
.subheader {
display: flex;
padding: 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.placeholder {
display: flex;
justify-content: center;
padding: 4rem 1rem;
}
</style>
<!-- A repository with no default branch, a default branch without a canonical
commit, or a peer who hasn't pushed it has nothing to open at. Keep the shell
so the issue and patch tabs stay reachable, and offer the peer branch
selector. -->
<Layout {baseUrl} {repo} {repoId} {nodeId} {nodeAvatarUrl} activeTab="source">
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
<div class="subheader" slot="subheader">
<PeerBranchSelector
baseRoute={{ resource: "repo.source", repo: repoId, node: baseUrl }}
onCanonical={false}
{peer}
selectedBranch={undefined}
{peers}
{repo} />
</div>
<div class="placeholder">
<Placeholder iconName="branch" {caption} />
</div>
</Layout>
+110 -123
View File
@@ -36,8 +36,11 @@ import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
import {
REFS_HEADS,
defaultBranchName,
defaultBranchTip,
getBranchesFromRefs,
getTagsFromRefs,
repoName,
unreachable,
} from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";
@@ -217,6 +220,18 @@ export type RepoLoadedRoute =
nodeAvatarUrl: string | undefined;
};
}
| {
resource: "repo.sourceUnavailable";
params: {
baseUrl: BaseUrl;
seedingPolicy: SeedingPolicy;
repo: Repo;
repoId: string;
peer: string | undefined;
nodeId: string;
nodeAvatarUrl: string | undefined;
};
}
| {
resource: "repo.commit";
params: {
@@ -354,24 +369,17 @@ export const cachedGetDiffStats = cached(
);
function parseRevisionToOid(
revision: string | undefined,
defaultBranch: string,
revision: string,
branches: Record<string, string>,
): string {
if (revision) {
if (isOid(revision)) {
return revision;
} else {
const oid = branches[revision];
if (oid) {
return oid;
} else {
throw new Error(`Revision ${revision} not found`);
}
}
} else {
return branches[defaultBranch];
if (isOid(revision)) {
return revision;
}
const oid = branches[revision];
if (oid) {
return oid;
}
throw new Error(`Revision ${revision} not found`);
}
export async function loadRepoRoute(
@@ -492,44 +500,46 @@ async function loadIssuesView(
};
}
// Older nodes don't report a release count and lack the release API. Show a
// friendly page pointing to another node instead of a raw 404.
// Neither the project nor the canonical refs payload names a branch, or the
// named branch has no tip, so there is no revision to browse. The repo still has COBs, so render its shell rather
// than a full-page not-found that would take the issue and patch tabs with it.
function sourceUnavailable(
route: { node: BaseUrl; repo: string; peer?: string },
repo: Repo,
seedingPolicy: SeedingPolicy,
node: { id: string; avatarUrl?: string | undefined },
): RepoLoadedRoute {
return {
resource: "repo.sourceUnavailable",
params: {
baseUrl: route.node,
repoId: route.repo,
repo,
seedingPolicy,
peer: route.peer,
nodeId: node.id,
nodeAvatarUrl: node.avatarUrl,
},
};
}
// A missing release count means the node cannot serve releases: it predates
// the release API or was built without release support.
function releasesNotSupported(node: BaseUrl): NotFoundRoute {
return {
resource: "notFound",
params: {
title: "Releases are not available on this node",
description:
"This node is running an older version that doesn't support browsing releases yet.\nSelect a different node to continue.",
"This node is running an older version or was built without release support.\nSelect a different node to continue.",
baseUrl: node,
},
};
}
// The query for a page of releases in one author scope. Older nodes ignore
// `scope`, so ask them for every author too when the scope is untrusted.
// The query for a page of releases in one author scope.
export function releasesQuery(scope: ReleaseScope, page: number) {
return {
scope,
allAuthors: scope === "untrusted" ? true : undefined,
page,
perPage: RELEASES_PER_PAGE,
};
}
// Keep only the releases in the author scope. A newer node has done this
// already; an older one answers the untrusted scope with every author. Its pages
// then come back short, so page on the length of the response, not of this.
export function scopeReleases(
releases: Release[],
repo: Repo,
scope: ReleaseScope,
): Release[] {
if (scope === "trusted") {
return releases;
}
const delegateIds = new Set(repo.delegates.map(d => d.id));
return releases.filter(r => !delegateIds.has(r.creator.id));
return { scope, page, perPage: RELEASES_PER_PAGE };
}
async function loadReleasesView(
@@ -540,42 +550,48 @@ async function loadReleasesView(
// shows only when asked for.
const scope = route.scope ?? "trusted";
// Fetch releases alongside the repo so the common path stays parallel; the
// repo's `meta.releases` then tells us whether a failure is an unsupported
// node or a genuine error.
const [repo, releasesResult, node] = await Promise.all([
api.repo.getByRid(route.repo),
api.repo.getAllReleases(route.repo, releasesQuery(scope, 0)).then(
releases => ({ releases }),
(error: unknown) => ({ error }),
),
// Fetch the page alongside the repo. The repo then tells whether a failed
// fetch is a node without releases or a real error.
const repoPromise = api.repo.getByRid(route.repo);
const [repo, pageResult, showFilters, node] = await Promise.all([
repoPromise,
api.repo
.getReleasesPage(
route.repo,
repoPromise.then(repo => repo.delegates),
releasesQuery(scope, 0),
)
.then(
page => ({ page }),
(error: unknown) => ({ error }),
),
// Others is one click away whenever it holds releases, and Delegates is
// one click back from it.
scope === "untrusted" ||
repoPromise.then(
repo =>
repo.releaseCount !== undefined &&
api.repo.hasOtherReleases(repo, RELEASES_PER_PAGE),
),
api.getNode(),
]);
// Older nodes report a single count here, newer ones the buckets, and
// either way its absence means no release API.
const releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
if (releasesMeta === undefined) {
// A missing release count means the node cannot serve releases.
if (repo.releaseCount === undefined) {
return releasesNotSupported(route.node);
}
if ("error" in releasesResult) {
throw releasesResult.error;
if ("error" in pageResult) {
throw pageResult.error;
}
const page = releasesResult.releases;
// Others is one click away whenever it holds releases.
const showFilters =
typeof releasesMeta !== "number"
? releasesMeta.other > 0
: scope === "untrusted" || (await legacyHasOthers(api, route.repo, repo));
const { page } = pageResult;
return {
resource: "repo.releases",
params: {
baseUrl: route.node,
repoId: route.repo,
releases: scopeReleases(page, repo, scope),
lastPageFull: page.length === RELEASES_PER_PAGE,
releases: page.releases,
lastPageFull: page.morePages,
scope,
showFilters,
repo,
@@ -585,25 +601,6 @@ async function loadReleasesView(
};
}
// Older nodes report a single release count without the delegate and
// non-delegate buckets, so look for others 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 keeps the filter. Remove this once
// nodes without the buckets are no longer supported.
async function legacyHasOthers(
api: HttpdClient,
repoId: string,
repo: Repo,
): Promise<boolean> {
const everyAuthor = await api.repo
.getAllReleases(repoId, releasesQuery("untrusted", 0))
.catch(() => undefined);
if (everyAuthor === undefined || everyAuthor.length === RELEASES_PER_PAGE) {
return true;
}
return scopeReleases(everyAuthor, repo, "untrusted").length > 0;
}
async function loadReleaseView(
route: RepoReleaseRoute,
): Promise<RepoLoadedRoute | NotFoundRoute> {
@@ -618,7 +615,7 @@ async function loadReleaseView(
api.getNode(),
]);
if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) {
if (repo.releaseCount === undefined) {
return releasesNotSupported(route.node);
}
if ("error" in releaseResult) {
@@ -679,13 +676,6 @@ async function loadTreeView(
nodePromise,
]);
if (!repo["payloads"]["xyz.radicle.project"]) {
throw new Error(
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
);
}
const project = repo["payloads"]["xyz.radicle.project"];
let branchMap = canonicalBranchMap(repo);
if (route.peer) {
@@ -708,11 +698,13 @@ async function loadTreeView(
route.path = path;
}
const commit = parseRevisionToOid(
route.revision,
project.data.defaultBranch,
branchMap,
);
const revision = route.revision || defaultBranchName(repo);
// Without a delegate quorum the default branch has no canonical tip.
if (!revision || (!route.revision && !branchMap[revision])) {
return sourceUnavailable(route, repo, seedingPolicy, node);
}
const commit = parseRevisionToOid(revision, branchMap);
const path = route.path || "/";
const [tree, blobResult] = await Promise.all([
api.repo.getTree(route.repo, commit),
@@ -827,17 +819,13 @@ async function loadHistoryView(
? await getPeerBranchMap(api, route.repo, route.peer)
: canonicalBranchMap(repo);
if (!repo["payloads"]["xyz.radicle.project"]) {
throw new Error(
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
);
const revision = route.revision || defaultBranchName(repo);
// Without a delegate quorum the default branch has no canonical tip.
if (!revision || (!route.revision && !branchMap[revision])) {
return sourceUnavailable(route, repo, seedingPolicy, node);
}
const project = repo["payloads"]["xyz.radicle.project"];
const commitId =
route.revision && isOid(route.revision)
? route.revision
: branchMap[route.revision || project.data.defaultBranch];
const commitId = isOid(revision) ? revision : branchMap[revision];
if (!commitId) {
throw new Error(
@@ -1032,11 +1020,12 @@ async function loadPatchView(
function canonicalBranchMap(repo: Repo): Record<string, string> {
const branchMap: Record<string, string> = {};
const project = repo.payloads["xyz.radicle.project"];
if (project) {
branchMap[project.data.defaultBranch] = project.meta.head;
branchMap[encodeURIComponent(project.data.defaultBranch)] =
project.meta.head;
// `defaultBranch` is qualified but this map is keyed by short names.
const name = defaultBranchName(repo);
const tip = defaultBranchTip(repo);
if (name && tip) {
branchMap[name] = tip;
branchMap[encodeURIComponent(name)] = tip;
}
for (const [refName, oid] of canonicalOids(repo.refs)) {
@@ -1362,35 +1351,31 @@ function patchRouteToPath(route: RepoPatchRoute): string {
export function repoTitle(loadedRoute: RepoLoadedRoute) {
const title: string[] = [];
if (!loadedRoute.params.repo["payloads"]["xyz.radicle.project"]) {
throw new Error(
`Repository ${loadedRoute.params.repo.rid} does not have a xyz.radicle.project payload.`,
);
}
const project = loadedRoute.params.repo["payloads"]["xyz.radicle.project"];
const repo = loadedRoute.params.repo;
const project = repo["payloads"]["xyz.radicle.project"];
if (loadedRoute.resource === "repo.source") {
title.push(project.data.name);
if (project.data.description.length > 0) {
title.push(repoName(repo));
if (project && project.data.description.length > 0) {
title.push(project.data.description);
}
} else if (loadedRoute.resource === "repo.commit") {
title.push(loadedRoute.params.commit.commit.summary);
title.push("Commit");
} else if (loadedRoute.resource === "repo.history") {
title.push(project.data.name);
title.push(repoName(repo));
title.push("Commits");
} else if (loadedRoute.resource === "repo.issue") {
title.push(loadedRoute.params.issue.title);
title.push("Issue");
} else if (loadedRoute.resource === "repo.issues") {
title.push(project.data.name);
title.push(repoName(repo));
title.push("Issues");
} else if (loadedRoute.resource === "repo.patch") {
title.push(loadedRoute.params.patch.title);
title.push("Patch");
} else if (loadedRoute.resource === "repo.patches") {
title.push(project.data.name);
title.push(repoName(repo));
title.push("Patches");
} else if (loadedRoute.resource === "repo.release") {
title.push(
@@ -1400,8 +1385,10 @@ export function repoTitle(loadedRoute: RepoLoadedRoute) {
);
title.push("Release");
} else if (loadedRoute.resource === "repo.releases") {
title.push(project.data.name);
title.push(repoName(repo));
title.push("Releases");
} else if (loadedRoute.resource === "repo.sourceUnavailable") {
title.push(repoName(repo));
} else {
return unreachable(loadedRoute);
}