//! Shared steps, as the index keeps what repositories share //! ([`crate::shared`]): `dev.knotless.listSteps`, the directory, and //! `dev.knotless.getStep`, one step with its versions and who uses it. use super::repo::invalid; use super::*; use crate::index::{STEP, Shared, Version}; use knotless::records::ReleaseRecord; use serde_json::Value; /// Steps a page of the directory shows unless asked for more or fewer. const PAGE: u32 = 50; /// The most repositories using a step that its page names. const USED_BY: u32 = 30; /// `dev.knotless.defs#stepView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StepView { uri: String, name: String, repo: RepoViewBasic, #[serde(skip_serializing_if = "Option::is_none")] description: Option, version: String, secret_count: usize, use_count: u64, created_at: String, } /// `dev.knotless.defs#stepViewDetailed`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StepViewDetailed { uri: String, name: String, repo: RepoViewBasic, #[serde(skip_serializing_if = "Option::is_none")] description: Option, version: String, commit: String, path: String, uses: String, inputs: Vec, needs: Vec, secrets: Vec, versions: Vec, use_count: u64, used_by: Vec, created_at: String, } /// `dev.knotless.defs#stepCommandView` and `#stepSecretView`. #[derive(Serialize)] pub struct Named { name: String, } /// `dev.knotless.defs#stepVersionView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StepVersionView { uri: String, version: String, commit: String, created_at: String, } /// A step's versions, the highest first; a version that is not one /// last. fn highest_first(versions: &mut [Version]) { let order = |r: &ReleaseRecord| std::cmp::Reverse(knotless::release::version(&r.version).ok()); versions.sort_by_key(|a| order(&a.1)); } /// The version shown as a step's: its highest with no pre-release part, /// or its highest when each has one, of those not yanked. `versions` are /// the highest first. fn latest(versions: &[Version]) -> Option<&Version> { let release = |v: &&Version| knotless::release::version(&v.1.version).is_ok_and(|s| s.pre.is_empty()); let mut live = versions.iter().filter(|v| !v.1.yanked); live.clone().find(release).or(live.next()) } /// What a workflow's `uses` names `name` of `owner`'s repository `repo` /// by to take `version`'s major version: before 1.0.0, its minor one. fn uses(owner: &str, repo: &str, name: &str, version: &str) -> String { let spec = match knotless::release::version(version) { Ok(v) if v.major == 0 => format!("0.{}", v.minor), Ok(v) => v.major.to_string(), Err(_) => version.to_string(), }; format!("{owner}/{repo}/{name}@{spec}") } /// What a step file says, as [`crate::shared`] kept it, by key. fn said(value: &Value, key: &str) -> Vec { let names = value[key].as_array().into_iter().flatten(); names .filter_map(|n| n.as_str().map(str::to_string)) .collect() } /// The index, or why there are no steps without one. fn indexed(app: &App) -> Result<&Index> { let why = || Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()); app.index.as_deref().ok_or_else(why) } /// The owner and key of the repository `uri` names, its /// `dev.knotless.repo` record by handle or DID. async fn repo_of(app: &App, index: &Index, uri: &str) -> Result<(String, String)> { let at = knotless::aturi::AtUri::parse(uri).ok(); let Some(at) = at.filter(|at| at.collection == nsid::REPO) else { let why = format!("{uri} is not at:///{}/", nsid::REPO); return Err(invalid(why)); }; let name = &at.rkey; let id = knotless::identity::resolve(&app.ctx, &at.author).await; let did = id.map_err(not_found)?.did; let key = index.key_named(&did, name).ok().flatten(); let key = key.ok_or_else(|| not_found(anyhow::anyhow!("no repository {uri}")))?; Ok((did, key)) } /// `thing`'s repository, as it is shown: none when the index lacks it. async fn repo_view(app: &App, index: &Index, thing: &Shared) -> Option { let name = index.name(&thing.did, &thing.key).ok().flatten()?; Some(RepoViewBasic { did: thing.did.clone(), handle: handle(app, &thing.did).await, name, }) } /// `thing`, at its latest version, as the directory shows it: none when /// it has no version that is one. async fn view(app: &App, index: &Index, thing: Shared) -> Option { let mut versions = index.shared_versions(&thing).ok()?; highest_first(&mut versions); let (uri, record, value) = latest(&versions)?; let (use_count, _) = index.users(&thing, 0).ok()?; Some(StepView { uri: uri.clone(), description: value["description"].as_str().map(str::to_string), version: record.version.clone(), secret_count: said(value, "secrets").len(), use_count, created_at: record.created_at.clone(), repo: repo_view(app, index, &thing).await?, name: thing.name, }) } #[derive(Deserialize)] pub struct ListParams { repo: Option, q: Option, limit: Option, cursor: Option, } #[derive(Serialize)] pub struct Steps { steps: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } /// `dev.knotless.listSteps`. pub(super) async fn list_steps( State(app): State>, Query(p): Query, ) -> Result { let index = indexed(&app)?; let repo = match &p.repo { Some(uri) => Some(repo_of(&app, index, uri).await?), None => None, }; let limit = p.limit.unwrap_or(PAGE).clamp(1, 100); let prefix = p.q.as_deref().unwrap_or_default().trim(); let of = repo.as_ref().map(|(did, key)| (did.as_str(), key.as_str())); let page = index.shared(STEP, of, prefix, limit, p.cursor.as_deref()); let (found, cursor) = page .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let each = found.into_iter().map(|thing| view(&app, index, thing)); let steps = futures::future::join_all(each).await; Ok(answer(Steps { steps: steps.into_iter().flatten().collect(), cursor, })) } #[derive(Deserialize)] pub struct GetParams { repo: String, name: String, } /// `dev.knotless.getStep`. pub(super) async fn get_step( State(app): State>, Query(p): Query, ) -> Result { let index = indexed(&app)?; let (did, key) = repo_of(&app, index, &p.repo).await?; let thing = Shared { kind: STEP.into(), did, key, name: p.name, }; let none = || not_found(anyhow::anyhow!("{} has no step {}", p.repo, thing.name)); let mut versions = index.shared_versions(&thing).unwrap_or_default(); highest_first(&mut versions); let (uri, record, value) = latest(&versions).ok_or_else(none)?.clone(); let repo = repo_view(&app, index, &thing).await.ok_or_else(none)?; let (use_count, users) = index.users(&thing, USED_BY).unwrap_or_default(); let app = &app; let each = users.into_iter().map(|(did, name)| async move { RepoViewBasic { handle: handle(app, &did).await, did, name, } }); let used_by = futures::future::join_all(each).await; let named = |key: &str| said(&value, key).into_iter().map(|name| Named { name }); let versions = versions.into_iter().map(|(uri, r, _)| StepVersionView { uri, version: r.version, commit: r.commit.unwrap_or_default(), created_at: r.created_at, }); Ok(answer(StepViewDetailed { uri, uses: uses(&repo.handle, &repo.name, &thing.name, &record.version), path: format!("{}/{}.yml", knotless::uses::STEPS, thing.name), description: value["description"].as_str().map(str::to_string), version: record.version, commit: record.commit.unwrap_or_default(), inputs: value["inputs"].as_array().cloned().unwrap_or_default(), needs: named("needs").collect(), secrets: named("secrets").collect(), versions: versions.collect(), use_count, used_by, created_at: record.created_at, repo, name: thing.name, })) } #[cfg(test)] mod tests { use super::*; fn version(v: &str) -> Version { let record = ReleaseRecord { repo: "k".into(), package: "@step/s".into(), version: v.into(), commit: None, check: None, notes: None, files: Vec::new(), created_at: String::new(), cargo: None, yanked: false, }; (v.into(), record, Value::Null) } #[test] fn the_latest_is_the_highest_released_or_else_the_highest() { let mut versions: Vec = ["1.0.0", "1.10.0", "2.0.0-rc.1", "1.2.0", "x"] .into_iter() .map(version) .collect(); highest_first(&mut versions); let order: Vec<&str> = versions.iter().map(|v| v.0.as_str()).collect(); assert_eq!(order, ["2.0.0-rc.1", "1.10.0", "1.2.0", "1.0.0", "x"]); assert_eq!(latest(&versions).unwrap().0, "1.10.0"); let pre = [version("0.1.0-rc.1")]; assert_eq!(latest(&pre).unwrap().0, "0.1.0-rc.1"); assert!(latest(&[]).is_none()); // A yanked version is passed over, and with none left there is none. let mut yanked = version("2.0.0"); yanked.1.yanked = true; let some = [yanked.clone(), version("1.0.0")]; assert_eq!(latest(&some).unwrap().0, "1.0.0"); assert!(latest(&[yanked]).is_none()); } #[test] fn uses_names_the_major_version() { assert_eq!(uses("a.test", "ci", "wait", "1.2.0"), "a.test/ci/wait@1"); assert_eq!(uses("a.test", "ci", "wait", "0.3.1"), "a.test/ci/wait@0.3"); assert_eq!(uses("a.test", "ci", "wait", "x"), "a.test/ci/wait@x"); } }