johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String>,
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<String>,
version: String,
commit: String,
path: String,
uses: String,
inputs: Vec<Value>,
needs: Vec<Named>,
secrets: Vec<Named>,
versions: Vec<StepVersionView>,
use_count: u64,
used_by: Vec<RepoViewBasic>,
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<String> {
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://<authority>/{}/<name>", 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<RepoViewBasic> {
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<StepView> {
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<String>,
q: Option<String>,
limit: Option<u32>,
cursor: Option<String>,
}
#[derive(Serialize)]
pub struct Steps {
steps: Vec<StepView>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// `dev.knotless.listSteps`.
pub(super) async fn list_steps(
State(app): State<Arc<App>>,
Query(p): Query<ListParams>,
) -> Result<Response> {
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<Arc<App>>,
Query(p): Query<GetParams>,
) -> Result<Response> {
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<Version> = ["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");
}
}