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
+10 -4
View File
@@ -9,10 +9,12 @@
# whatever a job that finished earlier committed is gone.
#
# check therefore runs first, and is the only step that writes content no other
# step produces: the lint caches, and the pinned binaries. The four steps that
# follow all copy its version and add nothing unique to it — `npm ci` against the
# same lockfile fetches the same tarballs, and install-binaries does nothing once
# the pinned version is there. Whichever of them commits last keeps everything.
# step produces: the lint caches, and the pinned binaries. The steps that follow
# all copy its version and add nothing unique to it — `npm ci` against the same
# lockfile fetches the same tarballs, and install-binaries does nothing once the
# pinned version is there. Whichever of them commits last keeps everything. The
# one exception is build-smoke, which adds the Open Graph worker's tarballs: when
# another step commits after it, they are lost and fetched again next time.
#
# Stale content is safe. The npm cache is content-addressed, and install-binaries
# keys its download on the pinned version.
@@ -126,3 +128,7 @@ steps:
- npm ci
- npm run build
- npm run test:e2e -- --project chromium --config playwright.buildSmoke.config.ts
# Bundles the Open Graph worker without deploying it.
# --prefix keeps the relative NPM_CONFIG_CACHE in explorer/
- npm ci --prefix workers/open-graph
- npm run check:open-graph
+6
View File
@@ -43,6 +43,12 @@ jobs:
run: npm run build
- name: Run Playwright build smoke test
run: npm run test:e2e -- --project chromium --config playwright.buildSmoke.config.ts
# The worker resolves compat.ts imports, such as zod, from explorer's
# node_modules, so it needs both installs.
- name: Install Open Graph worker dependencies
run: npm ci --prefix workers/open-graph
- name: Bundle Open Graph worker
run: npm run check:open-graph
- name: Upload artifacts
uses: actions/upload-artifact@v7
if: always()
+4 -3
View File
@@ -81,9 +81,10 @@ changes go through expand and contract, see
## Pre-push checklist
`npm run check:ci` runs the checks, unit, http-client, e2e and build smoke tests
from the GitHub workflows, and stops at the first failure. It logs each step's
output, duration and exit code to `tests/tmp/check-ci.log`. If Rust code
changed, also run the Rust commands and the `:local` variants above.
and the Open Graph worker bundle from the GitHub workflows, and stops at the
first failure. It logs each step's output, duration and exit code to
`tests/tmp/check-ci.log`. If Rust code changed, also run the Rust commands and
the `:local` variants above.
## Svelte components
+141 -33
View File
@@ -33,6 +33,9 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
pub const API_VERSION: &str = "6.2.0";
/// Prefix of a qualified branch reference.
const REFS_HEADS: &str = "refs/heads/";
/// Thread-safe wrapper around radicle's web configuration.
///
/// This struct provides concurrent read/write access to web configuration
@@ -192,35 +195,82 @@ impl Context {
let db = &self.profile.database()?;
let seeding = db.count(&rid).unwrap_or_default();
// Heartwood resolves this from the `xyz.radicle.crefs` symbolic HEAD,
// falling back to the project payload, so it works for repos that have
// neither a name nor a description. Reported qualified, to match the
// crefs payload and the keys of `refs`.
//
// Only ever a branch: explorer is branch-shaped throughout, so a
// HEAD resolving elsewhere leaves the repo without a default branch
// rather than a tag rendered as a branch it is not.
let default_branch = doc
.default_branch()
.ok()
.filter(|branch| branch.as_str().starts_with(REFS_HEADS))
.map(|branch| branch.to_string());
// Counts are computed rather than declared, so they can differ between
// seeds. A count this node cannot determine is omitted: zero is a claim
// we would not be entitled to make.
let patches = self
.profile
.patches(repo)
.ok()
.and_then(|patches| patches.counts().ok());
let issues = self
.profile
.issues(repo)
.ok()
.and_then(|issues| issues.counts().ok());
let mut cobs = json!({});
if let Some(issues) = &issues {
cobs["issues"] = json!(issues);
}
if let Some(patches) = &patches {
cobs["patches"] = json!(patches);
}
if with_releases {
add_releases_count(&mut cobs, repo, &self.profile);
}
// Compat: older clients read the counts and the head from inside the
// project payload. Server: remove this and the `head` below in a major
// release, see docs/api-changes.md.
//
// Those clients require every field of `meta`, so when one is missing
// `meta` is left out. The project payload is still sent, for newer
// clients to read the name and description from.
//
// `meta.releases` stays a single number, the releases a user can browse.
let head = repo.head().ok().map(|(_, oid)| oid);
let legacy_meta = match (head, &issues, &patches) {
(Some(head), Some(_), Some(_)) => {
let mut meta = cobs.clone();
meta["head"] = json!(head);
if let Some(releases) = cobs.get("releases") {
let count = ["delegate", "other"]
.iter()
.filter_map(|bucket| releases[*bucket].as_u64())
.sum::<u64>();
meta["releases"] = json!(count);
}
Some(meta)
}
_ => None,
};
let payloads: BTreeMap<PayloadId, Value> = doc
.payload()
.iter()
.filter_map(|(id, payload)| {
.map(|(id, payload)| {
let mut value = json!({ "data": payload });
if id == &PayloadId::project() {
let (_, head) = repo.head().ok()?;
let patches = self.profile.patches(repo).ok()?;
let patches = patches.counts().ok()?;
let issues = self.profile.issues(repo).ok()?;
let issues = issues.counts().ok()?;
let mut meta = json!({
"head": head,
"issues": issues,
"patches": patches
});
if with_releases {
add_releases_meta(&mut meta, repo, &self.profile);
if let Some(meta) = &legacy_meta {
value["meta"] = json!(meta);
}
Some((
id.clone(),
json!({
"data": payload,
"meta": meta
}),
))
} else {
Some((id.clone(), json!({ "data": payload })))
}
(id.clone(), value)
})
.collect();
@@ -230,6 +280,8 @@ impl Context {
Ok(repo::Info {
payloads,
cobs,
default_branch,
delegates,
threshold: doc.threshold(),
visibility: doc.visibility().clone(),
@@ -266,21 +318,29 @@ impl Context {
}
}
/// Add the count of releases a user can browse to a project payload's `meta`
/// object: those not left with every artifact redacted by a trusted party.
/// Add the bucketed release counts to a repo's `cobs` object. A count this
/// node cannot determine is omitted, like the issue and patch counts.
#[cfg(feature = "artifacts")]
fn add_releases_meta(meta: &mut Value, repo: &Repository, profile: &Profile) {
let counts = Releases::open_cached(repo, cache_db_path(profile.cobs()))
fn add_releases_count(cobs: &mut Value, repo: &Repository, profile: &Profile) {
let Some(counts) = Releases::open_cached(repo, cache_db_path(profile.cobs()))
.ok()
.and_then(|releases| releases.counts().ok())
.unwrap_or_default();
else {
return;
};
meta["releases"] = json!(counts.delegate + counts.other);
cobs["releases"] = json!({
"delegate": counts.delegate,
"delegateRedacted": counts.delegate_redacted,
"other": counts.other,
"otherRedacted": counts.other_redacted,
});
}
/// Without artifact support there is no release store, so the key is omitted.
/// Its absence is how clients learn this node cannot serve releases.
#[cfg(not(feature = "artifacts"))]
fn add_releases_meta(_meta: &mut Value, _repo: &Repository, _profile: &Profile) {}
fn add_releases_count(_cobs: &mut Value, _repo: &Repository, _profile: &Profile) {}
/// Run a blocking closure on the blocking thread pool.
///
@@ -319,7 +379,13 @@ impl ReadCanonicalRefs for Repository {
) -> Result<Vec<(git::fmt::RefString, git::Oid)>, error::Error> {
let mut refs = Vec::new();
for r in self.backend.references_glob(pattern.as_str())? {
let r = r?;
let r = match r {
Ok(r) => r,
Err(e) => {
tracing::warn!("skipping unreadable ref matching {pattern}: {e}");
continue;
}
};
let Some(refname) = r
.name()
.ok()
@@ -389,7 +455,14 @@ where
{
let mut canonical = repo::CanonicalReferences::default();
for pattern in patterns {
for (refname, oid) in repo.find_by_pattern(pattern)? {
let matches = match repo.find_by_pattern(pattern) {
Ok(matches) => matches,
Err(e) => {
tracing::warn!("skipping canonical refs rule {pattern}: {e}");
continue;
}
};
for (refname, oid) in matches {
if refname.as_str().starts_with("refs/tags/") {
match repo.resolve_tag(oid) {
Ok(tag) => {
@@ -568,6 +641,13 @@ mod repo {
#[serde(rename_all = "camelCase")]
pub struct Info {
pub payloads: BTreeMap<PayloadId, Value>,
/// Repo-level COB counts. Each is absent when this node cannot
/// determine it; the object itself is always present.
pub cobs: Value,
/// The qualified default branch, resolved from either the canonical
/// refs or the project payload. Absent when neither names a branch.
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
pub delegates: Vec<Value>,
pub threshold: usize,
pub visibility: Visibility,
@@ -780,7 +860,7 @@ mod tests {
}
mod mock {
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use radicle::git;
@@ -794,6 +874,7 @@ mod tests {
struct StubRepo {
refs: HashMap<String, Vec<(RefString, git::Oid)>>,
failing_patterns: HashSet<String>,
peel_errors: HashMap<git::Oid, String>,
annotated_tags: HashMap<git::Oid, (String, String, i64, String)>,
}
@@ -803,6 +884,9 @@ mod tests {
&self,
pattern: &QualifiedPattern,
) -> Result<Vec<(RefString, git::Oid)>, error::Error> {
if self.failing_patterns.contains(pattern.as_str()) {
return Err(error::Error::NotFound);
}
Ok(self.refs.get(pattern.as_str()).cloned().unwrap_or_default())
}
}
@@ -875,6 +959,7 @@ mod tests {
vec![(refname("refs/tags/v1"), tag)],
),
]),
failing_patterns: HashSet::new(),
peel_errors: HashMap::new(),
annotated_tags: HashMap::new(),
};
@@ -898,6 +983,7 @@ mod tests {
pattern.as_str().to_owned(),
vec![(refname("refs/tags/v1"), tag)],
)]),
failing_patterns: HashSet::new(),
peel_errors: HashMap::new(),
annotated_tags: HashMap::from([(
tag,
@@ -932,6 +1018,7 @@ mod tests {
(refname("refs/heads/broken"), bad),
],
)]),
failing_patterns: HashSet::new(),
peel_errors: HashMap::from([(bad, "missing".to_owned())]),
annotated_tags: HashMap::new(),
};
@@ -941,10 +1028,31 @@ mod tests {
assert!(!result.refs.contains_key(&refname("refs/heads/broken")));
}
#[test]
fn skips_rules_whose_lookup_fails() {
let head = oid("1111111111111111111111111111111111111111");
let heads = pat("refs/heads/*");
let tags = pat("refs/tags/*");
let stub = StubRepo {
refs: HashMap::from([(
heads.as_str().to_owned(),
vec![(refname("refs/heads/main"), head)],
)]),
failing_patterns: HashSet::from([tags.as_str().to_owned()]),
peel_errors: HashMap::new(),
annotated_tags: HashMap::new(),
};
let result = canonical_refs_for_patterns(&stub, [&tags, &heads]).unwrap();
assert_eq!(result.refs.get(&refname("refs/heads/main")), Some(&head));
assert!(result.tags.is_empty());
}
#[test]
fn empty_patterns_yield_empty_result() {
let stub = StubRepo {
refs: HashMap::new(),
failing_patterns: HashSet::new(),
peel_errors: HashMap::new(),
annotated_tags: HashMap::new(),
};
@@ -134,6 +134,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -171,6 +184,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 0,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 0,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -230,6 +256,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -267,6 +306,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 0,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 0,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -1407,6 +1407,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -1444,6 +1457,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 0,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 0,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -1494,6 +1520,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -1531,6 +1570,19 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 0,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 0,
"closed": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -1712,6 +1764,25 @@ mod routes {
}
}
},
"cobs": {
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
"releases": {
"delegate": 0,
"delegateRedacted": 0,
"other": 0,
"otherRedacted": 0,
},
},
"defaultBranch": "refs/heads/master",
"delegates": [
{
"id": DID,
@@ -2302,6 +2373,40 @@ mod routes {
assert!(refs["tags"].get("refs/tags/v2.0-rc").is_none());
}
#[tokio::test]
async fn test_repos_without_canonical_head() {
let tmp = tempfile::tempdir().unwrap();
let app = super::router(seed_no_quorum(tmp.path()));
let response = get(&app, format!("/repos/{RID}")).await;
assert_eq!(response.status(), StatusCode::OK);
// Without a head there is no `meta`, but the project payload is kept.
let body = response.json().await;
let project = &body["payloads"]["xyz.radicle.project"];
assert_eq!(project["data"]["name"], json!("hello-world"));
assert!(project.get("meta").is_none());
assert_eq!(body["defaultBranch"], json!("refs/heads/master"));
assert_eq!(body["cobs"]["issues"]["open"], json!(1));
assert_eq!(body["cobs"]["patches"]["open"], json!(1));
}
#[cfg(not(feature = "artifacts"))]
#[tokio::test]
async fn test_repos_without_release_support() {
let tmp = tempfile::tempdir().unwrap();
let app = super::router(seed(tmp.path()));
let body = get(&app, format!("/repos/{RID}")).await.json().await;
// Clients detect release support by the count, so it must be absent
// rather than zero.
assert!(body["cobs"].get("releases").is_none());
assert!(body["payloads"]["xyz.radicle.project"]["meta"]
.get("releases")
.is_none());
assert_eq!(body["cobs"]["issues"]["open"], json!(1));
}
#[tokio::test]
async fn test_repos_blob() {
let tmp = tempfile::tempdir().unwrap();
+44
View File
@@ -471,6 +471,50 @@ pub fn seed_multi_peer(dir: &Path) -> Context {
ctx
}
/// Seed a repo whose default branch has no canonical head.
///
/// A second delegate is added and the threshold raised to 2, but only the
/// first delegate has `master`. The top-level `master` is removed, so `HEAD`
/// is unborn and the head must come from a quorum that cannot be reached.
pub fn seed_no_quorum(dir: &Path) -> Context {
use radicle::identity::Identity;
let ctx = seed(dir);
let signer1 = SigningKey::from_seed(Seed::new([0xff; 32]));
let signer2 = SigningKey::from_seed(Seed::new([0xee; 32]));
let rid = radicle::identity::RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let mut identity = Identity::load_mut(&repo, &signer1).unwrap();
let current_doc = repo.identity_doc().unwrap();
let new_doc = current_doc
.doc
.clone()
.with_edits(|raw| {
raw.delegates.push((*signer2.public_key()).into());
raw.threshold = 2;
})
.unwrap();
identity
.update(Title::new("Require two delegates").unwrap(), "", &new_doc)
.unwrap();
let new_head = repo.identity_head_of(signer1.public_key()).unwrap();
repo.set_identity_head_to(new_head).unwrap();
repo.sign_refs(&signer1).unwrap();
repo.raw()
.find_reference("refs/heads/master")
.unwrap()
.delete()
.unwrap();
ctx
}
/// A repository whose default branch tip is a merge commit.
///
/// Returned by [`seed_merge`].
+5 -4
View File
@@ -40,13 +40,14 @@ Contract:
### Repo facts outside the project payload
Expand: `cobs`, `releaseCount` and a qualified `defaultBranch` move out of the
project payload. The legacy `meta` (counts and `head`) stays inside it. Read by
the explorer client and the Open Graph worker.
project payload. The legacy `meta` (counts and `head`) stays inside it. Only
`repos/:rid` sends the release counts: counting reads every release, so repo
lists leave them out. Read by the explorer client and the Open Graph worker.
Contract:
- Client, when explorer drops httpd 0.30: `legacyMetaSchema` and
`legacyRepoFields` in `http-client/lib/compat.ts`
- Client, when explorer drops httpd 0.30: `legacyMetaSchema`,
`crefsPayloadSchema` and `legacyRepoFields` in `http-client/lib/compat.ts`
- Client, when explorer drops httpd 0.30: `normalizeRepo` in
`workers/open-graph/index.js`
- Server, a major release: legacy `meta` and `head` in
+160
View File
@@ -0,0 +1,160 @@
// 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;
}
+112 -27
View File
@@ -1,10 +1,19 @@
import type { Fetcher, RequestOptions } from "./fetcher.js";
import { ResponseError } from "./fetcher.js";
import {
crefsPayloadSchema,
legacyHasOtherReleases,
legacyMetaSchema,
legacyReleasesQuery,
legacyRepoFields,
legacyScopeReleases,
} from "./compat.js";
import type { Commit, Commits } from "./repo/commit.js";
import type { Issue } from "./repo/issue.js";
import type { Patch } from "./repo/patch.js";
import type { Release } from "./repo/release.js";
import type { Author } from "./shared.js";
import type { ZodSchema } from "zod";
import {
@@ -67,33 +76,47 @@ export type ReleaseCounts = z.infer<typeof releaseCountsSchema>;
// delegate, or by anyone else.
export type ReleaseScope = "trusted" | "untrusted";
const repoSchema = object({
// Repository-level COB counts. Each is absent when the node cannot determine
// it — an unreadable COB cache, or a build without release support — so a
// missing count never reads as zero.
const cobCountsSchema = object({
patches: optional(
object({
open: number(),
draft: number(),
archived: number(),
merged: number(),
}),
),
issues: optional(
object({
open: number(),
closed: number(),
}),
),
releases: optional(releaseCountsSchema),
});
export type CobCounts = z.infer<typeof cobCountsSchema>;
export const repoSchema = object({
rid: string(),
payloads: object({
// Optional: a repo may declare its default branch in `xyz.radicle.crefs`
// instead, and carry no project payload at all.
"xyz.radicle.project": object({
data: object({
name: string(),
description: string(),
// Unqualified, unlike the top-level `defaultBranch`.
defaultBranch: string(),
}),
meta: object({
head: string(),
patches: object({
open: number(),
draft: number(),
archived: number(),
merged: number(),
}),
issues: object({
open: number(),
closed: number(),
}),
// Absent on older nodes that don't report release counts, and a
// single count on nodes that report one instead of the buckets.
releases: optional(union([releaseCountsSchema, number()])),
}),
}),
meta: legacyMetaSchema.optional(),
}).optional(),
"xyz.radicle.crefs": crefsPayloadSchema,
}),
cobs: optional(cobCountsSchema),
defaultBranch: optional(string()),
delegates: array(authorSchema),
threshold: number(),
visibility: union([
@@ -106,6 +129,27 @@ const repoSchema = object({
refs: record(string(), string()),
}).optional(),
alias: string().optional(),
}).transform(repo => {
// Older httpd doesn't send `cobs`, so only a node without it needs the
// legacy fields. A newer node that leaves out `defaultBranch` has none.
if (repo.cobs === undefined) {
return {
...repo,
...legacyRepoFields(
repo.payloads["xyz.radicle.project"],
repo.payloads["xyz.radicle.crefs"],
repo.refs,
),
};
}
return {
...repo,
cobs: repo.cobs,
// The unredacted delegate releases, which the releases tab opens on.
// Absent when the node cannot serve releases.
releaseCount: repo.cobs.releases?.delegate,
};
});
const reposSchema = array(repoSchema);
@@ -387,9 +431,10 @@ export class Client {
);
return commits;
} catch (error) {
// Older nodes don't serve the dedicated commit-count endpoint, so fall
// Compat: httpd < 0.27.0 doesn't serve the commit-count endpoint, so fall
// back to the tree stats endpoint, which also returns the commit count.
// TODO: Drop this fallback once enough nodes serve /stats/commits.
// Outside the support window; remove once the e2e tests stop running
// against httpd 0.26.0.
if (error instanceof ResponseError && error.status === 404) {
const stats = await this.getTreeStatsBySha(rid, sha, options);
return stats.commits;
@@ -496,9 +541,10 @@ export class Client {
diffStatsSchema,
);
} catch (error) {
// Older nodes don't serve the dedicated diff-stats endpoint, so fall back
// to the full diff, which also carries the stats.
// TODO: Drop this fallback once enough nodes serve /diff/:base/:oid/stats.
// Compat: httpd < 0.27.0 doesn't serve the diff-stats endpoint, so fall
// back to the full diff, which also carries the stats. Outside the
// support window; remove once the e2e tests stop running against httpd
// 0.26.0.
if (error instanceof ResponseError && error.status === 404) {
const { diff } = await this.getDiff(
rid,
@@ -618,9 +664,6 @@ export class Client {
page?: number;
perPage?: number;
scope?: ReleaseScope;
// Older nodes ignore `scope` and only know this superset of both
// scopes.
allAuthors?: boolean;
showRedacted?: boolean;
},
options?: RequestOptions,
@@ -629,10 +672,52 @@ export class Client {
{
method: "GET",
path: `repos/${rid}/releases`,
query,
query: { ...query, ...legacyReleasesQuery(query?.scope) },
options,
},
releasesSchema,
);
}
// One page of releases in an author scope, and whether more may follow.
// Takes the delegates as a promise too, so that the page can load
// alongside the repo.
public async getReleasesPage(
rid: string,
delegates: Author[] | Promise<Author[]>,
query: { scope: ReleaseScope; page: number; perPage: number },
options?: RequestOptions,
): Promise<{ releases: Release[]; morePages: boolean }> {
const [response, authors] = await Promise.all([
this.getAllReleases(rid, query, options),
delegates,
]);
return {
releases: legacyScopeReleases(response, authors, query.scope),
// Page on the response, as older nodes fill it with every author.
morePages: response.length === query.perPage,
};
}
// Whether anyone other than a delegate has a release that a user can see.
public async hasOtherReleases(
repo: Repo,
perPage: number,
options?: RequestOptions,
): Promise<boolean> {
const counts = repo.cobs?.releases;
if (counts) {
return counts.other > 0;
}
return legacyHasOtherReleases(
() =>
this.getAllReleases(
repo.rid,
{ scope: "untrusted", page: 0, perPage },
options,
),
repo.delegates,
perPage,
);
}
}
+1
View File
@@ -9,6 +9,7 @@
"build": "vite build",
"check": "scripts/check",
"check:ci": "scripts/check-ci",
"check:open-graph": "cd workers/open-graph && npx wrangler deploy --dry-run",
"check:radicle-httpd": "cargo clippy --all --tests && cargo fmt --all --check",
"format": "npx prettier . --write",
"test:unit": "TZ='UTC' vitest run",
+1
View File
@@ -47,6 +47,7 @@ steps=(
"rust lint|RUSTFLAGS='-D warnings' cargo clippy --workspace --all-features --tests && RUSTFLAGS='-D warnings' cargo clippy -p radicle-httpd --no-default-features --tests && cargo fmt --all -- --check"
"playwright install|npx playwright install chromium"
"build|npm run build && npm run test:e2e -- --project chromium --config playwright.buildSmoke.config.ts"
"open-graph|npm ci --prefix workers/open-graph && npm run check:open-graph"
"e2e|npm run test:e2e -- --project chromium"
)
summary=()
+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);
}
+11
View File
@@ -106,6 +106,17 @@ test.describe(() => {
await expect(page.getByText("eve commit")).toBeVisible();
});
test("keep a way back from empty others", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "no-others-releases",
});
await registerArtifact(peer, repoFolder, { name: "binary" });
await page.goto(`${peer.uiUrl()}/${rid}/releases?scope=untrusted`);
await page.getByRole("link", { name: "Delegates 1" }).click();
await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/releases`);
});
test("empty release listing", async ({ page, peer }) => {
const { rid } = await createRepo(peer, { name: "no-releases" });
@@ -0,0 +1,61 @@
import { expect, test } from "@tests/support/fixtures.js";
import { createRepo, dropProjectPayload } from "@tests/support/repo.js";
test("list a repo without a name", async ({ page, peer }) => {
const name = "no-project-card";
const { rid, repoFolder } = await createRepo(peer, { name });
await dropProjectPayload(peer, repoFolder);
await page.goto(peer.uiUrl());
const card = page.locator(".repo-card", { hasText: rid.slice(-6) });
await expect(card.getByTitle("This repository has no name")).toBeVisible();
await expect(card.getByText(name)).toBeHidden();
});
test("open a repo at its canonical refs default branch", async ({
page,
peer,
}) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "no-project-source",
});
await dropProjectPayload(peer, repoFolder);
await page.goto(`${peer.uiUrl()}/${rid}`);
await expect(page.getByRole("button", { name: "Commits 1" })).toBeVisible();
await expect(page.getByTitle("This repository has no name")).toBeVisible();
await page.getByRole("link", { name: "Issues" }).click();
await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/issues`);
});
test("offer branches when the node reports no default branch", async ({
page,
peer,
}) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "no-project-unavailable",
});
await dropProjectPayload(peer, repoFolder);
// Newer nodes report the default branch, and the client resolves it on older
// ones from the canonical refs payload, so leave out both.
await page.route(
({ pathname }) => /^\/api(\/v1)?\/repos\/[^/]+$/.test(pathname),
async route => {
const response = await route.fetch();
const repo = await response.json();
delete repo.defaultBranch;
delete repo.payloads["xyz.radicle.crefs"];
await route.fulfill({ response, json: repo });
},
);
await page.goto(`${peer.uiUrl()}/${rid}`);
await expect(page.getByRole("button", { name: /^Commits/ })).toBeHidden();
await page.getByTitle("Change branch").click();
await page.getByText("main", { exact: true }).click();
await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/tree/main`);
await expect(page.getByRole("button", { name: "Commits 1" })).toBeVisible();
});
+56
View File
@@ -107,6 +107,62 @@ export async function createContributor(
// Fetch what other peers have contributed to the repo. Artifact fixtures are
// registered without announcing, so the delegate's node needs an explicit
// fetch before its httpd serves them.
// Replace a repo's identity document with one that has no project payload,
// so its default branch comes only from the canonical refs payload.
export async function dropProjectPayload(
peer: RadiclePeer,
repoFolder: string,
defaultBranch = "main",
) {
const { stdout: did } = await peer.rad(["self", "--did"]);
const docPath = `${repoFolder}.identity.json`;
await Fs.writeFile(
docPath,
JSON.stringify({
payload: {
"xyz.radicle.crefs": {
rules: {
[`refs/heads/${defaultBranch}`]: {
allow: "delegates",
threshold: 1,
},
},
symbolic: { HEAD: `refs/heads/${defaultBranch}` },
},
},
delegates: [did],
threshold: 1,
}),
);
// `rad id update --edit` only runs its editor in a terminal, so `script`
// provides one. The editor then copies the prepared document into place.
const update = [
"rad",
"id",
"update",
"--edit",
"--title",
"Drop project payload",
"--description",
"Use canonical refs only",
"--no-confirm",
// Printing the result panics on the zero-width terminal of `script`.
"--quiet",
];
const quoted = update.map(arg => `'${arg}'`).join(" ");
const script =
process.platform === "darwin"
? `script -q /dev/null ${quoted}`
: `script -qec "${quoted}" /dev/null`;
// `script` reads the terminal settings of its standard input, which a pipe
// doesn't have.
await peer.spawn("sh", ["-c", `${script} < /dev/null`], {
cwd: repoFolder,
env: { EDITOR: `cp ${docPath}` },
});
}
export async function syncFrom(peer: RadiclePeer, repoFolder: string) {
await peer.rad(["sync", "--fetch"], { cwd: repoFolder });
}
+316
View File
@@ -0,0 +1,316 @@
import type { Release } from "@http-client";
import { describe, expect, test } from "vitest";
import {
legacyHasOtherReleases,
legacyReleasesQuery,
legacyScopeReleases,
} from "@http-client/lib/compat";
import { repoSchema } from "@http-client/lib/repo";
const delegate = { id: "did:key:z6MkDelegate" };
const other = { id: "did:key:z6MkOther" };
const issues = { open: 1, closed: 2 };
const patches = { open: 3, draft: 0, archived: 0, merged: 4 };
const buckets = {
delegate: 2,
delegateRedacted: 1,
other: 3,
otherRedacted: 0,
};
const project = {
data: { name: "heartwood", description: "", defaultBranch: "master" },
};
function repo(fields: Record<string, unknown>) {
return repoSchema.parse({
rid: "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
payloads: {},
delegates: [delegate],
threshold: 1,
visibility: { type: "public" },
seeding: 1,
...fields,
});
}
function release(creator: { id: string }): Release {
return { creator } as Release;
}
describe("repo shape", () => {
test("reads the top-level fields of current httpd", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 5 },
},
},
cobs: { issues, patches, releases: buckets },
defaultBranch: "refs/heads/main",
});
expect(parsed.cobs).toEqual({ issues, patches, releases: buckets });
expect(parsed.defaultBranch).toBe("refs/heads/main");
expect(parsed.releaseCount).toBe(buckets.delegate);
});
test("adapts the project payload of httpd 0.28.0 and 0.29.0", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 7 },
},
},
});
expect(parsed.cobs).toEqual({ issues, patches, releases: undefined });
expect(parsed.defaultBranch).toBe("refs/heads/master");
expect(parsed.releaseCount).toBe(7);
});
test("has no release count on httpd 0.27.0", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
});
expect(parsed.cobs?.issues).toEqual(issues);
expect(parsed.releaseCount).toBeUndefined();
});
test("has no release count on a build without releases", () => {
const parsed = repo({ cobs: { issues, patches } });
expect(parsed.releaseCount).toBeUndefined();
});
test("takes no default branch from a project payload without meta", () => {
const parsed = repo({
payloads: { "xyz.radicle.project": project },
cobs: { patches },
});
expect(parsed.cobs).toEqual({ patches });
expect(parsed.defaultBranch).toBeUndefined();
});
test("keeps a newer node's missing default branch missing", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 5 },
},
},
cobs: { issues, patches, releases: buckets },
});
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.releaseCount).toBe(buckets.delegate);
});
test.each([
["a direct HEAD", { HEAD: "refs/heads/main" }, "refs/heads/main"],
[
"a chained HEAD",
{ HEAD: "MAIN", MAIN: "refs/heads/trunk" },
"refs/heads/trunk",
],
["a HEAD at a tag", { HEAD: "refs/tags/v1" }, undefined],
["a dangling HEAD", { HEAD: "MAIN" }, undefined],
["a cycle", { HEAD: "A", A: "B", B: "A" }, undefined],
])(
"resolves the crefs default branch of older httpd from %s",
(_, symbolic, expected) => {
const parsed = repo({
payloads: {
"xyz.radicle.crefs": { data: { rules: {}, symbolic } },
},
});
expect(parsed.defaultBranch).toBe(expected);
},
);
test("prefers the crefs HEAD of older httpd to the project payload", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": {
data: { symbolic: { HEAD: "refs/heads/main" } },
},
},
});
expect(parsed.defaultBranch).toBe("refs/heads/main");
});
test("falls back to the project payload without a crefs HEAD", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": { data: { rules: {} } },
},
});
expect(parsed.defaultBranch).toBe("refs/heads/master");
});
test("takes the default branch tip from meta without canonical refs", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
});
expect(parsed.refs).toEqual({
tags: {},
refs: { "refs/heads/master": "abc" },
});
});
test("adds the default branch tip from meta to canonical refs", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
refs: { tags: {}, refs: { "refs/heads/dev": "def" } },
});
expect(parsed.refs?.refs).toEqual({
"refs/heads/dev": "def",
"refs/heads/master": "abc",
});
});
test("prefers the canonical default branch tip to meta", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
refs: { tags: {}, refs: { "refs/heads/master": "def" } },
});
expect(parsed.refs?.refs["refs/heads/master"]).toBe("def");
});
test("takes no tip from meta for a crefs HEAD on another branch", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": {
data: { symbolic: { HEAD: "refs/heads/main" } },
},
},
});
expect(parsed.refs).toBeUndefined();
});
test("takes no tip from meta on current httpd", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
cobs: { issues, patches },
defaultBranch: "refs/heads/master",
refs: { tags: {}, refs: {} },
});
expect(parsed.refs?.refs).toEqual({});
});
test("parses a repo with a malformed crefs payload", () => {
const parsed = repo({
payloads: { "xyz.radicle.crefs": { data: { symbolic: 1 } } },
});
expect(parsed.defaultBranch).toBeUndefined();
});
test("parses a repo without a project payload", () => {
const parsed = repo({});
expect(parsed.cobs).toBeUndefined();
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.releaseCount).toBeUndefined();
});
});
describe("legacyReleasesQuery", () => {
test("asks older nodes for every author in the untrusted scope", () => {
expect(legacyReleasesQuery("untrusted")).toEqual({ allAuthors: true });
});
test.each(["trusted" as const, undefined])("adds nothing for %s", scope => {
expect(legacyReleasesQuery(scope)).toEqual({});
});
});
describe("legacyScopeReleases", () => {
const releases = [release(delegate), release(other)];
test("keeps every release in the trusted scope", () => {
expect(legacyScopeReleases(releases, [delegate], "trusted")).toEqual(
releases,
);
});
test("drops delegate releases from the untrusted scope", () => {
expect(legacyScopeReleases(releases, [delegate], "untrusted")).toEqual([
release(other),
]);
});
});
describe("legacyHasOtherReleases", () => {
test("finds releases by others", async () => {
const page = async () => [release(delegate), release(other)];
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(true);
});
test("finds none when only delegates released", async () => {
const page = async () => [release(delegate)];
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(false);
});
test("assumes others after a full page", async () => {
const page = async () => [release(delegate)];
expect(await legacyHasOtherReleases(page, [delegate], 1)).toBe(true);
});
test("assumes others when the fetch fails", async () => {
const page = () => Promise.reject(new Error("offline"));
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(true);
});
});
+6
View File
@@ -58,6 +58,12 @@ describe("artifactView", () => {
expect(view.showFilters).toBe(true);
});
test("keeps the filter on others without artifacts by others", () => {
const view = artifactView([ours], delegates, "untrusted", false);
expect(view.shown).toEqual([]);
expect(view.showFilters).toBe(true);
});
test("hides the filter without artifacts by others", () => {
const view = artifactView([ours], delegates, "trusted", false);
expect(view.showFilters).toBe(false);
+43
View File
@@ -1,3 +1,5 @@
import type { Repo } from "@http-client";
import { describe, expect, test } from "vitest";
import * as utils from "@app/lib/utils";
@@ -353,6 +355,47 @@ describe("repoSegment", () => {
});
});
describe("repoName", () => {
const rid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
test("reads the name from the project payload", () => {
const repo = {
rid,
payloads: { "xyz.radicle.project": { data: { name: "heartwood" } } },
} as Repo;
expect(utils.repoName(repo)).toEqual("heartwood");
});
test("falls back to the RID without a project payload", () => {
const repo = { rid, payloads: {} } as Repo;
expect(utils.repoName(repo)).toEqual(utils.formatRepositoryId(rid));
});
});
describe("hasMorePages", () => {
test.each([
{ loaded: 9, total: 10, expected: true },
{ loaded: 10, total: 10, expected: false },
{ loaded: 0, total: 0, expected: false },
])(
"compares $loaded loaded with a total of $total",
({ loaded, total, expected }) => {
expect(utils.hasMorePages(loaded, 1, 10, total)).toBe(expected);
},
);
test.each([
{ loaded: 20, pages: 2, expected: true },
{ loaded: 19, pages: 2, expected: false },
{ loaded: 0, pages: 1, expected: false },
])(
"without a total, $loaded loaded in $pages pages => $expected",
({ loaded, pages, expected }) => {
expect(utils.hasMorePages(loaded, pages, 10, undefined)).toBe(expected);
},
);
});
describe("safeDecodeURIComponent", () => {
test("decodes an encoded branch name", () => {
expect(utils.safeDecodeURIComponent("releaseprep%2Fv27.1")).toEqual(
+53 -30
View File
@@ -4,6 +4,15 @@ import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
import bootonRegular from "./Booton-Regular.ttf";
import bootonSemiBold from "./Booton-SemiBold.ttf";
import forestJpg from "./forest.jpg";
import {
crefsPayloadSchema,
legacyRepoFields,
} from "../../http-client/lib/compat.ts";
import {
defaultBranchName,
defaultBranchTip,
repoName,
} from "../../src/lib/utils.ts";
const WIDTH = 1200;
const HEIGHT = 630;
@@ -102,7 +111,8 @@ async function apiFetch(url) {
return res.json();
}
const fetchRepo = (host, rid) => apiFetch(`${apiBase(host)}/repos/${rid}`);
const fetchRepo = (host, rid) =>
apiFetch(`${apiBase(host)}/repos/${rid}`).then(normalizeRepo);
const fetchIssue = (host, rid, id) =>
apiFetch(`${apiBase(host)}/repos/${rid}/issues/${id}`);
const fetchPatch = (host, rid, id) =>
@@ -231,6 +241,20 @@ function repoPayload(repo) {
return repo?.payloads?.["xyz.radicle.project"];
}
// Give repos from every supported httpd the same shape, like `repoSchema` in
// http-client/lib/repo.ts. Older httpd sends no `cobs`.
function normalizeRepo(repo) {
if (!repo || repo.cobs !== undefined) return repo;
return {
...repo,
...legacyRepoFields(
repoPayload(repo),
crefsPayloadSchema.parse(repo.payloads?.["xyz.radicle.crefs"]),
repo.refs,
),
};
}
function authorLabel(author) {
if (!author) return null;
return author.alias || author.id.slice(0, 7);
@@ -1167,11 +1191,11 @@ function userCard(host, did, identity) {
function repoCard(repo, host) {
if (!repo) return nodeCard(host, null, null);
const p = repoPayload(repo);
const issueCount = p?.meta?.issues?.open ?? 0;
const patchCount = p?.meta?.patches?.open ?? 0;
const issueCount = repo.cobs?.issues?.open ?? 0;
const patchCount = repo.cobs?.patches?.open ?? 0;
const delegates = repo.delegates ?? [];
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
return el(
"div",
@@ -1215,7 +1239,7 @@ function repoCard(repo, host) {
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
},
repoName || repo.rid,
repoName(repo),
),
el(
"div",
@@ -1309,10 +1333,10 @@ function repoCard(repo, host) {
function historyCard(repo, host, stats, branch, peer, peerAlias) {
if (!repo) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const commitCount = stats?.commits;
const branchLabel = branch || p?.data?.defaultBranch;
const branchLabel = branch || defaultBranchName(repo);
return el(
"div",
@@ -1356,7 +1380,7 @@ function historyCard(repo, host, stats, branch, peer, peerAlias) {
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
},
repoName || repo.rid,
repoName(repo),
),
el(
"div",
@@ -1444,9 +1468,9 @@ function historyCard(repo, host, stats, branch, peer, peerAlias) {
function issuesCard(repo, host) {
if (!repo) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const issues = p?.meta?.issues ?? {};
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const issues = repo.cobs?.issues ?? {};
return el(
"div",
@@ -1490,7 +1514,7 @@ function issuesCard(repo, host) {
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
},
repoName || repo.rid,
repoName(repo),
),
el(
"div",
@@ -1553,9 +1577,9 @@ function issuesCard(repo, host) {
function patchesCard(repo, host) {
if (!repo) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const patches = p?.meta?.patches ?? {};
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const patches = repo.cobs?.patches ?? {};
return el(
"div",
@@ -1599,7 +1623,7 @@ function patchesCard(repo, host) {
WebkitLineClamp: 1,
WebkitBoxOrient: "vertical",
},
repoName || repo.rid,
repoName(repo),
),
el(
"div",
@@ -1721,8 +1745,8 @@ function statePill(label, status, iconName) {
function issueCard(repo, host, issue) {
if (!issue) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const status = issue.state?.status ?? "open";
const statusLabel = status === "closed" ? "Closed Issue" : "Open Issue";
@@ -1784,7 +1808,7 @@ function issueCard(repo, host, issue) {
fontSize: 28,
lineHeight: "40px",
},
repoName || repo.rid,
repoName(repo),
),
),
el(
@@ -1839,8 +1863,8 @@ function issueCard(repo, host, issue) {
function patchCard(repo, host, patch) {
if (!patch) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const status = patch.state?.status ?? "open";
const statusLabels = {
open: "Open Patch",
@@ -1912,7 +1936,7 @@ function patchCard(repo, host, patch) {
fontSize: 28,
lineHeight: "40px",
},
repoName || repo.rid,
repoName(repo),
),
),
el(
@@ -1967,8 +1991,8 @@ function patchCard(repo, host, patch) {
function commitCard(repo, host, commit, authorAvatar, committerAvatar) {
if (!commit) return repoCard(repo, host);
const p = repoPayload(repo);
const repoName = p?.data?.name ?? "";
const avatarKey = `${repoName}${repo.rid.replace("rad:z", "")}${repoName}`;
const name = p?.data?.name ?? "";
const avatarKey = `${name}${repo.rid.replace("rad:z", "")}${name}`;
const c = commit.commit ?? {};
const author = c.author;
const committer = c.committer;
@@ -2032,7 +2056,7 @@ function commitCard(repo, host, commit, authorAvatar, committerAvatar) {
fontSize: 28,
lineHeight: "40px",
},
repoName || repo.rid,
repoName(repo),
),
),
el(
@@ -2334,8 +2358,7 @@ export default {
fetchRepo(route.host, route.rid),
route.peer ? fetchRemote(route.host, route.rid, route.peer) : null,
]);
const p = repoPayload(repo);
let sha = p?.meta?.head;
let sha = defaultBranchTip(repo);
let peerAlias = null;
let branch = route.branch;
@@ -2346,7 +2369,7 @@ export default {
);
peerAlias = delegate?.alias ?? null;
if (remote.heads) {
const targetBranch = branch || p?.data?.defaultBranch;
const targetBranch = branch || defaultBranchName(repo);
sha =
remote.heads[targetBranch] ??
Object.values(remote.heads)[0] ??