//! 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, /// Absent on the last page. #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, /// The workflows in `.knotless/workflows` on the default branch, by /// name. workflows: Vec, /// The minutes the repository's runners count for its owner. #[serde(skip_serializing_if = "Vec::is_empty")] minutes: Vec, } /// `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, /// For a run of a merge queue's batch, how many pulls it lands. #[serde(skip_serializing_if = "Option::is_none")] batch: Option, /// 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, } /// `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, } #[derive(Deserialize)] pub struct RunsParams { repo: String, workflow: Option, limit: Option, cursor: Option, } /// `dev.knotless.listRuns`. pub(super) async fn list_runs( State(app): State>, Query(p): Query, ) -> Result { let repo = found(&app, &p.repo).await?; let record = repo.record(&app).unwrap_or_default(); let runners: Vec = 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 { 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 { let files = o.tree("HEAD", workflow::DIR).unwrap_or_default(); let mut found: Vec = 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 { let lines = text .lines() .map_while(|l| l.strip_prefix('#')) .map(str::trim); let said = lines .take_while(|l| !l.is_empty()) .collect::>() .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); } }