//! What the appview answers from its index. use crate::index::CheckFilter; use crate::{App, AppError}; use axum::Json; use axum::extract::{Query, State}; use axum::http::StatusCode; use knotless::records::RepoRecord; use serde::Deserialize; use serde_json::{Value, json}; use std::sync::Arc; fn index(app: &App) -> Result<&crate::index::Index, AppError> { app.index .as_deref() .ok_or_else(|| AppError(StatusCode::NOT_FOUND, "this server keeps no index".into())) } #[derive(Deserialize)] pub struct ForgeParams { repo: String, key: String, limit: Option, cursor: Option, } /// The pulls, issues, comments and state changes about a repository: the /// one its owner made with `key`, whatever it has been named. A page at a /// time, by URI. pub async fn get_forge( State(app): State>, Query(p): Query, ) -> Result, AppError> { let did = knotless::forge::parse_uri(&p.repo) .map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))? .0; let mut entries = index(&app)? .forge(did, &p.key) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; entries.sort_by(|a, b| a.uri.cmp(&b.uri)); let after = p.cursor.unwrap_or_default(); let limit = p.limit.unwrap_or(500).clamp(1, 1000); let mut page: Vec<_> = entries.into_iter().filter(|e| e.uri > after).collect(); let more = page.len() > limit; page.truncate(limit); let mut out = json!({ "entries": page }); if more { out["cursor"] = page[limit - 1].uri.clone().into(); } Ok(Json(out)) } #[derive(Deserialize)] pub struct ChecksParams { repo: String, key: String, #[serde(rename = "ref")] refname: Option, commit: Option, pull: Option, limit: Option, cursor: Option, } /// The checks of a repository by the runners its record names, as the /// index has that record now: newest first, a page at a time. pub async fn list_checks( State(app): State>, Query(p): Query, ) -> Result, AppError> { let did = knotless::forge::parse_uri(&p.repo) .map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))? .0; let index = index(&app)?; let name = index .name(did, &p.key) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let uri = knotless::forge::repo_uri(did, name.as_deref().unwrap_or_default()); let record = index .repo(&uri) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let runners: Vec = record .and_then(|v| serde_json::from_value::(v).ok()) .map(|r| r.runners.into_iter().map(|r| r.did).collect()) .unwrap_or_default(); let filter = CheckFilter { refname: p.refname.as_deref(), commit: p.commit.as_deref(), pull: p.pull.as_deref(), ..CheckFilter::default() }; let limit = p.limit.unwrap_or(50).clamp(1, 100); let (checks, cursor) = index .checks(did, &p.key, &runners, filter, limit, p.cursor.as_deref()) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; if let Some(index) = &app.index { app.recheck(index, &checks); } let checks: Vec = checks .into_iter() .map(|e| json!({"uri": e.uri, "runner": e.author, "value": e.value})) .collect(); let mut out = json!({ "checks": checks }); if let Some(cursor) = cursor { out["cursor"] = cursor.into(); } Ok(Json(out)) } /// What this appview offers the clients that use it: the runner a new /// public repository names unless its owner names others, and the oldest /// client version and iOS build it asks for. pub async fn describe_server(State(app): State>) -> Json { let runners = app.default_runner.iter().map(|did| json!({"did": did})); let mut out = json!({"runners": runners.collect::>()}); if let Some((minimum, reason)) = &app.client_minimum { out["client"] = json!({"minimum": minimum}); if let Some(reason) = reason { out["client"]["reason"] = reason.as_str().into(); } } if let Some((build, reason)) = &app.ios_minimum { out["ios"] = json!({"minimumBuild": build}); if let Some(reason) = reason { out["ios"]["reason"] = reason.as_str().into(); } } Json(out) } #[derive(Deserialize)] pub struct ListParams { q: Option, runner: Option, triager: Option, topic: Option, limit: Option, cursor: Option, } /// Repositories, newest first, matching `q` by name or description and /// naming `runner` among their runners, `triager` among their triagers /// and `topic` among their topics, a page at a time. The first page of a /// search with none of these starts with the repositories named `q` /// ([`Index::named`](crate::index::Index::named)), originals before forks; /// asked as an account, its own of those first, and before them the /// private repositories it may read here that match ([`readable`]), and /// after them its other public ones that match. Each kind put first, when /// all of it fits on that page, no page lists again. pub async fn list_repos( State(app): State>, axum::Extension(viewer): axum::Extension, Query(p): Query, ) -> Result, AppError> { let limit = p.limit.unwrap_or(50).clamp(1, 500); let q = p.q.as_deref().unwrap_or_default(); let named = p.runner.is_some() || p.triager.is_some() || p.topic.is_some(); let first = p.cursor.is_none() && !named; let index = index(&app)?; let (exact, own, not_named, not_owner) = first_of(index, q, limit, named, viewer.0.as_deref())?; let (repos, cursor) = index .repos_page( q, limit, p.cursor.as_deref(), crate::index::Among { not_named, not_owner, runner: p.runner.as_deref(), triager: p.triager.as_deref(), topic: p.topic.as_deref(), ..Default::default() }, ) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let private = match viewer.0.filter(|_| first).zip(app.spaces.as_deref()) { Some((viewer, spaces)) => readable(&app, spaces, &viewer, q, limit), None => Vec::new(), }; let marked = private.len(); let put = first.then(|| exact.into_iter().chain(own)); let mut seen = std::collections::HashSet::new(); let public = put.into_iter().flatten().chain(repos); let public = public.filter(|r| seen.insert(r.uri.clone())); let repos: Vec<_> = private.into_iter().chain(public).collect(); let owners = repos.iter().map(|r| crate::web::actor(&app, &r.did)); let owners = futures::future::join_all(owners).await; let repos: Vec = repos .into_iter() .zip(owners) .enumerate() .map(|(i, (r, owner))| { let mut repo = json!(r); repo["owner"] = json!(owner); if i < marked { repo["private"] = true.into(); } repo }) .collect(); let mut out = json!({ "repos": repos }); if let Some(cursor) = cursor { out["cursor"] = cursor.into(); } Ok(Json(out)) } /// What a search puts first: the repositories named as searched, the /// viewer's own that match, then the name and the owner the rest leaves /// out. type First<'a> = ( Vec, Vec, Option<&'a str>, Option<&'a str>, ); /// What a search for `q` puts first, as [`list_repos`] says, at most /// `limit` of each kind: the repositories named `q`, `viewer`'s first, /// and `viewer`'s other ones that match; then the name and the owner /// whose repositories the rest leaves out, each only when all of its kind /// fit. None for no search, or one `named` by runner, triager or topic. fn first_of<'a>( index: &crate::index::Index, q: &'a str, limit: usize, named: bool, viewer: Option<&'a str>, ) -> Result, AppError> { if q.is_empty() || named { return Ok((Vec::new(), Vec::new(), None, None)); } let mut exact = index .named(q, limit + 1) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let all_named = exact.len() <= limit; exact.truncate(limit); // Stable: the viewer's own first, each kind as it was. exact.sort_by_key(|r| Some(r.did.as_str()) != viewer); let mut own = match viewer { Some(viewer) => { let among = crate::index::Among { owner: Some(viewer), ..Default::default() }; let (page, _) = index .repos_page(q, limit + 1, None, among) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; page } None => Vec::new(), }; let all_own = own.len() <= limit; own.truncate(limit); let not_named = all_named.then_some(q); Ok((exact, own, not_named, viewer.filter(|_| all_own))) } /// How many private repositories [`readable`] looks through for those its /// viewer may read. const READABLE: usize = 500; /// The private repositories, newest first and at most `limit`, whose name or /// description holds `q` and which `viewer` may read here, as this server's /// index of their spaces has them: their own, and those they were proven a /// member of, as they read one or as they are asked after in the /// background ([`crate::spaces::discover`]), which also looks for their /// own yet to be found, for a look after. fn readable( app: &Arc, spaces: &crate::index::Index, viewer: &str, q: &str, limit: usize, ) -> Vec { crate::spaces::discover(app, viewer); let found = spaces.repos(q, READABLE).unwrap_or_default(); let theirs = spaces.read_of(viewer).unwrap_or_default(); let shown: Vec<_> = found .into_iter() .filter(|r| r.did == viewer || theirs.contains(&r.uri)) .take(limit) .collect(); if !shown.is_empty() { crate::private::touched(); } shown } #[derive(Deserialize)] pub struct OwnersParams { q: String, limit: Option, } /// The accounts owning repositories whose handle holds `q`, ignoring case: /// those it starts with first, then the one with the newest repository. /// Owners whose handles were never looked up are looked up first, together, /// as pages look them up, and kept: once each. pub async fn search_owners( State(app): State>, Query(p): Query, ) -> Result, AppError> { let limit = p.limit.unwrap_or(10).clamp(1, 50); let index = index(&app)?; let unnamed = index.unnamed_owners().map_err(anyhow::Error::from); let naming = unnamed.map_err(AppError::internal)?; let naming = naming.iter().map(|did| crate::web::handle(&app, did)); futures::future::join_all(naming).await; let found = index.owners(p.q.trim(), limit).map_err(anyhow::Error::from); let found = found.map_err(AppError::internal)?; let shown = found.iter().map(|(did, ..)| crate::web::actor(&app, did)); let shown = futures::future::join_all(shown).await; let owners: Vec = shown .into_iter() .zip(found) .map(|(owner, (.., repos))| json!({ "owner": owner, "repoCount": repos })) .collect(); Ok(Json(json!({ "owners": owners }))) }