rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwoodfd089ac76524436f905e65d9ed2719c31e5681c3
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "a741d6b38d0b064572848a897707e8d31688c969",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"title": "httpd: Add fuzzy search endpoint for repositories",
"state": {
"status": "archived",
"conflicts": []
},
"before": "a4e10f034490d2b691b3dfefaa2f1c83e3cb7c03",
"after": "fd089ac76524436f905e65d9ed2719c31e5681c3",
"commits": [
"fd089ac76524436f905e65d9ed2719c31e5681c3"
],
"target": "6cfed884bf37cba1e0d8e97fa8b0e94df4a04b1f",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "a741d6b38d0b064572848a897707e8d31688c969",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Adds `/api/v1/projects/search?q=<query>` endpoint.\nAccepts queries for the name or the description, does a fuzzy search and returns any found results.\n\n- Excludes any private repos from search.\n- Sorts the output by max score obtained in the search.\n- Excludes repos that did not have any match.\n- Caches the response during one hour.",
"base": "51e64cfa8b291d87cd055d46759be1d7f2c164b8",
"oid": "9321820189a27962c360b431743a45adb9b382cd",
"timestamp": 1712322899
},
{
"id": "22e578cbc2dff7d03400e0c30b32c84468f5bf5c",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Simplify search to substring match, sort by who is closer to the beginning of the repo name.",
"base": "51e64cfa8b291d87cd055d46759be1d7f2c164b8",
"oid": "d53acab1bd73d788bb0ef0770466630432855f7b",
"timestamp": 1712350666
},
{
"id": "875116129cd6d76a98ca9dc23b2766b5d4a8f113",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Reduce amount of max-age in cache headers",
"base": "51e64cfa8b291d87cd055d46759be1d7f2c164b8",
"oid": "96ded7d0449262f0662d732fc9c4949e4c09f7d9",
"timestamp": 1712352594
},
{
"id": "6cf33eb7f327a469d51e38119918e7808cb60582",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Fix tests",
"base": "51e64cfa8b291d87cd055d46759be1d7f2c164b8",
"oid": "38346e1f6033a5d9fedd301ed96fbafd2be14790",
"timestamp": 1712352820
},
{
"id": "708ba06d8ab6a228b33627c40fb6d0d511f36eb3",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "",
"base": "bd8e0ebcda8f6f06dc20641a71614e3778a43fea",
"oid": "fe6912f78537b3a4aab15fb01619d280b32e086a",
"timestamp": 1712563670
},
{
"id": "cd2384b00b5e311b4f0ecfafc271575c073807bf",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Improve result sorting.\n\nGoes for results that start with the same query, and else looks at the seeding count",
"base": "bd8e0ebcda8f6f06dc20641a71614e3778a43fea",
"oid": "9e4bae57b02c5c5380f873bc6bf99e646d26ce77",
"timestamp": 1712567002
},
{
"id": "046335ff6ddc3bdc80e26817a9d11808584a63c0",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Rename seeding to seeds and add alias to returned delegates",
"base": "a4e10f034490d2b691b3dfefaa2f1c83e3cb7c03",
"oid": "fd089ac76524436f905e65d9ed2719c31e5681c3",
"timestamp": 1715948962
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "facaa101-0931-4ead-95c4-302ec6f3a51d"
},
"info_url": "https://cci.rad.levitte.org//facaa101-0931-4ead-95c4-302ec6f3a51d.html"
}
Started at: 2025-10-21 18:37:36.563720+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/facaa101-0931-4ead-95c4-302ec6f3a51d/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 125 issues · 15 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout a741d6b38d0b064572848a897707e8d31688c969
✓ Switched to branch patch/a741d6b at revision 046335f
✓ Branch patch/a741d6b setup to track rad/patches/a741d6b38d0b064572848a897707e8d31688c969
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout fd089ac76524436f905e65d9ed2719c31e5681c3
HEAD is now at fd089ac7 httpd: Add repo name search endpoint
Exit code: 0
$ git show fd089ac76524436f905e65d9ed2719c31e5681c3
commit fd089ac76524436f905e65d9ed2719c31e5681c3
Author: Sebastian Martinez <me@sebastinez.dev>
Date: Fri Apr 5 15:02:55 2024 +0200
httpd: Add repo name search endpoint
Queries the name with an input query.
- Excludes any private repos from search.
- Sorts by, if the name starts with the query or else by seeding
count.
- Excludes repos that did not have any match.
- Caches the response during one hour.
diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs
index 9efa7052..b548dac7 100644
--- a/radicle-httpd/src/api/v1/projects.rs
+++ b/radicle-httpd/src/api/v1/projects.rs
@@ -1,23 +1,21 @@
use std::collections::{BTreeMap, HashMap};
use axum::extract::{DefaultBodyLimit, State};
-use axum::handler::Handler;
-use axum::http::{header, HeaderValue};
-use axum::response::{IntoResponse, Response};
+use axum::response::IntoResponse;
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
+use nonempty::NonEmpty;
use radicle_surf::blob::BlobRef;
use serde::{Deserialize, Serialize};
use serde_json::json;
-use tower_http::set_header::SetResponseHeaderLayer;
use radicle::cob::{
issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Author,
Embed, Label, Uri,
};
-use radicle::identity::{Did, RepoId};
+use radicle::identity::{Did, Project, RepoId};
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Node, NodeId};
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
@@ -26,32 +24,19 @@ use radicle_surf::{diff, Glob, Oid, Repository};
use crate::api::error::Error;
use crate::api::project::Info;
use crate::api::{self, announce_refs, CobsQuery, Context, PaginationQuery, ProjectQuery};
-use crate::axum_extra::{immutable_response, Path, Query};
+use crate::axum_extra::{cached_response, immutable_response, Path, Query};
-const CACHE_1_HOUR: &str = "public, max-age=3600, must-revalidate";
const MAX_BODY_LIMIT: usize = 4_194_304;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/projects", get(project_root_handler))
+ .route("/projects/search", get(project_search_handler))
.route("/projects/:project", get(project_handler))
.route("/projects/:project/commits", get(history_handler))
.route("/projects/:project/commits/:sha", get(commit_handler))
.route("/projects/:project/diff/:base/:oid", get(diff_handler))
- .route(
- "/projects/:project/activity",
- get(
- activity_handler.layer(SetResponseHeaderLayer::if_not_present(
- header::CACHE_CONTROL,
- |response: &Response| {
- response
- .status()
- .is_success()
- .then_some(HeaderValue::from_static(CACHE_1_HOUR))
- },
- )),
- ),
- )
+ .route("/projects/:project/activity", get(activity_handler))
.route("/projects/:project/tree/:sha/", get(tree_handler_root))
.route("/projects/:project/tree/:sha/*path", get(tree_handler))
.route(
@@ -169,6 +154,92 @@ async fn project_root_handler(
Ok::<_, Error>(Json(infos))
}
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SearchQueryString {
+ pub q: Option<String>,
+ pub page: Option<usize>,
+ pub per_page: Option<usize>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct SearchResult {
+ pub rid: RepoId,
+ #[serde(flatten)]
+ pub payload: Project,
+ pub delegates: NonEmpty<serde_json::Value>,
+ pub seeds: usize,
+ #[serde(skip)]
+ pub index: usize,
+}
+
+/// Search repositories by name.
+/// `GET /projects/search?q=<query>`
+///
+/// We obtain the byte index of the first character of the query that matches the repo name.
+/// And skip if the query doesn't match the repo name.
+///
+/// Sorting algorithm:
+/// If both byte indices are 0, compare by seeding count.
+/// A repo name with a byte index of 0 should come before non-zero indices.
+/// If both indices are non-zero and equal, then compare by seeding count.
+/// If none of the above, all non-zero indices are compared by their seeding count primarily.
+async fn project_search_handler(
+ State(ctx): State<Context>,
+ Query(qs): Query<SearchQueryString>,
+) -> impl IntoResponse {
+ let SearchQueryString { q, per_page, page } = qs;
+ let q = q.unwrap_or_default();
+ let page = page.unwrap_or(0);
+ let per_page = per_page.unwrap_or(10);
+ let storage = &ctx.profile.storage;
+ let aliases = &ctx.profile.aliases();
+ let db = &ctx.profile.database()?;
+ let mut found_repos = storage
+ .repositories()?
+ .into_iter()
+ .filter_map(|info| {
+ if info.doc.visibility.is_private() {
+ return None;
+ }
+ let payload = info.doc.project().ok()?;
+ let index = payload.name().find(&q)?;
+ let seeds = db.count(&info.rid).unwrap_or_default();
+ let delegates = info.doc.delegates.map(|did| match aliases.alias(&did) {
+ Some(alias) => json!({
+ "id": did,
+ "alias": alias,
+ }),
+ None => json!({
+ "id": did,
+ }),
+ });
+
+ Some(SearchResult {
+ index,
+ seeds,
+ payload,
+ rid: info.rid,
+ delegates,
+ })
+ })
+ .collect::<Vec<_>>();
+ found_repos.sort_by(|a, b| match (a.index, b.index) {
+ (0, 0) => b.seeds.cmp(&a.seeds),
+ (0, _) => std::cmp::Ordering::Less,
+ (_, 0) => std::cmp::Ordering::Greater,
+ (ai, bi) if ai == bi => b.seeds.cmp(&a.seeds),
+ (_, _) => b.seeds.cmp(&a.seeds),
+ });
+ let found_repos = found_repos
+ .into_iter()
+ .skip(page * per_page)
+ .take(per_page)
+ .collect::<Vec<_>>();
+
+ Ok::<_, Error>(cached_response(found_repos, "600").into_response())
+}
+
/// Get project metadata.
/// `GET /projects/:project`
async fn project_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
@@ -407,7 +478,7 @@ async fn activity_handler(
})
.collect::<Vec<i64>>();
- Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps }))))
+ Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), "3600"))
}
/// Get project source tree for '/' path.
@@ -1140,6 +1211,33 @@ mod routes {
);
}
+ #[tokio::test]
+ async fn test_search_projects() {
+ let tmp = tempfile::tempdir().unwrap();
+ let app = super::router(seed(tmp.path()));
+ let response = get(&app, format!("/projects/search?q=he")).await;
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.json().await,
+ json!([
+ {
+ "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
+ "name": "hello-world",
+ "description": "Rad repository for tests",
+ "defaultBranch": "master",
+ "delegates": [
+ {
+ "id": DID,
+ "alias": CONTRIBUTOR_ALIAS,
+ }
+ ],
+ "seeds": 0
+ }
+ ])
+ );
+ }
+
#[tokio::test]
async fn test_projects_not_found() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/radicle-httpd/src/axum_extra.rs b/radicle-httpd/src/axum_extra.rs
index 3bdfcb6a..370226ee 100644
--- a/radicle-httpd/src/axum_extra.rs
+++ b/radicle-httpd/src/axum_extra.rs
@@ -97,3 +97,15 @@ pub fn immutable_response(data: impl serde::Serialize) -> impl IntoResponse {
Json(data),
)
}
+
+/// Add a Cache-Control header that marks the response as must-revalidate and
+/// instructs clients to cache the response for 1 hour.
+pub fn cached_response(data: impl serde::Serialize, max_age: &str) -> impl IntoResponse {
+ (
+ [(
+ header::CACHE_CONTROL,
+ format!("public, max-age={max_age}, must-revalidate"),
+ )],
+ Json(data),
+ )
+}
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny clippy::all cargo build --all-targets --workspace cargo doc --workspace cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name facaa101-0931-4ead-95c4-302ec6f3a51d -v /opt/radcis/ci.rad.levitte.org/cci/state/facaa101-0931-4ead-95c4-302ec6f3a51d/s:/facaa101-0931-4ead-95c4-302ec6f3a51d/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/facaa101-0931-4ead-95c4-302ec6f3a51d/w:/facaa101-0931-4ead-95c4-302ec6f3a51d/w -w /facaa101-0931-4ead-95c4-302ec6f3a51d/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /facaa101-0931-4ead-95c4-302ec6f3a51d/s/script.sh
+ cargo --version
info: syncing channel updates for '1.77-x86_64-unknown-linux-gnu'
info: latest update on 2024-04-09, rust version 1.77.2 (25ef9e3d8 2024-04-09)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
cargo 1.77.2 (e52e36006 2024-03-26)
+ rustc --version
rustc 1.77.2 (25ef9e3d8 2024-04-09)
+ cargo fmt --check
error: 'cargo-fmt' is not installed for the toolchain '1.77-x86_64-unknown-linux-gnu'.
To install, run `rustup component add --toolchain 1.77-x86_64-unknown-linux-gnu rustfmt`
Exit code: 1
{
"response": "finished",
"result": "failure"
}