Integrate radicle-artifact into explorer

- Add release read API to httpd as cargo feature enabled by default
- Add Releases and Release views to the UI
- Filter out redacted artifacts and mark delegate authors
- Degrade releases UI gracefully with older radicle-httpd versions
- Hide fully redacted releases
- Skip releases in the list endpoint when every artifact was redacted by
  its author or a delegate, so no empty release entries are shown
- Keep them reachable with showRedacted=true and by release id
- Add e2e tests for the release routes

Co-Authored-By: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Daniel Norman
2026-08-28 16:33:14 +02:00
co-authored by Rūdolfs Ošiņš
parent 8f2ddcf489
commit b1acb3eb4d
37 changed files with 3121 additions and 68 deletions
+4
View File
@@ -76,4 +76,8 @@ steps:
# components. Only this step needs them.
- rustup component add rustfmt clippy
- RUSTFLAGS="-D warnings" cargo clippy --workspace --all-features --tests
# Run clippy with default features disabled (artifacts)
# TODO: If more features are added explore adding `cargo hack --feature-powerset`
# to cover every feature combination
- RUSTFLAGS="-D warnings" cargo clippy -p radicle-httpd --no-default-features --tests
- cargo fmt --all -- --check
+5
View File
@@ -54,5 +54,10 @@ jobs:
run: cargo clippy --workspace --all-features --tests
env:
RUSTFLAGS: -D warnings
# Nothing else compiles the artifacts-off path, so it would rot.
- name: Run clippy on httpd without default features
run: cargo clippy -p radicle-httpd --no-default-features --tests
env:
RUSTFLAGS: -D warnings
- name: Check formatting
run: cargo fmt --all -- --check
Generated
+469 -44
View File
@@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.6",
"generic-array",
]
@@ -184,6 +184,18 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ascii"
version = "1.1.0"
@@ -192,9 +204,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-compression"
version = "0.4.42"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
dependencies = [
"compression-codecs",
"compression-core",
@@ -213,6 +225,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -299,6 +320,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base256emoji"
version = "1.0.2"
@@ -341,7 +368,7 @@ checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
dependencies = [
"blowfish",
"pbkdf2",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -350,6 +377,20 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake3"
version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.3.0",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -359,6 +400,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -503,13 +553,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cid"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971"
dependencies = [
"multibase",
"multihash",
"serde",
"serde_bytes",
"unsigned-varint",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.6",
"inout",
]
@@ -554,6 +617,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.19",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -604,6 +676,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.8.0"
@@ -655,6 +733,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -704,6 +788,16 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
"rand_core 0.10.1",
]
[[package]]
name = "ctr"
version = "0.9.2"
@@ -722,13 +816,31 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rand_core 0.10.1",
"rustc_version",
"serde",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
@@ -844,6 +956,7 @@ dependencies = [
"quote",
"rustc_version",
"syn 2.0.119",
"unicode-xid",
]
[[package]]
@@ -858,21 +971,31 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"block-buffer 0.10.4",
"const-oid",
"crypto-common",
"crypto-common 0.1.6",
"subtle",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -913,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature 2.2.0",
@@ -940,16 +1063,42 @@ dependencies = [
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"serdect",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519 3.0.0",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -969,9 +1118,9 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"base16ct 0.2.0",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array",
"group",
@@ -984,6 +1133,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -1045,6 +1206,12 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1170,6 +1337,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]]
name = "generic-array"
version = "0.14.9"
@@ -1274,6 +1450,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1291,6 +1476,20 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1312,14 +1511,14 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
name = "http"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
@@ -1360,6 +1559,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.11.0"
@@ -1601,6 +1809,7 @@ dependencies = [
"bitflags",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"tempfile",
"unicode-segmentation",
"unicode-width",
@@ -1612,6 +1821,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iroh-base"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
dependencies = [
"curve25519-dalek 5.0.0",
"data-encoding",
"data-encoding-macro",
"derive_more",
"ed25519-dalek 3.0.0",
"getrandom 0.4.3",
"n0-error",
"rand 0.10.2",
"serde",
"url",
"zeroize",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1661,7 +1889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"ed25519-dalek 2.2.0",
"getrandom 0.2.17",
"hmac",
"js-sys",
@@ -1671,7 +1899,7 @@ dependencies = [
"rsa",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"signature 2.2.0",
"zeroize",
]
@@ -1910,6 +2138,37 @@ dependencies = [
"data-encoding-macro",
]
[[package]]
name = "multihash"
version = "0.19.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447"
dependencies = [
"serde",
"unsigned-varint",
]
[[package]]
name = "n0-error"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895"
dependencies = [
"n0-error-macros",
"spez",
]
[[package]]
name = "n0-error-macros"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "nom"
version = "8.0.0"
@@ -2030,7 +2289,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -2042,7 +2301,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -2051,12 +2310,12 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
"base16ct",
"base16ct 0.2.0",
"ecdsa",
"elliptic-curve",
"primeorder",
"rand_core 0.6.4",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -2088,7 +2347,7 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -2168,6 +2427,19 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2343,6 +2615,64 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "radicle-artifact"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8166746c3d6bf1618d97aebb48d8b3239c31527fc96fc235309f7c47543ba23"
dependencies = [
"blake3",
"cid",
"clap",
"indexmap",
"indicatif",
"inquire",
"log",
"radicle",
"radicle-artifact-client",
"radicle-artifact-core",
"serde",
"serde_json",
"sqlite",
"thiserror 2.0.19",
"url",
]
[[package]]
name = "radicle-artifact-client"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59abfdeba1a2476c45264014ccc0e9d86cee55eb327e755506e5bc8c0d2b0153"
dependencies = [
"cid",
"radicle",
"radicle-artifact-core",
"serde",
"serde_json",
"thiserror 2.0.19",
]
[[package]]
name = "radicle-artifact-core"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba6ef833ec05bb0731b61854c8f39467896d593845c69c0f9437e6f6f2d7bf36"
dependencies = [
"blake3",
"cid",
"dunce",
"iroh-base",
"multibase",
"multihash",
"postcard",
"radicle",
"serde",
"serde_json",
"thiserror 2.0.19",
"url",
"walkdir",
]
[[package]]
name = "radicle-cob"
version = "0.21.1"
@@ -2386,9 +2716,9 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76de6172f3ccae0f489578f4a5da45bc77ff8855ace6352b002d94c0e0783bb5"
dependencies = [
"curve25519-dalek",
"curve25519-dalek 4.1.3",
"cyphernet",
"ed25519-dalek",
"ed25519-dalek 2.2.0",
"multibase",
"qcheck",
"radicle-git-ref-format",
@@ -2449,6 +2779,7 @@ dependencies = [
"nonempty 0.12.0",
"pretty_assertions",
"radicle",
"radicle-artifact",
"radicle-crypto",
"radicle-job",
"radicle-search",
@@ -2751,14 +3082,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
"subtle",
@@ -2795,9 +3126,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@@ -2840,6 +3171,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -2877,7 +3217,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"base16ct 0.2.0",
"der",
"generic-array",
"pkcs8",
@@ -2934,6 +3274,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -3002,6 +3352,16 @@ dependencies = [
"serde",
]
[[package]]
name = "serdect"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct 1.0.0",
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3010,7 +3370,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -3089,10 +3460,19 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -3137,11 +3517,25 @@ dependencies = [
"cypheraddr",
]
[[package]]
name = "spez"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
@@ -3222,7 +3616,7 @@ checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -3232,7 +3626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"ed25519-dalek 2.2.0",
"num-bigint-dig",
"p256",
"p384",
@@ -3240,7 +3634,7 @@ dependencies = [
"rand_core 0.6.4",
"rsa",
"sec1",
"sha2",
"sha2 0.10.9",
"signature 2.2.0",
"ssh-cipher",
"ssh-encoding",
@@ -3493,13 +3887,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -3709,6 +4103,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
@@ -3721,10 +4121,16 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.6",
"subtle",
]
[[package]]
name = "unsigned-varint"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3786,6 +4192,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -3923,6 +4339,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
+5 -1
View File
@@ -10,7 +10,10 @@ default-run = "radicle-httpd"
build = "build.rs"
[features]
default = []
default = ["artifacts"]
artifacts = [
"dep:radicle-artifact",
]
logfmt = [
"tracing-logfmt",
]
@@ -33,6 +36,7 @@ lru = { version = "0.16.0" }
mime_guess = { version = "2.0.5" }
nonempty = { version = "0.12.0", features = ["serialize"] }
radicle.workspace = true
radicle-artifact = { version = "0.18", optional = true }
radicle-job = { version = "0.6.0" }
radicle-search = { path = "../radicle-search" }
radicle-surf = { workspace = true, features = ["serde"] }
+25 -6
View File
@@ -14,6 +14,8 @@ use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{git, web, Profile};
#[cfg(feature = "artifacts")]
use radicle_artifact::Releases;
use tokio::sync::RwLock;
mod error;
@@ -29,7 +31,7 @@ use crate::Options;
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.1.0";
pub const API_VERSION: &str = "6.2.0";
/// Thread-safe wrapper around radicle's web configuration.
///
@@ -178,16 +180,18 @@ impl Context {
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
});
add_releases_meta(&mut meta, repo);
Some((
id.clone(),
json!({
"data": payload,
"meta": {
"head": head,
"issues": issues,
"patches": patches
}
"meta": meta
}),
))
} else {
@@ -238,6 +242,21 @@ impl Context {
}
}
/// Add the release count to a project payload's `meta` object.
#[cfg(feature = "artifacts")]
fn add_releases_meta(meta: &mut Value, repo: &Repository) {
let releases = Releases::open(repo)
.ok()
.and_then(|releases| releases.count().ok())
.unwrap_or_default();
meta["releases"] = json!(releases);
}
/// Without artifact support there is no release store, so the key is omitted.
#[cfg(not(feature = "artifacts"))]
fn add_releases_meta(_meta: &mut Value, _repo: &Repository) {}
/// Run a blocking closure on the blocking thread pool.
///
/// Repository reads go through libgit2 and the COB cache, which are
+12
View File
@@ -53,6 +53,18 @@ pub struct CobsQuery<T> {
pub status: Option<T>,
}
#[cfg(feature = "artifacts")]
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ReleasesQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
/// Include releases and artifacts authored by non-delegates.
pub all_authors: Option<bool>,
/// Include artifacts redacted by their author or a delegate.
pub show_redacted: Option<bool>,
}
#[derive(Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub enum IssueStatus {
@@ -131,6 +131,7 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -168,6 +169,7 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -227,6 +229,7 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -264,6 +267,7 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
+16 -2
View File
@@ -1,4 +1,6 @@
mod job;
#[cfg(feature = "artifacts")]
mod releases;
use std::collections::{BTreeMap, HashMap};
@@ -29,7 +31,7 @@ use crate::axum_extra::{cached_response, immutable_response, Path, Query};
const MAX_BODY_LIMIT: usize = 4_194_304;
pub fn router(ctx: Context) -> Router {
Router::new()
let router = Router::new()
.route("/repos", get(repo_root_handler))
.route("/repos/search", get(repo_search_handler))
.route("/repos/{rid}", get(repo_handler))
@@ -56,7 +58,14 @@ pub fn router(ctx: Context) -> Router {
.route("/repos/{rid}/issues", get(issues_handler))
.route("/repos/{rid}/issues/{id}", get(issue_handler))
.route("/repos/{rid}/patches", get(patches_handler))
.route("/repos/{rid}/patches/{id}", get(patch_handler))
.route("/repos/{rid}/patches/{id}", get(patch_handler));
#[cfg(feature = "artifacts")]
let router = router
.route("/repos/{rid}/releases", get(releases::list_handler))
.route("/repos/{rid}/releases/{id}", get(releases::get_handler));
router
.with_state(ctx)
.layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
}
@@ -1391,6 +1400,7 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1428,6 +1438,7 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1478,6 +1489,7 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1515,6 +1527,7 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1695,6 +1708,7 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -0,0 +1,518 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::Json;
use serde_json::{json, Value};
use radicle::git::Oid;
use radicle::identity::doc::Delegates;
use radicle::node::AliasStore;
use radicle::storage::git::Repository;
use radicle_artifact::display::{CommitTitle, TagName};
use radicle_artifact::{cache_db_path, Artifact, Cid, Release, ReleaseId, Releases};
use crate::api;
use crate::api::error::Error;
use crate::api::json::Author;
use crate::api::query::{ReleasesQuery, MAX_PER_PAGE};
use crate::api::Context;
use crate::axum_extra::{Path, Query};
/// Default number of releases returned per page.
const DEFAULT_PER_PAGE: usize = 30;
/// Whether an artifact was redacted by its own author or by a delegate.
fn redacted_by_trusted(artifact: &Artifact, delegates: &Delegates) -> bool {
artifact
.redactions()
.keys()
.any(|did| did == artifact.author() || delegates.contains(did))
}
/// How far the caller widened the default, delegate-scoped release view.
#[derive(Clone, Copy)]
struct Filter {
/// Include releases and artifacts authored by non-delegates.
all_authors: bool,
/// Include artifacts redacted by their author or a delegate.
show_redacted: bool,
}
impl Filter {
/// Whether an artifact is shown under this view: authored by a delegate
/// (or `all_authors`), and not redacted by its author or a delegate (or
/// `show_redacted`).
fn show_artifact(&self, artifact: &Artifact, delegates: &Delegates) -> bool {
(self.all_authors || delegates.contains(artifact.author()))
&& (self.show_redacted || !redacted_by_trusted(artifact, delegates))
}
/// Whether a release is shown under this view: created by a delegate (or
/// `all_authors`), and not left with all of its artifacts redacted by a
/// trusted party (or `show_redacted`).
fn show_release(&self, release: &Release, delegates: &Delegates) -> bool {
(self.all_authors || delegates.contains(release.creator()))
&& (self.show_redacted || !release_redacted(release, delegates))
}
}
/// Whether every artifact of a release was redacted by a trusted party. A
/// release without artifacts is not redacted; it has nothing to redact.
fn release_redacted(release: &Release, delegates: &Delegates) -> bool {
let artifacts = release.artifacts();
!artifacts.is_empty()
&& artifacts
.values()
.all(|artifact| redacted_by_trusted(artifact, delegates))
}
/// Serialize a single artifact. Locations are flattened across contributors
/// into `{ user, url }` entries; attestations are the attesting nodes;
/// redactions carry the flagging user and its reason.
fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> Value {
let locations = artifact
.locations()
.iter()
.flat_map(|(did, urls)| {
urls.iter()
.map(move |url| json!({ "user": Author::new(did).as_json(aliases), "url": url }))
})
.collect::<Vec<_>>();
let attestations = artifact
.attestations()
.iter()
.map(|did| Author::new(did).as_json(aliases))
.collect::<Vec<_>>();
let redactions = artifact
.redactions()
.iter()
.map(|(did, reason)| json!({ "user": Author::new(did).as_json(aliases), "reason": reason }))
.collect::<Vec<_>>();
json!({
"cid": cid.to_string(),
"name": artifact.name(),
"author": Author::new(artifact.author()).as_json(aliases),
"locations": locations,
"attestations": attestations,
"redactions": redactions,
"metadata": artifact.metadata(),
})
}
/// Serialize a release. The COB has no title field: it is resolved from the
/// tag message when a tag is linked, otherwise the commit summary. `filter`
/// omits the artifacts hidden under the current view; `None` keeps all of
/// them, as a release fetched by id does.
fn release_json(
id: ReleaseId,
release: &Release,
repo: &Repository,
aliases: &impl AliasStore,
delegates: &Delegates,
filter: Option<Filter>,
) -> Value {
let title = release
.tag()
.and_then(|tag| repo.title(tag))
.or_else(|| repo.title(release.oid()));
let tag_name = release.tag().and_then(|tag| repo.tag_name(tag));
let artifacts = release
.artifacts()
.iter()
.filter(|(_, artifact)| {
filter.is_none_or(|filter| filter.show_artifact(artifact, delegates))
})
.map(|(cid, artifact)| artifact_json(cid, artifact, aliases))
.collect::<Vec<_>>();
json!({
"id": id.to_string(),
"oid": release.oid(),
"tag": release.tag(),
"tagName": tag_name,
"title": title,
"createdAt": release.timestamp().as_secs(),
"creator": Author::new(release.creator()).as_json(aliases),
"artifacts": artifacts,
})
}
/// Get repo releases list, newest first.
/// `GET /repos/:rid/releases`
///
/// Scoped to releases created by a delegate and artifacts authored by a
/// delegate (hiding those redacted by a trusted party) unless widened with
/// `allAuthors=true` / `showRedacted=true`. A release whose artifacts were all
/// redacted is hidden with them.
pub async fn list_handler(
State(ctx): State<Context>,
Path(rid): Path<String>,
Query(qs): Query<ReleasesQuery>,
) -> impl IntoResponse {
let rid = ctx.resolve_repo(&rid)?;
let releases = api::blocking(move || {
let (repo, doc) = ctx.repo(rid)?;
let delegates = doc.delegates();
let aliases = ctx.profile.aliases();
let ReleasesQuery {
page,
per_page,
all_authors,
show_redacted,
} = qs;
let page = page.unwrap_or(0);
let per_page = per_page.unwrap_or(DEFAULT_PER_PAGE).min(MAX_PER_PAGE);
let filter = Filter {
all_authors: all_authors.unwrap_or(false),
show_redacted: show_redacted.unwrap_or(false),
};
// Read through the SQLite cache; it self-warms on read and is shared
// with other release reads on this node.
let cache = cache_db_path(ctx.profile.cobs());
let mut releases: Vec<_> = Releases::open_cached(&repo, cache)?
.all()?
.into_iter()
.filter_map(|r| {
let (id, release) = r.ok()?;
filter
.show_release(&release, delegates)
.then_some((id, release))
})
.collect();
releases.sort_by_key(|(_, release)| std::cmp::Reverse(release.timestamp()));
Ok::<_, Error>(
releases
.into_iter()
.skip(page * per_page)
.take(per_page)
.map(|(id, release)| {
release_json(
ReleaseId::from(id),
&release,
&repo,
&aliases,
delegates,
Some(filter),
)
})
.collect::<Vec<_>>(),
)
})
.await?;
Ok::<_, Error>(Json(releases))
}
/// Get a single repo release by id, with all its artifacts.
/// `GET /repos/:rid/releases/:id`
pub async fn get_handler(
State(ctx): State<Context>,
Path((rid, release_id)): Path<(String, Oid)>,
) -> impl IntoResponse {
let rid = ctx.resolve_repo(&rid)?;
let value = api::blocking(move || {
let (repo, doc) = ctx.repo(rid)?;
let delegates = doc.delegates();
let aliases = ctx.profile.aliases();
let cache = cache_db_path(ctx.profile.cobs());
let release = Releases::open_cached(&repo, cache)?
.get(&ReleaseId::from(release_id))?
.ok_or(Error::NotFound)?;
Ok::<_, Error>(release_json(
ReleaseId::from(release_id),
&release,
&repo,
&aliases,
delegates,
None,
))
})
.await?;
Ok::<_, Error>(Json(value))
}
#[cfg(test)]
mod routes {
use std::net::SocketAddr;
use std::str::FromStr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use axum::Router;
use pretty_assertions::assert_eq;
use serde_json::json;
use radicle::crypto::{Seed, SigningKey};
use radicle::git::Oid;
use radicle::identity::RepoId;
use radicle::storage::WriteStorage;
use radicle_artifact::{Cid, Releases};
use url::Url;
use crate::api::Context;
use crate::test::{get, seed, DID, HEAD, RID};
/// A valid CIDv1 string; the release COB stores it verbatim.
const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
/// A second, distinct CIDv1 string, for releases with more than one
/// artifact.
const CID_2: &str = "bafkr4ihjtgc6jaulcccny3jhc3ezxfjdgnp4vvt23nf6qqlrhpk764ufbu";
const LOCATION: &str = "https://example.com/linux-amd64.tar.gz";
/// The delegate signer seeded by `test::seed` (alias "seed").
const DELEGATE_SEED: [u8; 32] = [0xff; 32];
/// A signer that is not a delegate of the seeded repo.
const NON_DELEGATE_SEED: [u8; 32] = [0xee; 32];
fn app(ctx: Context) -> Router {
super::super::router(ctx).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))))
}
/// Create a release on the seeded repo at `HEAD`, signed by `seed`, with a
/// single artifact and location. Returns the release id string.
fn create_release(ctx: &Context, signer_seed: [u8; 32]) -> String {
let signer = SigningKey::from_seed(Seed::new(signer_seed));
let rid = RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let oid = Oid::from_str(HEAD).unwrap();
let mut releases = Releases::open(&repo).unwrap();
let mut release = releases.create(oid, None, &signer).unwrap();
let cid = Cid::from_str(CID).unwrap();
release
.register_artifact(cid, "linux-amd64".to_string(), &signer)
.unwrap();
release
.add_location(cid, Url::parse(LOCATION).unwrap(), &signer)
.unwrap();
release.id().oid().to_string()
}
#[tokio::test]
async fn test_repos_releases_empty() {
let tmp = tempfile::tempdir().unwrap();
let app = app(seed(tmp.path()));
let response = get(&app, format!("/repos/{RID}/releases")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await, json!([]));
}
#[tokio::test]
async fn test_repos_releases_list() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
let id = create_release(&ctx, DELEGATE_SEED);
let app = app(ctx);
let response = get(&app, format!("/repos/{RID}/releases")).await;
assert_eq!(response.status(), StatusCode::OK);
// `createdAt` is stamped at COB-creation time, so drop it before
// comparing against the fixed shape.
let mut body = response.json().await;
assert!(body[0]["createdAt"].as_u64().unwrap() > 0);
body[0]["createdAt"].take();
assert_eq!(
body,
json!([{
"id": id,
"oid": HEAD,
"tag": null,
"tagName": null,
"title": "Add another folder",
"createdAt": null,
"creator": { "id": DID, "alias": "seed" },
"artifacts": [{
"cid": CID,
"name": "linux-amd64",
"author": { "id": DID, "alias": "seed" },
"locations": [
{ "user": { "id": DID, "alias": "seed" }, "url": LOCATION }
],
"attestations": [],
"redactions": [],
"metadata": {},
}],
}])
);
}
#[tokio::test]
async fn test_repos_release_by_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
let id = create_release(&ctx, DELEGATE_SEED);
let app = app(ctx);
let response = get(&app, format!("/repos/{RID}/releases/{id}")).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.json().await;
assert_eq!(body["id"], json!(id));
assert_eq!(body["oid"], json!(HEAD));
assert_eq!(body["title"], json!("Add another folder"));
assert_eq!(body["artifacts"][0]["cid"], json!(CID));
}
#[tokio::test]
async fn test_repos_release_not_found() {
let tmp = tempfile::tempdir().unwrap();
let app = app(seed(tmp.path()));
let response = get(
&app,
format!("/repos/{RID}/releases/ffffffffffffffffffffffffffffffffffffffff"),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_repos_releases_hides_non_delegate_creator() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
create_release(&ctx, NON_DELEGATE_SEED);
let app = app(ctx);
// Default view is scoped to delegate creators.
let response = get(&app, format!("/repos/{RID}/releases")).await;
assert_eq!(response.json().await, json!([]));
// `allAuthors=true` widens it to include non-delegate creators.
let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await;
let body = response.json().await;
assert_eq!(body.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_repos_releases_list_hides_non_delegate_artifact() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
// The delegate creates a release with two artifacts: its own, and
// one registered by a non-delegate.
{
let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED));
let other_signer = SigningKey::from_seed(Seed::new(NON_DELEGATE_SEED));
let rid = RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let oid = Oid::from_str(HEAD).unwrap();
let mut releases = Releases::open(&repo).unwrap();
let mut release = releases.create(oid, None, &signer).unwrap();
let cid = Cid::from_str(CID).unwrap();
release
.register_artifact(cid, "linux-amd64".to_string(), &signer)
.unwrap();
let other_cid = Cid::from_str(CID_2).unwrap();
release
.register_artifact(other_cid, "eve-build".to_string(), &other_signer)
.unwrap();
}
let app = app(ctx);
// The release itself stays visible: its creator is a delegate. Only
// the non-delegate's artifact is dropped from the list view.
let response = get(&app, format!("/repos/{RID}/releases")).await;
let body = response.json().await;
let artifacts = body[0]["artifacts"].as_array().unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0]["cid"], json!(CID));
// `allAuthors=true` widens it to include the non-delegate's artifact.
let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await;
let body = response.json().await;
let artifacts = body[0]["artifacts"].as_array().unwrap();
assert_eq!(artifacts.len(), 2);
}
#[tokio::test]
async fn test_repos_releases_hides_redacted_artifact() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
// The delegate creates a release, then redacts its own artifact.
{
let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED));
let rid = RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let oid = Oid::from_str(HEAD).unwrap();
let mut releases = Releases::open(&repo).unwrap();
let mut release = releases.create(oid, None, &signer).unwrap();
let cid = Cid::from_str(CID).unwrap();
release
.register_artifact(cid, "linux-amd64".to_string(), &signer)
.unwrap();
release
.redact(cid, "bad build".to_string(), &signer)
.unwrap();
}
let app = app(ctx);
// With its only artifact redacted by a trusted party, the release has
// nothing left to show and is hidden too.
let response = get(&app, format!("/repos/{RID}/releases")).await;
assert_eq!(response.json().await, json!([]));
// `showRedacted=true` surfaces it, carrying the redaction reason.
let response = get(&app, format!("/repos/{RID}/releases?showRedacted=true")).await;
let body = response.json().await;
assert_eq!(body[0]["artifacts"][0]["cid"], json!(CID));
assert_eq!(
body[0]["artifacts"][0]["redactions"][0]["reason"],
json!("bad build")
);
}
#[tokio::test]
async fn test_repos_releases_alias_resolution() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = seed(tmp.path());
ctx.set_repo_aliases(std::collections::HashMap::from_iter([(
"hello".to_string(),
RepoId::from_str(RID).unwrap(),
)]));
let id = create_release(&ctx, DELEGATE_SEED);
let app = app(ctx);
// Both release routes accept a configured alias in place of the RID.
let response = get(&app, "/repos/hello/releases").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await[0]["id"], json!(id));
let response = get(&app, format!("/repos/hello/releases/{id}")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await["id"], json!(id));
// An unknown alias is not found.
let response = get(&app, "/repos/nope/releases").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_repos_releases_per_page_is_clamped() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
create_release(&ctx, DELEGATE_SEED);
let app = app(ctx);
// A caller requesting more than MAX_PER_PAGE must not be able to
// widen the page beyond the cap.
let response = get(&app, format!("/repos/{RID}/releases?perPage=1000")).await;
assert_eq!(response.status(), StatusCode::OK);
let len = response.json().await.as_array().unwrap().len();
assert!(len <= crate::api::query::MAX_PER_PAGE);
}
}
+10
View File
@@ -41,6 +41,12 @@ import type {
Revision,
Verdict,
} from "./lib/repo/patch.js";
import type {
Artifact,
Location,
Redaction,
Release,
} from "./lib/repo/release.js";
import type { RequestOptions } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";
@@ -57,6 +63,7 @@ import {
} from "./lib/shared.js";
export type {
Artifact,
Author,
BaseUrl,
Blob,
@@ -79,11 +86,14 @@ export type {
IssueState,
Job,
LifecycleState,
Location,
Merge,
Patch,
PatchState,
PeerRefs,
Reaction,
Redaction,
Release,
Remote,
Repo,
RepoListQuery,
+40
View File
@@ -4,6 +4,7 @@ import { ResponseError } from "./fetcher.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 { ZodSchema } from "zod";
import {
@@ -28,6 +29,7 @@ import {
} from "./repo/commit.js";
import { issueSchema, issuesSchema } from "./repo/issue.js";
import { patchSchema, patchesSchema } from "./repo/patch.js";
import { releaseSchema, releasesSchema } from "./repo/release.js";
import { authorSchema } from "./shared.js";
export type PeerRefs = {
@@ -70,6 +72,8 @@ const repoSchema = object({
open: number(),
closed: number(),
}),
// Optional: absent on older nodes that don't report release counts.
releases: optional(number()),
}),
}),
}),
@@ -575,4 +579,40 @@ export class Client {
patchesSchema,
);
}
public async getReleaseById(
rid: string,
releaseId: string,
options?: RequestOptions,
): Promise<Release> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `repos/${rid}/releases/${releaseId}`,
options,
},
releaseSchema,
);
}
public async getAllReleases(
rid: string,
query?: {
page?: number;
perPage?: number;
allAuthors?: boolean;
showRedacted?: boolean;
},
options?: RequestOptions,
): Promise<Release[]> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `repos/${rid}/releases`,
query,
options,
},
releasesSchema,
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { ZodSchema } from "zod";
import * as z from "zod";
import { authorSchema } from "../shared.js";
export const locationSchema = z.object({
user: authorSchema,
url: z.string(),
});
export type Location = z.infer<typeof locationSchema>;
export const redactionSchema = z.object({
user: authorSchema,
reason: z.string(),
});
export type Redaction = z.infer<typeof redactionSchema>;
export const artifactSchema = z.object({
cid: z.string(),
name: z.string(),
author: authorSchema,
locations: z.array(locationSchema),
attestations: z.array(authorSchema),
redactions: z.array(redactionSchema),
metadata: z.record(z.string(), z.unknown()),
});
export type Artifact = z.infer<typeof artifactSchema>;
// A release is keyed by a commit (optionally an annotated tag). It has no
// title field of its own; `title`/`tagName` are resolved by the backend from
// the tag or commit message, and are absent when unresolvable.
export const releaseSchema = z.object({
id: z.string(),
oid: z.string(),
tag: z.string().nullish(),
tagName: z.string().nullish(),
title: z.string().nullish(),
createdAt: z.number(),
creator: authorSchema,
artifacts: z.array(artifactSchema),
});
export type Release = z.infer<typeof releaseSchema>;
export const releasesSchema = z.array(releaseSchema) satisfies ZodSchema<
Release[]
>;
+63
View File
@@ -0,0 +1,63 @@
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
import { describe, expect } from "vitest";
import { testFixture } from "@http-client/tests/support/fixtures.js";
import { createRepo } from "@tests/support/repo.js";
import { useLocalHttpd } from "@tests/support/support.js";
describe("release", () => {
// The release read API only exists in the local httpd build, so skip these
// against the stable pre-built binary the default suite runs. Releases are
// seeded on an isolated peer to avoid perturbing the shared fixtures.
testFixture.skipIf(!useLocalHttpd)(
"#getAllReleases(rid) + #getReleaseById(rid, id)",
async ({ httpd: { api, peer } }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "releases",
defaultBranch: "main",
});
const { stdout: head } = await peer.git(["rev-parse", "HEAD"], {
cwd: repoFolder,
});
// Register an artifact from a local file so the CID is computed for us.
const artifactPath = Path.join(repoFolder, "artifact.bin");
await Fs.writeFile(artifactPath, "hello release\n");
const { stdout: receipt } = await peer.spawn(
"rad-artifact",
["--no-announce", "--no-input", "create", head, "--json"],
{ cwd: repoFolder },
);
const { releaseId } = JSON.parse(receipt);
await peer.spawn(
"rad-artifact",
[
"--no-announce",
"--no-input",
"register",
artifactPath,
"--release",
releaseId,
"--name",
"binary",
],
{ cwd: repoFolder },
);
// The sole delegate created the release, so it shows under the default
// delegate-scoped view.
const releases = await api.repo.getAllReleases(rid);
expect(releases.length).toBe(1);
expect(releases[0].id).toBe(releaseId);
expect(releases[0].oid).toBe(head);
expect(releases[0].artifacts[0].name).toBe("binary");
const release = await api.repo.getReleaseById(rid, releaseId);
expect(release.id).toBe(releaseId);
expect(release.artifacts.length).toBe(1);
},
);
});
+15 -1
View File
@@ -4,10 +4,13 @@ set -e
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")
RELEASE=$(cat "$REPO_ROOT/tests/support/heartwood-release")
HTTPD_RELEASE=$(cat "$REPO_ROOT/tests/support/radicle-httpd-release")
ARTIFACT_RELEASE=$(cat "$REPO_ROOT/tests/support/radicle-artifact-release")
BINARY_PATH=$REPO_ROOT/tests/tmp/bin
OS=$(uname)
install() {
local BINARY=""
if test -d "$BINARY_PATH/$1/$2"; then
echo ✅ "Folder $BINARY_PATH/$1/$2 exists already skipping download."
else
@@ -32,13 +35,23 @@ install() {
FETCH_URL="https://files.radicle.dev/releases/radicle-httpd/$2/radicle-httpd-$ARCH.tar.xz"
FILENAME="radicle-httpd-$2-$ARCH"
;;
artifact)
# radicle-artifact publishes bare executables instead of archives.
FETCH_URL="https://files.radicle.dev/releases/radicle-artifact/$2/rad-artifact_$2_$ARCH"
BINARY="rad-artifact"
;;
*)
echo "No precompiled binary found with the name $1." && exit 1
;;
esac
echo Downloading "$1" v"$2" from "$FETCH_URL into /tests/tmp/bin/$1/$2"
curl --fail --silent --location "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1)
if test -n "$BINARY"; then
curl --fail --silent --location --output "$BINARY_PATH/$1/$2/$BINARY" "$FETCH_URL" || (echo "Download failed" && exit 1)
chmod +x "$BINARY_PATH/$1/$2/$BINARY"
else
curl --fail --silent --location "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1)
fi
fi
}
@@ -65,5 +78,6 @@ done
install "heartwood" "$RELEASE"
install "httpd" "$HTTPD_RELEASE"
install "artifact" "$ARTIFACT_RELEASE"
echo
+6
View File
@@ -24,6 +24,8 @@
import NotFound from "@app/views/NotFound.svelte";
import Patch from "@app/views/repos/Patch.svelte";
import Patches from "@app/views/repos/Patches.svelte";
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 Users from "@app/views/users/View.svelte";
@@ -100,6 +102,10 @@
<Patches {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patch"}
<Patch {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.releases"}
<Releases {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.release"}
<Release {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "error"}
<Error {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "notFound"}
+4
View File
@@ -82,6 +82,7 @@
| "offline"
| "online"
| "open-external"
| "parcel"
| "patch"
| "patch-archived"
| "patch-draft"
@@ -488,6 +489,9 @@
<path d="M6 2.5V3.5H3.5V12.5H12.5V10H13.5V13.5H2.5V2.5H6Z" />
<path
d="M6.29297 9L11.793 3.5L8.5 3.5L8.5 2.5L13.5 2.5L13.5 7.5L12.5 7.5L12.5 4.20703L7 9.70703L6.29297 9Z" />
{:else if name === "parcel"}
<path
d="M2.5 10.7129L7.5 13.6289V8.78613L2.5 5.87011V10.7129ZM8.5 8.78613V13.6289L13.5 10.7129V5.87011L8.5 8.78613ZM5.99121 6.75L8 7.92089L13.0078 5L11 3.82812L5.99121 6.75ZM2.99121 5L5 6.17089L10.0078 3.25L8 2.07812L2.99121 5ZM14.5 11C14.5 11.1778 14.4055 11.342 14.252 11.4316L8.25195 14.9316C8.09627 15.0224 7.90373 15.0224 7.74805 14.9316L1.74805 11.4316C1.59451 11.342 1.5 11.1778 1.5 11V5C1.5 4.82221 1.59451 4.65798 1.74805 4.56835L7.74805 1.06835C7.90373 0.977541 8.09627 0.977541 8.25195 1.06835L14.252 4.56835C14.4055 4.65798 14.5 4.82221 14.5 5V11Z" />
{:else if name === "patch"}
<path
d="M5.5 12C5.5 11.1716 4.82843 10.5 4 10.5C3.17157 10.5 2.5 11.1716 2.5 12C2.5 12.8284 3.17157 13.5 4 13.5C4.82843 13.5 5.5 12.8284 5.5 12ZM13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5 12.8284 13.5 12ZM7.5 2.5H11.5V3.5H9.20703L12.5 6.79297V9.5498C13.6411 9.78142 14.5 10.7905 14.5 12C14.5 13.3807 13.3807 14.5 12 14.5C10.6193 14.5 9.5 13.3807 9.5 12C9.5 10.7905 10.3589 9.78142 11.5 9.5498V7.20703L8.5 4.20703V6.5H7.5V2.5ZM5.5 4C5.5 3.17157 4.82843 2.5 4 2.5C3.17157 2.5 2.5 3.17157 2.5 4C2.5 4.82843 3.17157 5.5 4 5.5C4.82843 5.5 5.5 4.82843 5.5 4ZM6.5 4C6.5 5.20943 5.64105 6.21753 4.5 6.44922V9.5498C5.64114 9.78142 6.5 10.7905 6.5 12C6.5 13.3807 5.38071 14.5 4 14.5C2.61929 14.5 1.5 13.3807 1.5 12C1.5 10.7905 2.35886 9.78142 3.5 9.5498V6.44922C2.35895 6.21753 1.5 5.20943 1.5 4C1.5 2.61929 2.61929 1.5 4 1.5C5.38071 1.5 6.5 2.61929 6.5 4Z" />
+16 -2
View File
@@ -12,6 +12,9 @@
export let ariaLabel: string | undefined = undefined;
export let styleWidth: string | undefined = undefined;
export let title: string | undefined = undefined;
// Keep a long id on one line and end it with an ellipsis instead of
// wrapping, for ids shown in a width-constrained column.
export let truncate: boolean = false;
let icon: ComponentProps<Icon>["name"] = "copy";
const text = "Click to copy";
@@ -43,6 +46,16 @@
position: relative;
display: inline-block;
}
.container.truncate {
display: block;
min-width: 0;
max-width: 100%;
}
.txt-id.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popover {
position: absolute;
left: 1rem;
@@ -64,10 +77,10 @@
}
</style>
<div class="container" style:width={styleWidth} {title}>
<div class="container" class:truncate style:width={styleWidth} {title}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
style:display="inline-flex"
style:display={truncate ? "block" : "inline-flex"}
style:flex-wrap="wrap"
on:mouseenter={() => {
setVisible(true);
@@ -76,6 +89,7 @@
setVisible(false);
}}
class="txt-id"
class:truncate
style:cursor="copy"
aria-label={ariaLabel}
on:click|preventDefault|stopPropagation={async () => {
+6 -2
View File
@@ -296,7 +296,9 @@ function setTitle(loadedRoute: LoadedRoute) {
loadedRoute.resource === "repo.issue" ||
loadedRoute.resource === "repo.issues" ||
loadedRoute.resource === "repo.patches" ||
loadedRoute.resource === "repo.patch"
loadedRoute.resource === "repo.patch" ||
loadedRoute.resource === "repo.releases" ||
loadedRoute.resource === "repo.release"
) {
title.push(...repoTitle(loadedRoute));
} else if (loadedRoute.resource === "nodes") {
@@ -555,7 +557,9 @@ export function routeToPath(route: Route): string {
route.resource === "repo.issues" ||
route.resource === "repo.issue" ||
route.resource === "repo.patches" ||
route.resource === "repo.patch"
route.resource === "repo.patch" ||
route.resource === "repo.releases" ||
route.resource === "repo.release"
) {
return repoRouteToPath(route);
} else if (
+3 -1
View File
@@ -114,7 +114,9 @@ export async function loadRoute(
route.resource === "repo.issues" ||
route.resource === "repo.issue" ||
route.resource === "repo.patches" ||
route.resource === "repo.patch"
route.resource === "repo.patch" ||
route.resource === "repo.releases" ||
route.resource === "repo.release"
) {
return await loadRepoRoute(route, previousLoaded);
} else {
+35 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts" context="module">
export type ActiveTab = "source" | "issues" | "patches" | undefined;
export type ActiveTab =
"source" | "issues" | "patches" | "releases" | undefined;
</script>
<script lang="ts">
@@ -39,6 +40,12 @@
repo: repoId,
node: baseUrl,
});
} else if (activeTab === "releases") {
return routeToPath({
resource: "repo.releases",
repo: repoId,
node: baseUrl,
});
} else {
return routeToPath({
resource: "repo.source",
@@ -153,10 +160,36 @@
</Button>
</Link>
<!-- Only nodes that report a release count support the release API; hide the
tab on older nodes where the field is absent. -->
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
<Link
route={{
resource: "repo.releases",
repo: repoId,
node: baseUrl,
}}>
<Button
let:hover
variant={activeTab === "releases" ? "gray" : "background"}>
<Icon name="parcel" />
<div class="title-counter">
Releases
<span
class="counter"
class:hover={hover && activeTab !== "releases"}
class:selected={activeTab === "releases"}>
{repo.payloads["xyz.radicle.project"].meta.releases}
</span>
</div>
</Button>
</Link>
{/if}
<div class="spacer"></div>
<div class="actions">
{#if activeTab !== "issues" && activeTab !== "patches"}
{#if activeTab !== "issues" && activeTab !== "patches" && activeTab !== "releases"}
<Button variant="outline" size="regular" on:click={copyLink}>
<Icon name={shareIcon} />
<span class="global-hide-on-small-desktop-down">Copy link</span>
+20
View File
@@ -195,6 +195,26 @@
</Button>
</Link>
</div>
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
<div style:width="100%">
<Link
title="Releases"
route={{
resource: "repo.releases",
repo: repoId,
node: baseUrl,
}}>
<Button
variant={activeTab === "releases"
? "secondary"
: "secondary-mobile"}
styleWidth="100%">
<Icon name="parcel" />
</Button>
</Link>
</div>
{/if}
</MobileFooter>
</div>
</div>
+623
View File
@@ -0,0 +1,623 @@
<script lang="ts">
import type { Artifact, BaseUrl, Release, Repo } from "@http-client";
import * as utils from "@app/lib/utils";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import CobHeader from "@app/views/repos/Cob/CobHeader.svelte";
import DelegateBadge from "./Release/DelegateBadge.svelte";
import DownloadButton from "./Release/DownloadButton.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
import Layout from "./Layout.svelte";
import Link from "@app/components/Link.svelte";
import NodeId from "@app/components/NodeId.svelte";
import Separator from "./Separator.svelte";
export let baseUrl: BaseUrl;
export let release: Release;
export let repo: Repo;
export let repoId: string;
export let allAuthors: boolean;
export let nodeId: string;
export let nodeAvatarUrl: string | undefined;
const SIZE_KEY = "sizeBytes";
$: title = release.title || release.tagName || release.id;
// An artifact redacted by its own author or a delegate is hidden by default.
let showRedacted = false;
$: delegateIds = new Set(repo.delegates.map(d => d.id));
// Whether the artifact was redacted by a trusted party (its author or a
// delegate), mirroring the backend's default-hidden rule.
function redactedByTrusted(artifact: Artifact, delegates: Set<string>) {
return artifact.redactions.some(
r => r.user.id === artifact.author.id || delegates.has(r.user.id),
);
}
// Label for the redacted badge. Delegate takes precedence over author,
// since the author may also be a delegate.
function redactedByLabel(artifact: Artifact, delegates: Set<string>): string {
const byDelegate = artifact.redactions.some(r => delegates.has(r.user.id));
return byDelegate ? "Redacted by delegate" : "Redacted by author";
}
// Artifacts visible under the given redaction toggle.
function visible(
list: Artifact[],
show: boolean,
delegates: Set<string>,
): Artifact[] {
return show ? list : list.filter(a => !redactedByTrusted(a, delegates));
}
$: delegateArtifacts = release.artifacts.filter(a =>
delegateIds.has(a.author.id),
);
// Artifacts are scoped to delegate authors by default; the endpoint returns
// all of them, so filtering happens client-side.
$: authorArtifacts = allAuthors ? release.artifacts : delegateArtifacts;
$: shownArtifacts = visible(authorArtifacts, showRedacted, delegateIds);
// Segment counts reflect what each choice would actually show; the redacted
// count is the hidden set within the current author scope.
$: delegateCount = visible(
delegateArtifacts,
showRedacted,
delegateIds,
).length;
$: allCount = visible(release.artifacts, showRedacted, delegateIds).length;
$: redactedCount = authorArtifacts.filter(a =>
redactedByTrusted(a, delegateIds),
).length;
// Format a byte count as a human-readable size (mirrors the CLI display).
function formatBytes(bytes: number): string {
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
function artifactSize(artifact: Artifact): string | undefined {
const size = artifact.metadata[SIZE_KEY];
return typeof size === "number" ? formatBytes(size) : undefined;
}
// Group an artifact's locations by the contributing node, preserving order.
function locationsByNode(artifact: Artifact) {
const order: string[] = [];
const groups: Record<
string,
{ user: Artifact["locations"][number]["user"]; urls: string[] }
> = {};
for (const { user, url } of artifact.locations) {
if (!groups[user.id]) {
groups[user.id] = { user, urls: [] };
order.push(user.id);
}
groups[user.id].urls.push(url);
}
return order.map(id => groups[id]);
}
// Order a node-keyed list so delegates come first. The backend returns
// these sorted by DID (arbitrary to a reader); sort is stable, so the
// original order is preserved within each group.
function delegatesFirst<T>(
items: T[],
id: (item: T) => string,
delegates: Set<string>,
): T[] {
return [...items].sort(
(a, b) => Number(delegates.has(id(b))) - Number(delegates.has(id(a))),
);
}
// Metadata entries other than the size hint, shown as raw key/value pairs.
function otherMetadata(artifact: Artifact): [string, unknown][] {
return Object.entries(artifact.metadata).filter(
([key]) => key !== SIZE_KEY,
);
}
// Whether the browser can fetch the location itself, as opposed to it being
// served over the radicle-artifact protocol.
function isWebUrl(url: string): boolean {
return /^https?:\/\//i.test(url);
}
</script>
<style>
.release {
display: flex;
flex: 1;
min-height: 100%;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
background-color: var(--color-surface-base);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-heading-l);
word-break: break-word;
}
.artifacts {
display: flex;
flex-direction: column;
}
.filter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
padding: 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.empty-artifacts {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 4rem 1.25rem;
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
text-align: center;
}
.empty-artifacts :global(svg) {
width: 2rem;
height: 2rem;
}
.segmented {
display: flex;
align-items: center;
gap: 0.25rem;
}
.title-counter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.counter {
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
color: var(--color-text-tertiary);
padding: 0 0.25rem;
min-width: 1.5rem;
text-align: center;
}
.selected {
background-color: var(--color-surface-alpha-subtle);
color: var(--color-text-primary);
}
.hover {
background-color: var(--color-surface-strong);
color: var(--color-text-primary);
}
.artifact {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background-color: var(--color-surface-canvas);
border-bottom: 1px solid var(--color-border-subtle);
}
.artifact-name {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font: var(--txt-body-m-regular);
word-break: break-word;
}
.cid {
margin-top: -0.5rem;
margin-bottom: 0.5rem;
}
.provenance {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.attested {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
.attestor {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.metadata {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
font: var(--txt-body-m-regular);
}
.meta-item {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.meta-key {
color: var(--color-text-secondary);
}
.meta-value {
color: var(--color-text-tertiary);
}
.artifact-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.size {
color: var(--color-text-tertiary);
}
.divider {
width: 100%;
height: 0;
margin: 0.25rem 0;
border: none;
}
.field-label {
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.accordion summary {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: fit-content;
list-style: none;
}
.accordion summary::-webkit-details-marker {
display: none;
}
.chevron {
display: inline-flex;
color: var(--color-text-tertiary);
transition: transform 0.1s ease;
}
.accordion[open] .chevron {
transform: rotate(90deg);
}
.locations {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.75rem;
}
.redactions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
font: var(--txt-body-m-regular);
}
.location-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.location-node {
display: flex;
align-items: center;
gap: 0.5rem;
}
.location-url {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.redaction {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
color: var(--color-feedback-error-text);
}
.reason {
color: var(--color-text-tertiary);
word-break: break-word;
}
</style>
<Layout {baseUrl} {nodeId} {nodeAvatarUrl} {repo} {repoId} activeTab="releases">
<svelte:fragment slot="breadcrumb">
<Separator />
<Link
route={{
resource: "repo.releases",
repo: repoId,
node: baseUrl,
allAuthors,
}}>
Releases
</Link>
<Separator />
<span class="txt-id">
<div class="global-hide-on-small-desktop-down">{release.id}</div>
<div class="global-hide-on-medium-desktop-up">
{utils.formatObjectId(release.id)}
</div>
</span>
</svelte:fragment>
<div class="release">
<div class="main">
<CobHeader>
<svelte:fragment slot="title">
<div class="title">
<Icon name="parcel" />
<InlineTitle fontSize="heading-l" content={title} />
</div>
</svelte:fragment>
<svelte:fragment slot="state">
{#if release.tagName}
<Badge size="tiny" variant="neutral">{release.tagName}</Badge>
{/if}
<span class="row">
commit
<Link
route={{
resource: "repo.commit",
repo: repoId,
node: baseUrl,
commit: release.oid,
}}>
<Id id={release.oid} />
</Link>
</span>
<NodeId
{baseUrl}
nodeId={release.creator.id}
alias={release.creator.alias} />
released
<Id id={release.id} />
<span title={utils.absoluteTimestamp(release.createdAt)}>
{utils.formatTimestamp(release.createdAt)}
</span>
</svelte:fragment>
</CobHeader>
<div class="artifacts">
<div class="filter">
<div class="segmented">
<Link
route={{
resource: "repo.release",
repo: repoId,
node: baseUrl,
release: release.id,
}}>
<Button let:hover variant={!allAuthors ? "gray" : "background"}>
<Icon name="badge" />
<div class="title-counter">
Delegates
<span
class="counter"
class:selected={!allAuthors}
class:hover={hover && allAuthors}>
{delegateCount}
</span>
</div>
</Button>
</Link>
<Link
route={{
resource: "repo.release",
repo: repoId,
node: baseUrl,
release: release.id,
allAuthors: true,
}}>
<Button let:hover variant={allAuthors ? "gray" : "background"}>
<Icon name="avatar-incognito" />
<div class="title-counter">
All
<span
class="counter"
class:selected={allAuthors}
class:hover={hover && !allAuthors}>
{allCount}
</span>
</div>
</Button>
</Link>
</div>
{#if redactedCount > 0}
<Button
variant="background"
on:click={() => (showRedacted = !showRedacted)}>
<Icon name="warning" />
{showRedacted ? "Hide redacted" : "Show redacted"}
<span class="counter">{redactedCount}</span>
</Button>
{/if}
</div>
{#if shownArtifacts.length === 0}
<div class="empty-artifacts">
<Icon name="attach" />
No artifacts
</div>
{/if}
{#each shownArtifacts as artifact (artifact.cid)}
{@const size = artifactSize(artifact)}
{@const locations = delegatesFirst(
locationsByNode(artifact),
g => g.user.id,
delegateIds,
)}
{@const metadata = otherMetadata(artifact)}
{@const locationCount = artifact.locations.length}
{@const cliOnly =
locationCount > 0 && !artifact.locations.some(l => isWebUrl(l.url))}
{@const redactions = delegatesFirst(
artifact.redactions,
r => r.user.id,
delegateIds,
)}
<div class="artifact">
<div class="artifact-name">
<span>{artifact.name}</span>
{#if redactedByTrusted(artifact, delegateIds)}
<Badge size="tiny" variant="negative">
<Icon name="warning" />
{redactedByLabel(artifact, delegateIds)}
</Badge>
{/if}
<div class="artifact-actions">
{#if size}
<span class="size">{size}</span>
{/if}
<DownloadButton {artifact} rid={repo.rid} {delegateIds} />
</div>
</div>
<div class="cid">
<Id id={artifact.cid}>{utils.truncateId(artifact.cid)}</Id>
</div>
<div class="provenance">
<NodeId
{baseUrl}
nodeId={artifact.author.id}
alias={artifact.author.alias} />
{#if delegateIds.has(artifact.author.id)}
<DelegateBadge />
{/if}
{#if artifact.attestations.length > 0}
<span class="attested">
attested by
{#each delegatesFirst(artifact.attestations, n => n.id, delegateIds) as node, i (i)}
<span class="attestor">
<NodeId {baseUrl} nodeId={node.id} alias={node.alias} />
{#if delegateIds.has(node.id)}
<DelegateBadge />
{/if}{#if i < artifact.attestations.length - 1},{/if}
</span>
{/each}
</span>
{/if}
</div>
{#if metadata.length > 0}
<div class="metadata">
{#each metadata as [key, value] (key)}
<span class="meta-item">
<span class="meta-key">{key}</span>
<span class="meta-value">
{typeof value === "string"
? value
: JSON.stringify(value)}
</span>
</span>
{/each}
</div>
{/if}
{#if locationCount > 0}
<hr class="divider" />
<details class="accordion" open={cliOnly}>
<summary>
<span class="field-label">
{locationCount} location{locationCount === 1 ? "" : "s"}
</span>
<span class="chevron"><Icon name="chevron-right" /></span>
</summary>
<div class="locations">
{#each locations as group (group.user.id)}
<div class="location-group">
<div class="location-node">
<NodeId
{baseUrl}
nodeId={group.user.id}
alias={group.user.alias} />
{#if delegateIds.has(group.user.id)}
<DelegateBadge
tooltip="Location added by a delegate" />
{/if}
</div>
{#each group.urls as url (url)}
<div class="location-url">
<Id id={url} truncate>{url}</Id>
</div>
{/each}
</div>
{/each}
</div>
</details>
{/if}
{#if redactions.length > 0}
<details class="accordion">
<summary>
<span class="field-label">
{redactions.length} redaction{redactions.length === 1
? ""
: "s"}
</span>
<span class="chevron"><Icon name="chevron-right" /></span>
</summary>
<div class="redactions">
{#each redactions as redaction (redaction.user.id)}
<div class="redaction">
<Icon name="warning" />
Redacted by
<NodeId
{baseUrl}
nodeId={redaction.user.id}
alias={redaction.user.alias} />
{#if delegateIds.has(redaction.user.id)}
<DelegateBadge />
{/if}
<span class="reason">
{redaction.reason || "No reason"}
</span>
</div>
{/each}
</div>
</details>
{/if}
</div>
{/each}
</div>
</div>
</div>
</Layout>
@@ -0,0 +1,44 @@
<script lang="ts">
import Icon from "@app/components/Icon.svelte";
export let style: string | undefined = undefined;
export let tooltip: string = "Delegate";
</script>
<style>
.delegate {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--color-text-tertiary);
}
/* Tighten the gap when the badge follows another element, e.g. a NodeId,
in a flex row — the icon's own whitespace makes a full gap look loose. */
.delegate:not(:first-child) {
margin-left: -0.25rem;
}
.tooltip {
position: absolute;
bottom: 1.5rem;
left: 0;
display: none;
z-index: 20;
padding: 0.25rem 0.5rem;
background: var(--color-surface-subtle);
color: var(--color-text-primary);
font: var(--txt-body-s-regular);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
box-shadow: var(--elevation-low);
white-space: nowrap;
}
.delegate:hover .tooltip {
display: block;
}
</style>
<span class="delegate" {style}>
<Icon name="badge" />
<span class="tooltip">{tooltip}</span>
</span>
@@ -0,0 +1,161 @@
<script lang="ts">
import type { Artifact } from "@http-client";
import Button from "@app/components/Button.svelte";
import Command from "@app/components/Command.svelte";
import DelegateBadge from "./DelegateBadge.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import Popover from "@app/components/Popover.svelte";
import Radio from "@app/components/Radio.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
export let artifact: Artifact;
export let rid: string;
export let delegateIds: Set<string>;
// Whether the browser can fetch the location itself, as opposed to it being
// served over the radicle-artifact protocol.
function isWebUrl(url: string): boolean {
return /^https?:\/\//i.test(url);
}
// Delegate locations come first: they are the ones a reader can trust most.
$: webLocations = [...artifact.locations.filter(l => isWebUrl(l.url))].sort(
(a, b) =>
Number(delegateIds.has(b.user.id)) - Number(delegateIds.has(a.user.id)),
);
// Any location at all can be fetched with the CLI, web locations included.
$: downloadable = artifact.locations.length > 0;
$: command = `rad artifact -r ${rid} download --cid ${artifact.cid}`;
let activeTab: "cli" | "browser" = "cli";
</script>
<style>
.popover {
font: var(--txt-body-m-regular);
}
label {
display: block;
margin-bottom: 0.75rem;
}
.warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
color: var(--color-feedback-warning-text);
}
.warning :global(svg) {
flex-shrink: 0;
}
.locations {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.location {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.url {
flex: 1;
min-width: 0;
}
.download {
display: inline-flex;
flex-shrink: 0;
color: var(--color-text-tertiary);
}
.download:hover {
color: var(--color-text-primary);
}
</style>
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
<Button
slot="toggle"
let:toggle
on:click={toggle}
variant="gray"
disabled={!downloadable}
title={downloadable
? undefined
: "No download source is currently available for this artifact"}>
<Icon name="download" />
Download
</Button>
<div slot="popover" style:width="24rem" class="popover">
<div style:margin-bottom="1.5rem">
<Radio ariaLabel="Toggle download method" styleGap="2px">
<Button
styleWidth="100%"
styleBorderRadius="0"
variant={activeTab === "cli" ? "selected" : "not-selected"}
on:click={() => {
activeTab = "cli";
}}>
<Icon name="logo" />
CLI
</Button>
<div class="global-spacer"></div>
<Button
styleWidth="100%"
styleBorderRadius="0"
disabled={webLocations.length === 0}
title={webLocations.length === 0
? "This artifact has no web locations"
: undefined}
variant={activeTab === "browser" ? "selected" : "not-selected"}
on:click={() => {
activeTab = "browser";
}}>
<Icon name="download" />
Browser
</Button>
</Radio>
</div>
{#if activeTab === "cli"}
<label for="download-command">
Use the <ExternalLink
href="https://radicle.network/nodes/iris.radicle.network/rad:z4VYyJ9KuwMNkXGQnmKuGPGKw3inv">
Radicle Artifact CLI
</ExternalLink> to download and verify this artifact.
</label>
<Command {command} />
{:else}
<div class="warning">
<Icon name="warning" />
These downloads are not verified.
</div>
<div class="locations">
{#each webLocations as location (location.url)}
<div class="location">
{#if delegateIds.has(location.user.id)}
<DelegateBadge tooltip="Location added by a delegate" />
{/if}
<div class="url">
<Id id={location.url} truncate title={location.url}>
{location.url}
</Id>
</div>
<a
class="download"
href={location.url}
target="_blank"
rel="noreferrer"
download
title="Download">
<Icon name="download" />
</a>
</div>
{/each}
</div>
{/if}
</div>
</Popover>
@@ -0,0 +1,112 @@
<script lang="ts">
import type { BaseUrl, Release } from "@http-client";
import { absoluteTimestamp, formatTimestamp } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
import Link from "@app/components/Link.svelte";
import NodeId from "@app/components/NodeId.svelte";
export let baseUrl: BaseUrl;
export let release: Release;
export let repoId: string;
export let allAuthors: boolean = false;
// The COB has no title; fall back to the tag name, then the release id.
$: title = release.title || release.tagName || release.id;
</script>
<style>
.release-teaser {
display: flex;
padding: 1.25rem;
background-color: var(--color-surface-canvas);
}
.release-teaser:hover {
background-color: var(--color-surface-subtle);
}
.content {
gap: 0.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.subtitle {
display: flex;
flex-direction: column;
flex-wrap: wrap;
font: var(--txt-body-m-regular);
gap: 0.5rem;
}
.summary {
display: flex;
align-items: flex-start;
gap: 0.5rem;
word-break: break-word;
}
.icon {
justify-self: center;
align-self: flex-start;
margin-right: 0.5rem;
padding: 0.25rem;
color: var(--color-text-tertiary);
}
.right {
display: flex;
margin-left: auto;
min-height: 1.5rem;
align-items: center;
gap: 0.75rem;
color: var(--color-text-tertiary);
}
.counter {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
</style>
<div role="button" tabindex="0" class="release-teaser">
<div class="icon">
<Icon name="parcel" />
</div>
<div class="content">
<div class="summary">
<span>
<Link
styleHoverState
route={{
resource: "repo.release",
repo: repoId,
node: baseUrl,
release: release.id,
allAuthors,
}}>
<InlineTitle fontSize="body-m-regular" content={title} />
</Link>
</span>
<div class="right">
<span class="counter" title="Artifacts">
<Icon name="attach" />
{release.artifacts.length}
</span>
</div>
</div>
<div class="subtitle">
<div
style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<NodeId
{baseUrl}
nodeId={release.creator.id}
alias={release.creator.alias} />
released
<Id id={release.id} />
<span title={absoluteTimestamp(release.createdAt)}>
{formatTimestamp(release.createdAt)}
</span>
</div>
</div>
</div>
</div>
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
import type { BaseUrl, Release, Repo } from "@http-client";
import { HttpdClient } from "@http-client";
import { RELEASES_PER_PAGE } from "./router";
import { baseUrlToString } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import ErrorMessage from "@app/components/ErrorMessage.svelte";
import Icon from "@app/components/Icon.svelte";
import Layout from "./Layout.svelte";
import Link from "@app/components/Link.svelte";
import List from "@app/components/List.svelte";
import Loading from "@app/components/Loading.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ReleaseTeaser from "@app/views/repos/Release/ReleaseTeaser.svelte";
import Separator from "./Separator.svelte";
export let baseUrl: BaseUrl;
export let releases: Release[];
export let repo: Repo;
export let repoId: string;
export let allAuthors: boolean;
export let nodeId: string;
export let nodeAvatarUrl: string | undefined;
let loading = false;
let page = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let error: any;
let allReleases: Release[];
$: {
allReleases = releases;
page = 0;
}
$: delegateIds = new Set(repo.delegates.map(d => d.id));
// The delegate count isn't in repo metadata; derive it from the loaded list.
// In the all-authors view we filter for delegates; otherwise the loaded list
// already is the delegate set.
$: delegateReleaseCount = allAuthors
? allReleases.filter(r => delegateIds.has(r.creator.id)).length
: allReleases.length;
const api = new HttpdClient(baseUrl);
async function loadReleases(): Promise<void> {
loading = true;
// Only advance the cursor once the page is in hand, so a failed load
// doesn't leave a gap behind on retry.
const next = page + 1;
try {
const response = await api.repo.getAllReleases(repo.rid, {
allAuthors,
page: next,
perPage: RELEASES_PER_PAGE,
});
allReleases = [...allReleases, ...response];
page = next;
} catch (e) {
error = e;
} finally {
loading = false;
}
}
// The count for the current filter isn't in repo metadata, so offer "More"
// whenever every loaded page came back full; a short page means we've reached
// the end.
$: showMoreButton =
!loading && !error && allReleases.length === (page + 1) * RELEASES_PER_PAGE;
</script>
<style>
.header {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.counter {
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
color: var(--color-text-tertiary);
padding: 0 0.25rem;
min-width: 1.5rem;
text-align: center;
}
.selected {
background-color: var(--color-surface-alpha-subtle);
color: var(--color-text-primary);
}
.hover {
background-color: var(--color-surface-strong);
color: var(--color-text-primary);
}
.title-counter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.more {
margin-top: 2rem;
min-height: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
height: calc(100% - 4rem);
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 719.98px) {
.placeholder {
height: calc(100vh - 10rem);
}
}
</style>
<Layout {nodeId} {nodeAvatarUrl} {baseUrl} {repo} {repoId} activeTab="releases">
<svelte:fragment slot="breadcrumb">
<Separator />
<Link
route={{
resource: "repo.releases",
repo: repoId,
node: baseUrl,
}}>
Releases
</Link>
</svelte:fragment>
<div slot="header" class="header">
<Link route={{ resource: "repo.releases", repo: repoId, node: baseUrl }}>
<Button let:hover variant={!allAuthors ? "gray" : "background"}>
<Icon name="badge" />
<div class="title-counter">
Delegates
<span
class="counter"
class:selected={!allAuthors}
class:hover={hover && allAuthors}>
{delegateReleaseCount}{showMoreButton ? "+" : ""}
</span>
</div>
</Button>
</Link>
<Link
route={{
resource: "repo.releases",
repo: repoId,
node: baseUrl,
allAuthors: true,
}}>
<Button let:hover variant={allAuthors ? "gray" : "background"}>
<Icon name="avatar-incognito" />
<div class="title-counter">
All
<!-- Only the full (all-authors) count is in repo metadata; absent on
older nodes. -->
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
<span
class="counter"
class:selected={allAuthors}
class:hover={hover && !allAuthors}>
{repo.payloads["xyz.radicle.project"].meta.releases}
</span>
{/if}
</div>
</Button>
</Link>
</div>
<List items={allReleases}>
<ReleaseTeaser
slot="item"
let:item
{baseUrl}
{repoId}
{allAuthors}
release={item} />
</List>
{#if error}
<ErrorMessage
title="Couldn't load releases"
description="Please make sure you are able to connect to the seed <code>{baseUrlToString(
api.baseUrl,
)}</code>"
{error} />
{/if}
{#if allReleases.length === 0 && !error}
<div class="placeholder">
<Placeholder iconName="desert" caption="No releases" />
</div>
{/if}
{#if loading || showMoreButton}
<div class="more">
{#if loading}
<Loading noDelay small center />
{/if}
{#if showMoreButton}
<Button size="large" variant="outline" on:click={loadReleases}>
More
</Button>
{/if}
</div>
{/if}
</Layout>
+4
View File
@@ -18,6 +18,10 @@ export function handleError(
subject = "Issue";
} else if (route.resource === "repo.patch") {
subject = "Patch";
} else if (route.resource === "repo.release") {
subject = "Release";
} else if (route.resource === "repo.releases") {
subject = "Releases";
} else {
subject = "Repository";
}
+186 -1
View File
@@ -17,6 +17,7 @@ import type {
Patch,
PatchState,
PeerRefs,
Release,
Remote,
Repo,
Revision,
@@ -39,6 +40,7 @@ import { nodePath } from "@app/views/nodes/router";
export const PATCHES_PER_PAGE = 10;
export const ISSUES_PER_PAGE = 10;
export const RELEASES_PER_PAGE = 30;
export function peerHasBranches(peer: PeerRefs): boolean {
return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/"));
@@ -91,7 +93,9 @@ export type RepoRoute =
| RepoIssuesRoute
| RepoIssueRoute
| RepoPatchesRoute
| RepoPatchRoute;
| RepoPatchRoute
| RepoReleasesRoute
| RepoReleaseRoute;
interface RepoIssuesRoute {
resource: "repo.issues";
@@ -100,6 +104,21 @@ interface RepoIssuesRoute {
status?: "open" | "closed";
}
interface RepoReleasesRoute {
resource: "repo.releases";
node: BaseUrl;
repo: string;
allAuthors?: boolean;
}
interface RepoReleaseRoute {
resource: "repo.release";
node: BaseUrl;
repo: string;
release: string;
allAuthors?: boolean;
}
interface RepoIssueRoute {
resource: "repo.issue";
node: BaseUrl;
@@ -254,6 +273,30 @@ export type RepoLoadedRoute =
nodeId: string;
nodeAvatarUrl: string | undefined;
};
}
| {
resource: "repo.releases";
params: {
baseUrl: BaseUrl;
repo: Repo;
repoId: string;
releases: Release[];
allAuthors: boolean;
nodeId: string;
nodeAvatarUrl: string | undefined;
};
}
| {
resource: "repo.release";
params: {
baseUrl: BaseUrl;
repo: Repo;
repoId: string;
release: Release;
allAuthors: boolean;
nodeId: string;
nodeAvatarUrl: string | undefined;
};
};
export type BlobResult =
@@ -362,6 +405,10 @@ export async function loadRepoRoute(
return await loadIssuesView(route);
} else if (route.resource === "repo.patches") {
return await loadPatchesView(route);
} else if (route.resource === "repo.releases") {
return await loadReleasesView(route);
} else if (route.resource === "repo.release") {
return await loadReleaseView(route);
} else {
return unreachable(route);
}
@@ -439,6 +486,101 @@ 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.
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.",
baseUrl: node,
},
};
}
async function loadReleasesView(
route: RepoReleasesRoute,
): Promise<RepoLoadedRoute | NotFoundRoute> {
const api = new HttpdClient(route.node);
const allAuthors = route.allAuthors || false;
// 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, {
allAuthors,
page: 0,
perPage: RELEASES_PER_PAGE,
})
.then(
releases => ({ releases }),
(error: unknown) => ({ error }),
),
api.getNode(),
]);
if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) {
return releasesNotSupported(route.node);
}
if ("error" in releasesResult) {
throw releasesResult.error;
}
return {
resource: "repo.releases",
params: {
baseUrl: route.node,
repoId: route.repo,
releases: releasesResult.releases,
allAuthors,
repo,
nodeId: node.id,
nodeAvatarUrl: node.avatarUrl,
},
};
}
async function loadReleaseView(
route: RepoReleaseRoute,
): Promise<RepoLoadedRoute | NotFoundRoute> {
const api = new HttpdClient(route.node);
const allAuthors = route.allAuthors || false;
const [repo, releaseResult, node] = await Promise.all([
api.repo.getByRid(route.repo),
api.repo.getReleaseById(route.repo, route.release).then(
release => ({ release }),
(error: unknown) => ({ error }),
),
api.getNode(),
]);
if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) {
return releasesNotSupported(route.node);
}
if ("error" in releaseResult) {
throw releaseResult.error;
}
return {
resource: "repo.release",
params: {
baseUrl: route.node,
repoId: route.repo,
repo,
release: releaseResult.release,
allAuthors,
nodeId: node.id,
nodeAvatarUrl: node.avatarUrl,
},
};
}
async function loadTreeView(
route: RepoTreeRoute,
previousLoaded: LoadedRoute,
@@ -964,6 +1106,27 @@ export function resolveRepoRoute(
}
} else if (content === "patches") {
return resolvePatchesRoute(node, repo, segments, urlSearch);
} else if (content === "releases") {
const release = segments.shift();
const allAuthors =
new URLSearchParams(sanitizeQueryString(urlSearch)).get("allAuthors") ===
"true";
if (release) {
return {
resource: "repo.release",
node,
repo,
release,
allAuthors,
};
} else {
return {
resource: "repo.releases",
node,
repo,
allAuthors,
};
}
} else {
return null;
}
@@ -1085,6 +1248,18 @@ export function repoRouteToPath(route: RepoRoute): string {
return url;
} else if (route.resource === "repo.patch") {
return patchRouteToPath(route);
} else if (route.resource === "repo.releases") {
let url = [...pathSegments, "releases"].join("/");
if (route.allAuthors) {
url += "?allAuthors=true";
}
return url;
} else if (route.resource === "repo.release") {
let url = [...pathSegments, "releases", route.release].join("/");
if (route.allAuthors) {
url += "?allAuthors=true";
}
return url;
} else {
return unreachable(route);
}
@@ -1154,6 +1329,16 @@ export function repoTitle(loadedRoute: RepoLoadedRoute) {
} else if (loadedRoute.resource === "repo.patches") {
title.push(project.data.name);
title.push("patches");
} else if (loadedRoute.resource === "repo.release") {
title.push(
loadedRoute.params.release.title ||
loadedRoute.params.release.tagName ||
loadedRoute.params.release.id,
);
title.push("release");
} else if (loadedRoute.resource === "repo.releases") {
title.push(project.data.name);
title.push("releases");
} else {
return unreachable(loadedRoute);
}
+4
View File
@@ -4,6 +4,10 @@ test("navigate patch details", async ({ page }) => {
await page.goto(`${cobUrl}/patches`);
await page.getByText("Add subtitle to README").click();
await expect(page).toHaveURL(/patches\/[a-f0-9]{40}$/);
// The router pushes the URL before the patch view renders. Until it is up,
// the link below resolves to the teaser on the patch listing, and clicking
// that navigates to the patch we are already on.
await page.getByRole("heading", { name: "Add subtitle to README" }).waitFor();
await page.getByRole("link", { name: "Add subtitle to README" }).click();
await expect(page).toHaveURL(/commits\/[a-f0-9]{40}$/);
await page.goBack();
+198
View File
@@ -0,0 +1,198 @@
import { test, expect } from "@tests/support/fixtures.js";
import {
createContributor,
createRepo,
radArtifact,
registerArtifact,
syncFrom,
} from "@tests/support/repo.js";
import { releaseApiOnlyLocal, useLocalHttpd } from "@tests/support/support.js";
test.describe(() => {
test.skip(!useLocalHttpd, releaseApiOnlyLocal);
// Release fixtures need several rad-artifact commands, which takes longer
// than the default per-test budget.
test.describe.configure({ timeout: 30_000 });
test("navigate single release", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "single-release",
});
const { releaseId } = await registerArtifact(peer, repoFolder, {
name: "binary",
});
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await page.locator(".release-teaser").getByText("initial commit").click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases/${releaseId}`,
);
await expect(page.locator(".artifact-name")).toContainText("binary");
});
test("release title and tag name come from the tag", async ({
page,
peer,
}) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "tagged-release",
});
await peer.git(["tag", "--annotate", "v1.0", "--message", "Version 1.0"], {
cwd: repoFolder,
});
// rad-artifact resolves revisions in Radicle storage, where refs are
// namespaced per node, so push the tag and address it by object id.
await peer.git(["push", "rad", "--tags"], { cwd: repoFolder });
const { stdout: tagOid } = await peer.git(["rev-parse", "v1.0"], {
cwd: repoFolder,
});
const { releaseId } = await registerArtifact(peer, repoFolder, {
name: "binary",
revision: tagOid,
});
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
await expect(page.locator(".title")).toContainText("Version 1.0");
await expect(
page.getByRole("button", { name: "v1.0", exact: true }),
).toBeVisible();
});
test("artifact download locations", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "release-locations",
});
// An artifact with a web location is downloadable straight from the
// browser.
const { releaseId, cid } = await registerArtifact(peer, repoFolder, {
name: "web-artifact",
});
await radArtifact(peer, repoFolder, [
"location",
"add",
"--release",
releaseId,
"--cid",
cid,
"https://example.com/web-artifact",
]);
// An artifact served over the radicle-artifact protocol needs the CLI.
const { cid: cliCid } = await registerArtifact(peer, repoFolder, {
name: "cli-artifact",
release: releaseId,
});
await radArtifact(peer, repoFolder, [
"location",
"add",
"--release",
releaseId,
"--cid",
cliCid,
// A bare radiroh location matches any seeding endpoint.
"radiroh://",
]);
// An artifact without any location can't be fetched at all.
await registerArtifact(peer, repoFolder, {
name: "unavailable-artifact",
release: releaseId,
});
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
const webArtifact = page
.locator(".artifact")
.filter({ hasText: "web-artifact" });
await webArtifact.getByRole("button", { name: "Download" }).click();
await webArtifact.getByRole("button", { name: "Browser" }).click();
await expect(
webArtifact.getByRole("link", { name: "Download" }),
).toHaveAttribute("href", "https://example.com/web-artifact");
const cliArtifact = page
.locator(".artifact")
.filter({ hasText: "cli-artifact" });
await cliArtifact.getByRole("button", { name: "Download" }).click();
await expect(
cliArtifact.getByRole("button", { name: "Browser" }),
).toBeDisabled();
await expect(cliArtifact.locator(".cmd")).toContainText(
`rad artifact -r ${rid} download --cid ${cliCid}`,
);
await expect(
page
.locator(".artifact")
.filter({ hasText: "unavailable-artifact" })
.getByRole("button", { name: "Download" }),
).toBeDisabled();
});
test("show redacted artifacts", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "redacted-artifact",
});
const { releaseId } = await registerArtifact(peer, repoFolder, {
name: "good-artifact",
});
const { cid } = await registerArtifact(peer, repoFolder, {
name: "flagged-artifact",
release: releaseId,
});
await radArtifact(peer, repoFolder, [
"redact",
"--release",
releaseId,
"--cid",
cid,
"--reason",
"Built from the wrong toolchain",
]);
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
await expect(page.getByText("good-artifact")).toBeVisible();
await expect(page.getByText("flagged-artifact")).toBeHidden();
await page.getByRole("button", { name: "Show redacted 1" }).click();
await expect(page.getByText("flagged-artifact")).toBeVisible();
await expect(
page.getByText("Built from the wrong toolchain"),
).toBeVisible();
});
test("filter artifacts by author", async ({ page, peer, peerManager }) => {
const name = "artifact-authors";
const { rid, repoFolder } = await createRepo(peer, { name });
const { releaseId } = await registerArtifact(peer, repoFolder, {
name: "delegate-artifact",
});
// Eve isn't a delegate, so her artifact is out of scope by default.
const { contributor, repoFolder: contributorFolder } =
await createContributor(peerManager, peer, { rid, repoName: name });
await registerArtifact(contributor, contributorFolder, {
name: "eve-artifact",
release: releaseId,
});
await syncFrom(peer, repoFolder);
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
await expect(page.getByText("delegate-artifact")).toBeVisible();
await expect(page.getByText("eve-artifact")).toBeHidden();
await page.getByRole("link", { name: "All 2" }).click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases/${releaseId}?allAuthors=true`,
);
await expect(page.getByText("eve-artifact")).toBeVisible();
await page.getByRole("link", { name: "Delegates 1" }).click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases/${releaseId}`,
);
await expect(page.getByText("eve-artifact")).toBeHidden();
});
});
+118
View File
@@ -0,0 +1,118 @@
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
import {
createContributor,
createRepo,
radArtifact,
registerArtifact,
syncFrom,
} from "@tests/support/repo.js";
import { releaseApiOnlyLocal, useLocalHttpd } from "@tests/support/support.js";
test.describe(() => {
test.skip(!useLocalHttpd, releaseApiOnlyLocal);
// Release fixtures need several rad-artifact commands, which takes longer
// than the default per-test budget.
test.describe.configure({ timeout: 30_000 });
test("navigate release listing", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "release-listing",
});
await registerArtifact(peer, repoFolder, { name: "binary" });
await page.goto(`${peer.uiUrl()}/${rid}`);
await page.getByRole("link", { name: "Releases 1" }).click();
await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/releases`);
// The release COB has no title; it falls back to the commit summary.
await expect(
page.locator(".release-teaser").getByText("initial commit"),
).toBeVisible();
});
test("filter releases by author", async ({ page, peer, peerManager }) => {
const name = "release-authors";
const { rid, repoFolder } = await createRepo(peer, { name });
await registerArtifact(peer, repoFolder, { name: "delegate-binary" });
// Eve isn't a delegate, so her release is out of scope by default.
const { contributor, repoFolder: contributorFolder } =
await createContributor(peerManager, peer, { rid, repoName: name });
// A release is keyed by a commit, so Eve needs a commit of her own in
// storage to open one.
await contributor.git(
["commit", "--allow-empty", "--message", "eve commit"],
{ cwd: contributorFolder },
);
await contributor.git(["push", "rad", "HEAD:refs/heads/eve"], {
cwd: contributorFolder,
});
const { stdout: eveHead } = await contributor.git(["rev-parse", "HEAD"], {
cwd: contributorFolder,
});
await registerArtifact(contributor, contributorFolder, {
name: "eve-binary",
revision: eveHead,
});
await syncFrom(peer, repoFolder);
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.locator(".release-teaser")).toHaveCount(1);
await expect(page.getByText("eve commit")).toBeHidden();
await page.getByRole("link", { name: "All 2" }).click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases?allAuthors=true`,
);
await expect(page.locator(".release-teaser")).toHaveCount(2);
await expect(page.getByText("eve commit")).toBeVisible();
await page.getByRole("link", { name: "Delegates 1" }).click();
await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.locator(".release-teaser")).toHaveCount(1);
});
test("empty release listing", async ({ page, peer }) => {
const { rid } = await createRepo(peer, { name: "no-releases" });
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.getByText("No releases")).toBeVisible();
});
test("hide fully redacted releases", async ({ page, peer }) => {
const { rid, repoFolder } = await createRepo(peer, {
name: "redacted-release",
});
const { releaseId, cid } = await registerArtifact(peer, repoFolder, {
name: "binary",
});
await radArtifact(peer, repoFolder, [
"redact",
"--release",
releaseId,
"--cid",
cid,
"--reason",
"Compromised build",
]);
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.getByText("No releases")).toBeVisible();
// The tab counter comes from the repo metadata, which counts every release
// COB, redacted or not.
await expect(page.getByRole("link", { name: "Releases 1" })).toBeVisible();
});
});
test("releases are unavailable on older nodes", async ({ page }) => {
test.skip(useLocalHttpd, "older nodes are the pre-built httpd release");
await page.goto(cobUrl);
await expect(page.getByRole("link", { name: /^Releases/ })).toBeHidden();
await page.goto(`${cobUrl}/releases`);
await expect(
page.getByText("Releases are not available on this node"),
).toBeVisible();
});
+9
View File
@@ -109,6 +109,9 @@ test.describe("repo page navigation", () => {
await page.getByText(".hidden").click();
await expect(page).toHaveURL(`${repoTreeURL}/.hidden`);
// Wait for the blob, not just the URL: clicking a source tree that is
// about to be re-rendered loses the click.
await expect(page.getByText("I'm a hidden file.")).toBeVisible();
await page.getByText("bin").click();
await page.getByText("true").click();
@@ -154,6 +157,9 @@ test.describe("repo page navigation", () => {
await page.getByText(".hidden").click();
await expect(page).toHaveURL(`${repoTreeURL}/tree/.hidden`);
// Wait for the blob, not just the URL: clicking a source tree that is
// about to be re-rendered loses the click.
await expect(page.getByText("I'm a hidden file.")).toBeVisible();
await page.getByText("bin").click();
await page.getByText("true").click();
@@ -178,6 +184,9 @@ test.describe("repo page navigation", () => {
await page.getByText(".hidden").click();
await expect(page).toHaveURL(`${repoTreeURL}/.hidden`);
// Wait for the blob, not just the URL: clicking a source tree that is
// about to be re-rendered loses the click.
await expect(page.getByText("I'm a hidden file.")).toBeVisible();
await page.getByText("bin").click();
await page.getByText("true").click();
+6 -1
View File
@@ -104,12 +104,17 @@ export const test = base.extend<{
await logFile.close();
},
peerManager: async ({ stateDir, outputLog }, use) => {
peerManager: async ({ stateDir, outputLog, page }, use) => {
const peerManager = await createPeerManager({
dataDir: Path.resolve(Path.join(stateDir, "peers")),
outputLog,
});
await use(peerManager);
// A test can end with requests still in flight, because the router pushes
// the URL before the route finishes loading. Close the page before the
// nodes go away, so those requests don't fail against a dead httpd and
// trip the page error handler above after the test has already passed.
await page.close();
await peerManager.shutdown();
},
+20 -3
View File
@@ -3,6 +3,7 @@ import * as Path from "node:path";
import {
assertBinariesInstalled,
heartwoodRelease,
radicleArtifactRelease,
radicleHttpdRelease,
removeWorkspace,
tmpDir,
@@ -25,6 +26,12 @@ const heartwoodBinaryPath = Path.join(
"heartwood",
heartwoodRelease,
).trim();
const artifactBinaryPath = Path.join(
tmpDir,
"bin",
"artifact",
radicleArtifactRelease,
).trim();
const httpdBinaryPath = useLocalHttpd
? Path.join(tmpDir, "bin", "httpd", "local").trim()
: Path.join(tmpDir, "bin", "httpd", radicleHttpdRelease).trim();
@@ -32,6 +39,7 @@ const httpdBinaryPath = useLocalHttpd
process.env.PATH = [
heartwoodBinaryPath,
httpdBinaryPath,
artifactBinaryPath,
process.env.PATH,
].join(Path.delimiter);
@@ -43,15 +51,20 @@ export default async function globalSetup(): Promise<() => void> {
useLocalHttpd ? "pre-release" : radicleHttpdRelease,
httpdBinaryPath,
);
await assertBinariesInstalled(
"rad-artifact",
radicleArtifactRelease,
artifactBinaryPath,
);
} catch (error) {
console.error(error);
console.log("");
console.log("To download the required test binaries, run:");
console.log(" 👉 ./scripts/install-binaries");
if (useLocalHttpd) {
console.log("");
console.log("To compile local radicle-httpd binary, run:");
console.log(" 👉 ./scripts/compile-local-httpd");
} else {
console.log("To download the required test binaries, run:");
console.log(" 👉 ./scripts/install-binaries");
}
console.log("");
process.exit(1);
@@ -172,6 +185,9 @@ export default async function globalSetup(): Promise<() => void> {
"--version",
]);
const { stdout: httpdVersion } = await exec("radicle-httpd", ["--version"]);
const { stdout: radArtifactVersion } = await exec("rad-artifact", [
"--version",
]);
// radicle-httpd outputs logging lines, extract just the version line (last line)
const httpdVersionClean =
httpdVersion.trim().split("\n").pop() || httpdVersion;
@@ -182,6 +198,7 @@ export default async function globalSetup(): Promise<() => void> {
console.log(
` radicle-httpd: ${httpdVersionClean}${useLocalHttpd ? " (local)" : ""}`,
);
console.log(` rad-artifact: ${radArtifactVersion.trim()}`);
console.log("");
return async () => {
+1
View File
@@ -0,0 +1 @@
0.18.0
+81 -1
View File
@@ -1,8 +1,11 @@
import type { Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager";
import type { PeerManager, RadiclePeer } from "@tests/support/peerManager";
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
import { defaultConfig, gitOptions } from "@tests/support/fixtures.js";
export async function changeBranch(peer: string, branch: string, page: Page) {
await page.locator('[title="Change branch"]:visible').first().click();
const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
@@ -63,6 +66,83 @@ export async function createRepo(
return { rid, repoFolder, defaultBranch };
}
// Run a rad-artifact command without prompts or network announcements.
export function radArtifact(
peer: RadiclePeer,
repoFolder: string,
args: string[],
) {
return peer.spawn("rad-artifact", ["--no-announce", "--no-input", ...args], {
cwd: repoFolder,
});
}
// Add a peer that has cloned the repo but is not a delegate, so tests can
// create releases and artifacts from a non-delegate author. Its contributions
// live in its own storage; pull them into the delegate's storage, which the UI
// reads, with `syncFrom`. The contributor is always Eve, which is safe because
// `peerManager` is scoped to a single test.
export async function createContributor(
peerManager: PeerManager,
delegate: RadiclePeer,
{ rid, repoName }: { rid: string; repoName: string },
): Promise<{ contributor: RadiclePeer; repoFolder: string }> {
const contributor = await peerManager.createPeer({
name: "eve",
gitOptions: gitOptions["eve"],
});
await contributor.startNode({
node: { ...defaultConfig.node, connect: [delegate.address], alias: "eve" },
});
await contributor.rad(["clone", rid], { cwd: contributor.checkoutPath });
return {
contributor,
repoFolder: Path.join(contributor.checkoutPath, repoName),
};
}
// 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.
export async function syncFrom(peer: RadiclePeer, repoFolder: string) {
await peer.rad(["sync", "--fetch"], { cwd: repoFolder });
}
// Write a file and register it as a release artifact. The file is named after
// the artifact and the CID is computed from its contents, which default to the
// name, so artifacts in one release need distinct names to get distinct CIDs.
// The release is created when `release` is not given.
export async function registerArtifact(
peer: RadiclePeer,
repoFolder: string,
{
name,
content = `${name}\n`,
revision = "HEAD",
release,
}: {
name: string;
content?: string;
revision?: string;
release?: string;
},
): Promise<{ releaseId: string; cid: string }> {
const artifactPath = Path.join(repoFolder, name);
await Fs.writeFile(artifactPath, content);
const { stdout } = await radArtifact(peer, repoFolder, [
"register",
artifactPath,
...(release ? ["--release", release] : ["--revision", revision]),
"--name",
name,
"--json",
]);
return JSON.parse(stdout);
}
export function extractPatchId(cmdOutput: { stderr: string }) {
const match = cmdOutput.stderr.match(/[0-9a-f]{40}/);
if (match) {
+13 -1
View File
@@ -33,15 +33,27 @@ export const radicleHttpdRelease = (
await Fs.readFile(`${supportDir}/radicle-httpd-release`, "utf8")
).trim();
export const radicleArtifactRelease = (
await Fs.readFile(`${supportDir}/radicle-artifact-release`, "utf8")
).trim();
export const useLocalHttpd = process.env.USE_LOCAL_HTTPD === "true";
export const releaseApiOnlyLocal =
"the release read API only exists in the local httpd build";
// Assert that binaries are installed and are the correct version.
export async function assertBinariesInstalled(
binary: string,
expectedVersion: string,
expectedPath: string,
): Promise<void> {
const { stdout: which } = await execa("which", [binary]);
let which: string;
try {
({ stdout: which } = await execa("which", [binary]));
} catch {
throw new Error(`${binary} is not installed`);
}
if (Path.dirname(which) !== expectedPath) {
throw new Error(
`${binary} path doesn't match used ${binary} binary: ${expectedPath} !== ${which}`,