~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
304 lines · 10.0 KB Raw History
//! 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");
    }
}