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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
199 lines · 6.6 KB Raw History
//! A repository's runs: what its runners ran, newest first, of every
//! workflow or of one, each with what it ran on; and the workflows on its
//! default branch, those that run only when asked among them, which no
//! push or pull shows.

use super::repo::found;
use super::*;
use knotless::workflow;

/// Runs a page shows unless asked for more or fewer.
const PAGE: usize = 30;

/// `dev.knotless.listRuns`'s answer.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Runs {
    runs: Vec<RunView>,
    /// Absent on the last page.
    #[serde(skip_serializing_if = "Option::is_none")]
    cursor: Option<String>,
    /// The workflows in `.knotless/workflows` on the default branch, by
    /// name.
    workflows: Vec<WorkflowView>,
    /// The minutes the repository's runners count for its owner.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    minutes: Vec<MinutesView>,
}

/// `dev.knotless.listRuns#runView`: one run of a workflow, with what it
/// ran on.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunView {
    check: CheckView,
    /// The ref it ran at: for a pull's run its head branch, for a merge
    /// queue batch's the branch it lands on.
    #[serde(rename = "ref")]
    refname: String,
    /// For a run of a pull's head, the pull.
    #[serde(skip_serializing_if = "Option::is_none")]
    pull: Option<ItemViewBasic>,
    /// For a run of a merge queue's batch, how many pulls it lands.
    #[serde(skip_serializing_if = "Option::is_none")]
    batch: Option<usize>,
    /// For a run of a commit pushed to the ref, the first line of its
    /// message, where this server has the commit.
    #[serde(skip_serializing_if = "Option::is_none")]
    subject: Option<String>,
}

/// `dev.knotless.listRuns#workflowView`: a workflow file on the default
/// branch.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowView {
    name: String,
    /// Whether it runs only when asked for by name, on no push or pull.
    asked: bool,
    /// The first sentence of the comment its file opens with.
    #[serde(skip_serializing_if = "Option::is_none")]
    about: Option<String>,
}

#[derive(Deserialize)]
pub struct RunsParams {
    repo: String,
    workflow: Option<String>,
    limit: Option<usize>,
    cursor: Option<String>,
}

/// `dev.knotless.listRuns`.
pub(super) async fn list_runs(
    State(app): State<Arc<App>>,
    Query(p): Query<RunsParams>,
) -> Result<Response> {
    let repo = found(&app, &p.repo).await?;
    let record = repo.record(&app).unwrap_or_default();
    let runners: Vec<String> = record.runners.iter().map(|r| r.did.clone()).collect();
    let filter = CheckFilter {
        workflow: p.workflow.as_deref(),
        ..CheckFilter::default()
    };
    let limit = p.limit.unwrap_or(PAGE).clamp(1, 100);
    let cursor = p.cursor.as_deref();
    let found = checks_index(&app, &repo.uri()).and_then(|(index, private)| {
        let found = index.checks(&repo.did, &repo.key, &runners, filter, limit, cursor);
        let found = found.ok();
        if let Some((entries, _)) = found.as_ref().filter(|_| !private) {
            app.recheck(index, entries);
        }
        found
    });
    let (entries, cursor) = found.unwrap_or_default();
    let made = assembled(&app, &repo).await?;
    let items = &made.items;
    let objects = repo.objects().ok();
    let shown = repo.basic(&app).await;
    let runs = entries.into_iter().filter_map(|e| {
        let c: CheckRecord = serde_json::from_value(e.value).ok()?;
        let pull = c
            .pull
            .as_deref()
            .and_then(|uri| items.iter().find(|i| i.uri == uri));
        let pushed = c.pull.is_none() && c.batch.is_empty();
        let subject = objects
            .as_ref()
            .filter(|_| pushed)
            .and_then(|o| subject(o, &c.commit));
        Some(RunView {
            refname: c.ref_name.clone(),
            pull: pull.map(|i| ItemViewBasic {
                uri: i.uri.clone(),
                title: i.title.clone(),
            }),
            batch: (!c.batch.is_empty()).then_some(c.batch.len()),
            subject,
            check: check_view(&shown, e.uri, c)?,
        })
    });
    let runs = runs.collect();
    let workflows = objects.as_ref().map(workflows).unwrap_or_default();
    let minutes = minutes(&app, &repo.uri(), &repo.did, &runners).await;
    Ok(answer(Runs {
        runs,
        cursor,
        workflows,
        minutes,
    }))
}

/// The first line of the message of the commit `sha`, if `o` has it.
fn subject(o: &Objects, sha: &str) -> Option<String> {
    let commit = o.log(sha, "", 0, 1).ok()?.into_iter().next()?;
    Some(
        commit
            .message
            .lines()
            .next()
            .unwrap_or_default()
            .to_string(),
    )
}

/// The workflows in `.knotless/workflows` at `HEAD`, by name: those that
/// read as one, as a runner reads them.
fn workflows(o: &Objects) -> Vec<WorkflowView> {
    let files = o.tree("HEAD", workflow::DIR).unwrap_or_default();
    let mut found: Vec<WorkflowView> = files
        .into_iter()
        .filter(|e| e.kind == "blob" && (e.name.ends_with(".yml") || e.name.ends_with(".yaml")))
        .filter_map(|e| {
            let text = o
                .blob("HEAD", &format!("{}/{}", workflow::DIR, e.name))
                .ok()?;
            let w = workflow::parse(&e.name, &text).ok()?;
            let on = &w.on;
            Some(WorkflowView {
                asked: on.push.is_none() && on.pull.is_none() && on.asked.is_some(),
                about: about(&String::from_utf8_lossy(&text)),
                name: w.name,
            })
        })
        .collect();
    found.sort_by(|a, b| a.name.cmp(&b.name));
    found
}

/// The first sentence of the comment `text` opens with, its lines joined.
fn about(text: &str) -> Option<String> {
    let lines = text
        .lines()
        .map_while(|l| l.strip_prefix('#'))
        .map(str::trim);
    let said = lines
        .take_while(|l| !l.is_empty())
        .collect::<Vec<_>>()
        .join(" ");
    let first = said.split_inclusive(". ").next().unwrap_or_default().trim();
    (!first.is_empty()).then(|| first.to_string())
}

#[cfg(test)]
mod tests {
    use super::about;

    #[test]
    fn abouts() {
        let text =
            "# Checks the docs. Costs model usage,\n# so only when asked.\n#\n# More.\nimage: x\n";
        assert_eq!(about(text).as_deref(), Some("Checks the docs."));
        assert_eq!(
            about("# One line, no stop\non: {}\n").as_deref(),
            Some("One line, no stop")
        );
        assert_eq!(about("image: x\n# later\n"), None);
        assert_eq!(about("#\n# after a blank\n"), None);
    }
}