mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-13 18:40:50 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] ??
|
||||
|
||||
Reference in New Issue
Block a user