From 0dc1e557a592eebc3ea1d4a411cdb8538f63aeaa Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Thu, 3 Sep 2026 13:38:06 +0200 Subject: [PATCH] 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 --- .buildkite/explorer-node-checks.yml | 14 +- .github/workflows/explorer-build.yml | 6 + explorer/AGENTS.md | 7 +- explorer/crates/radicle-httpd/src/api.rs | 174 ++++++++-- .../radicle-httpd/src/api/v1/delegates.rs | 52 +++ .../crates/radicle-httpd/src/api/v1/repos.rs | 105 ++++++ explorer/crates/radicle-httpd/src/test.rs | 44 +++ explorer/docs/api-changes.md | 9 +- explorer/http-client/lib/compat.ts | 160 +++++++++ explorer/http-client/lib/repo.ts | 139 ++++++-- explorer/package.json | 1 + explorer/scripts/check-ci | 1 + explorer/src/App.svelte | 3 + explorer/src/components/RepoCard.svelte | 56 +++- explorer/src/components/RepoCard.ts | 22 +- explorer/src/lib/releases.ts | 5 +- explorer/src/lib/router.ts | 3 +- explorer/src/lib/utils.ts | 43 ++- explorer/src/marketing/Landing.svelte | 63 ++-- explorer/src/views/explore/Section.svelte | 3 +- explorer/src/views/repos/Cob/Revision.svelte | 14 +- explorer/src/views/repos/Header.svelte | 37 +- explorer/src/views/repos/History.svelte | 6 +- explorer/src/views/repos/Issue.svelte | 8 +- explorer/src/views/repos/Issues.svelte | 27 +- explorer/src/views/repos/Layout.svelte | 16 +- explorer/src/views/repos/Patch.svelte | 67 ++-- explorer/src/views/repos/Patches.svelte | 44 ++- explorer/src/views/repos/Releases.svelte | 14 +- explorer/src/views/repos/Source.svelte | 6 +- explorer/src/views/repos/Source/Header.svelte | 15 +- .../repos/Source/PeerBranchSelector.svelte | 78 +++-- .../views/repos/Source/RepoNameHeader.svelte | 41 ++- .../src/views/repos/SourceUnavailable.svelte | 74 ++++ explorer/src/views/repos/router.ts | 233 ++++++------- explorer/tests/e2e/repo/releases.spec.ts | 11 + .../tests/e2e/repo/withoutProject.spec.ts | 61 ++++ explorer/tests/support/repo.ts | 56 ++++ explorer/tests/unit/compat.test.ts | 316 ++++++++++++++++++ explorer/tests/unit/releases.test.ts | 6 + explorer/tests/unit/utils.test.ts | 43 +++ explorer/workers/open-graph/index.js | 83 +++-- 42 files changed, 1716 insertions(+), 450 deletions(-) create mode 100644 explorer/http-client/lib/compat.ts create mode 100644 explorer/src/views/repos/SourceUnavailable.svelte create mode 100644 explorer/tests/e2e/repo/withoutProject.spec.ts create mode 100644 explorer/tests/unit/compat.test.ts diff --git a/.buildkite/explorer-node-checks.yml b/.buildkite/explorer-node-checks.yml index 3e7d064b3..dcd048f1c 100644 --- a/.buildkite/explorer-node-checks.yml +++ b/.buildkite/explorer-node-checks.yml @@ -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 diff --git a/.github/workflows/explorer-build.yml b/.github/workflows/explorer-build.yml index 54eaf735d..b18655b92 100644 --- a/.github/workflows/explorer-build.yml +++ b/.github/workflows/explorer-build.yml @@ -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() diff --git a/explorer/AGENTS.md b/explorer/AGENTS.md index 0ca6cd842..7f6a2e3be 100644 --- a/explorer/AGENTS.md +++ b/explorer/AGENTS.md @@ -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 diff --git a/explorer/crates/radicle-httpd/src/api.rs b/explorer/crates/radicle-httpd/src/api.rs index 378d89e7a..5e3ded448 100644 --- a/explorer/crates/radicle-httpd/src/api.rs +++ b/explorer/crates/radicle-httpd/src/api.rs @@ -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::(); + meta["releases"] = json!(count); + } + Some(meta) + } + _ => None, + }; + let payloads: BTreeMap = 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, 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, + /// 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, pub delegates: Vec, 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>, + failing_patterns: HashSet, peel_errors: HashMap, annotated_tags: HashMap, } @@ -803,6 +884,9 @@ mod tests { &self, pattern: &QualifiedPattern, ) -> Result, 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(), }; diff --git a/explorer/crates/radicle-httpd/src/api/v1/delegates.rs b/explorer/crates/radicle-httpd/src/api/v1/delegates.rs index 83a004c54..ac4a093ab 100644 --- a/explorer/crates/radicle-httpd/src/api/v1/delegates.rs +++ b/explorer/crates/radicle-httpd/src/api/v1/delegates.rs @@ -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, diff --git a/explorer/crates/radicle-httpd/src/api/v1/repos.rs b/explorer/crates/radicle-httpd/src/api/v1/repos.rs index 65611bb55..a25411c65 100644 --- a/explorer/crates/radicle-httpd/src/api/v1/repos.rs +++ b/explorer/crates/radicle-httpd/src/api/v1/repos.rs @@ -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(); diff --git a/explorer/crates/radicle-httpd/src/test.rs b/explorer/crates/radicle-httpd/src/test.rs index 28fc790e5..30ba050e0 100644 --- a/explorer/crates/radicle-httpd/src/test.rs +++ b/explorer/crates/radicle-httpd/src/test.rs @@ -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`]. diff --git a/explorer/docs/api-changes.md b/explorer/docs/api-changes.md index b4d459830..77ed45d48 100644 --- a/explorer/docs/api-changes.md +++ b/explorer/docs/api-changes.md @@ -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 diff --git a/explorer/http-client/lib/compat.ts b/explorer/http-client/lib/compat.ts new file mode 100644 index 000000000..51054fb63 --- /dev/null +++ b/explorer/http-client/lib/compat.ts @@ -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; + } + | undefined; + +type LegacyRefs = + { tags: Record; refs: Record } | 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( + project: LegacyProject, + crefs: z.infer, + refs: LegacyRefs, +) { + 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( + project: LegacyProject, + defaultBranch: string | undefined, + refs: LegacyRefs, +) { + 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, +): string | undefined { + const symbolic = crefs?.data.symbolic; + if (symbolic?.HEAD !== undefined) { + const seen = new Set(); + 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 { + 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, + delegates: Author[], + perPage: number, +): Promise { + const everyAuthor = await getPage().catch(() => undefined); + if (everyAuthor === undefined || everyAuthor.length === perPage) { + return true; + } + return legacyScopeReleases(everyAuthor, delegates, "untrusted").length > 0; +} diff --git a/explorer/http-client/lib/repo.ts b/explorer/http-client/lib/repo.ts index 072a136f0..4d1f40000 100644 --- a/explorer/http-client/lib/repo.ts +++ b/explorer/http-client/lib/repo.ts @@ -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; // 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; + +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, + 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 { + 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, + ); + } } diff --git a/explorer/package.json b/explorer/package.json index 55d76758a..e1a0afe92 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -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", diff --git a/explorer/scripts/check-ci b/explorer/scripts/check-ci index 88452f941..573652e67 100755 --- a/explorer/scripts/check-ci +++ b/explorer/scripts/check-ci @@ -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=() diff --git a/explorer/src/App.svelte b/explorer/src/App.svelte index 177494622..c5b2965ec 100644 --- a/explorer/src/App.svelte +++ b/explorer/src/App.svelte @@ -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 @@ {:else if $activeRouteStore.resource === "repo.source"} +{:else if $activeRouteStore.resource === "repo.sourceUnavailable"} + {:else if $activeRouteStore.resource === "repo.history"} {:else if $activeRouteStore.resource === "repo.commit"} diff --git a/explorer/src/components/RepoCard.svelte b/explorer/src/components/RepoCard.svelte index e6c9b2350..80bb3f21b 100644 --- a/explorer/src/components/RepoCard.svelte +++ b/explorer/src/components/RepoCard.svelte @@ -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); + + + + + +
+ +
+ +
+ +
+
diff --git a/explorer/src/views/repos/router.ts b/explorer/src/views/repos/router.ts index 16c296a30..0346a45fc 100644 --- a/explorer/src/views/repos/router.ts +++ b/explorer/src/views/repos/router.ts @@ -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 { - 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 { - 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 { @@ -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 { const branchMap: Record = {}; - 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); } diff --git a/explorer/tests/e2e/repo/releases.spec.ts b/explorer/tests/e2e/repo/releases.spec.ts index 60678045b..e35541513 100644 --- a/explorer/tests/e2e/repo/releases.spec.ts +++ b/explorer/tests/e2e/repo/releases.spec.ts @@ -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" }); diff --git a/explorer/tests/e2e/repo/withoutProject.spec.ts b/explorer/tests/e2e/repo/withoutProject.spec.ts new file mode 100644 index 000000000..2331fca68 --- /dev/null +++ b/explorer/tests/e2e/repo/withoutProject.spec.ts @@ -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(); +}); diff --git a/explorer/tests/support/repo.ts b/explorer/tests/support/repo.ts index cd80cfa0a..46fffc050 100644 --- a/explorer/tests/support/repo.ts +++ b/explorer/tests/support/repo.ts @@ -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 }); } diff --git a/explorer/tests/unit/compat.test.ts b/explorer/tests/unit/compat.test.ts new file mode 100644 index 000000000..2542af4d5 --- /dev/null +++ b/explorer/tests/unit/compat.test.ts @@ -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) { + 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); + }); +}); diff --git a/explorer/tests/unit/releases.test.ts b/explorer/tests/unit/releases.test.ts index cdf762def..2486d6177 100644 --- a/explorer/tests/unit/releases.test.ts +++ b/explorer/tests/unit/releases.test.ts @@ -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); diff --git a/explorer/tests/unit/utils.test.ts b/explorer/tests/unit/utils.test.ts index 3d02eb0b5..b804c83db 100644 --- a/explorer/tests/unit/utils.test.ts +++ b/explorer/tests/unit/utils.test.ts @@ -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( diff --git a/explorer/workers/open-graph/index.js b/explorer/workers/open-graph/index.js index b27a3712b..6e971682b 100644 --- a/explorer/workers/open-graph/index.js +++ b/explorer/workers/open-graph/index.js @@ -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] ??