use std::process::Command;
use anyhow::{Context, Result, bail};
use serde::Serialize;
pub const GH_ENV: &str = "RIGGER_GH";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Delivery {
TagOnly,
NoAssets,
Released,
PublishRunning,
PublishFailed,
Published,
}
impl Delivery {
pub fn as_str(self) -> &'static str {
match self {
Delivery::TagOnly => "tag-only",
Delivery::NoAssets => "no-assets",
Delivery::Released => "released",
Delivery::PublishRunning => "publish-running",
Delivery::PublishFailed => "publish-failed",
Delivery::Published => "published",
}
}
pub fn parse(text: &str) -> Option<Delivery> {
Some(match text {
"tag-only" => Delivery::TagOnly,
"no-assets" => Delivery::NoAssets,
"released" => Delivery::Released,
"publish-running" => Delivery::PublishRunning,
"publish-failed" => Delivery::PublishFailed,
"published" => Delivery::Published,
_ => return None,
})
}
pub fn describe(self) -> &'static str {
match self {
Delivery::TagOnly => "tag exists, no release",
Delivery::NoAssets => "released without archives",
Delivery::Released => "released",
Delivery::PublishRunning => "released, publish still running",
Delivery::PublishFailed => "released, publish failed",
Delivery::Published => "released and published",
}
}
pub fn is_short(self) -> bool {
matches!(self, Delivery::TagOnly | Delivery::NoAssets | Delivery::PublishFailed)
}
pub fn from_engine(release: bool, registries: &[String]) -> Delivery {
match (release, registries.is_empty()) {
(_, false) => Delivery::Published,
(true, true) => Delivery::Released,
(false, true) => Delivery::TagOnly,
}
}
}
pub fn github_repo(remote: &str) -> Option<String> {
let rest = remote
.strip_prefix("https://github.com/")
.or_else(|| remote.strip_prefix("http://github.com/"))
.or_else(|| remote.strip_prefix("git@github.com:"))
.or_else(|| remote.strip_prefix("ssh://git@github.com/"))?;
let rest = rest.trim_end_matches('/').trim_end_matches(".git");
let mut parts = rest.split('/');
let (owner, name) = (parts.next()?, parts.next()?);
(parts.next().is_none() && !owner.is_empty() && !name.is_empty()).then(|| format!("{owner}/{name}"))
}
fn gh() -> Option<std::ffi::OsString> {
match std::env::var_os(GH_ENV) {
Some(named) => Some(named),
None if crate::paths::scratch() => None,
None => Some("gh".into()),
}
}
#[derive(Debug)]
pub enum Unread {
NoGh,
Failed(String),
}
fn run(args: &[&str]) -> Result<Result<String, String>, Unread> {
let Some(program) = gh() else {
return Err(Unread::NoGh);
};
let out = match Command::new(&program).args(args).output() {
Ok(out) => out,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(Unread::NoGh),
Err(e) => return Err(Unread::Failed(e.to_string())),
};
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
Ok(if out.status.success() { Ok(stdout) } else { Err(stderr) })
}
pub fn read(repo: &str, tag: &str) -> Result<Option<Delivery>, Unread> {
let release = run(&["release", "view", tag, "--repo", repo, "--json", "assets,isDraft"])?;
let assets = match release {
Ok(json) => {
let value: serde_json::Value = serde_json::from_str(&json).map_err(|e| Unread::Failed(format!("gh said something that is not JSON: {e}")))?;
if value["isDraft"].as_bool().unwrap_or(false) {
None
} else {
Some(value["assets"].as_array().map(Vec::len).unwrap_or(0))
}
}
Err(said) if said.contains("release not found") => None,
Err(said) => return Err(Unread::Failed(said)),
};
let Some(assets) = assets else {
return match run(&["release", "list", "--repo", repo, "--limit", "1", "--json", "tagName"])? {
Ok(json) if json.trim().trim_start_matches('[').trim_end_matches(']').trim().is_empty() => Ok(None),
Ok(_) => Ok(Some(Delivery::TagOnly)),
Err(said) => Err(Unread::Failed(said)),
};
};
let runs = match run(&[
"run",
"list",
"--repo",
repo,
"--branch",
tag,
"--json",
"workflowName,status,conclusion",
"--limit",
"20",
])? {
Ok(json) => json,
Err(said) => return Err(Unread::Failed(said)),
};
let runs: Vec<serde_json::Value> = serde_json::from_str(&runs).map_err(|e| Unread::Failed(format!("gh said something that is not JSON: {e}")))?;
Ok(Some(publish_state(&runs, assets)))
}
fn publish_state(runs: &[serde_json::Value], assets: usize) -> Delivery {
let publish = runs
.iter()
.find(|run| run["workflowName"].as_str().is_some_and(|name| name.to_ascii_lowercase().contains("publish")));
let Some(run) = publish else {
return if assets == 0 { Delivery::NoAssets } else { Delivery::Released };
};
match (run["status"].as_str(), run["conclusion"].as_str()) {
(Some("completed"), Some("success")) => Delivery::Published,
(Some("completed"), _) => Delivery::PublishFailed,
_ => Delivery::PublishRunning,
}
}
#[derive(Debug, Serialize)]
pub struct Recorded {
pub version: String,
pub delivery: Delivery,
pub registries: Vec<String>,
pub closed: bool,
}
pub fn record(db: &crate::db::Db, project: &crate::db::Project, tag: &str, release: bool, registries: &[String], text: Option<&str>) -> Result<Recorded> {
let version = tag_version(tag)?;
let registries = registries.iter().map(|r| registry(r)).collect::<Result<Vec<_>>>()?;
let moment = crate::db::now();
let day = moment.split('T').next().unwrap_or_default().to_string();
let (name, change) = db.mark_released(project.id, &version, &day, &moment)?;
let delivery = Delivery::from_engine(release, ®istries);
if let Some(id) = db.version_id(project.id, Some(&name))? {
db.set_delivery(id, delivery, ®istries, "engine")?;
}
let composed = {
let mut reached = Vec::new();
if release {
reached.push("the release with its archives".to_string());
}
reached.extend(registries.iter().cloned());
match reached.is_empty() {
true => format!("{name} went out as a tag"),
false => format!("{name} went out: {}", reached.join(", ")),
}
};
let body = match text.map(str::trim).filter(|t| !t.is_empty()) {
Some(text) => format!("{composed}\n\n{text}"),
None => composed,
};
db.record_event(project.id, "shipped", &body, &moment, "engine")?;
Ok(Recorded {
version: name,
delivery,
registries,
closed: change != crate::db::Change::Unchanged,
})
}
pub fn registry(name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() || name.contains(char::is_whitespace) || name.contains(',') {
bail!("{name:?} is not a registry name; write it as crates.io, npm, pypi");
}
Ok(name.to_string())
}
pub fn tag_version(tag: &str) -> Result<String> {
let tag = tag.trim();
let digits = tag.strip_prefix('v').unwrap_or(tag);
let first = digits.split('.').next().unwrap_or_default();
first
.parse::<u32>()
.ok()
.and_then(|_| digits.contains('.').then_some(()))
.with_context(|| format!("{tag:?} is not a version tag; write it as v0.23.0"))?;
Ok(tag.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_remote_names_its_repository_in_either_spelling() {
assert_eq!(github_repo("https://github.com/acme/widget.git").as_deref(), Some("acme/widget"));
assert_eq!(github_repo("https://github.com/acme/widget").as_deref(), Some("acme/widget"));
assert_eq!(github_repo("git@github.com:acme/widget.git").as_deref(), Some("acme/widget"));
assert_eq!(github_repo("https://gitlab.com/acme/widget.git"), None);
assert_eq!(github_repo("https://github.com/acme"), None);
}
#[test]
fn only_the_publish_run_decides_publication() {
let built_only = [json!({ "workflowName": "Release", "status": "completed", "conclusion": "success" })];
assert_eq!(publish_state(&built_only, 3), Delivery::Released);
assert_eq!(publish_state(&built_only, 0), Delivery::NoAssets);
let red = [
json!({ "workflowName": "Publish", "status": "completed", "conclusion": "failure" }),
json!({ "workflowName": "Release", "status": "completed", "conclusion": "success" }),
];
assert_eq!(publish_state(&red, 3), Delivery::PublishFailed);
let running = [json!({ "workflowName": "Publish", "status": "in_progress", "conclusion": "" })];
assert_eq!(publish_state(&running, 3), Delivery::PublishRunning);
let rerun = [
json!({ "workflowName": "Publish", "status": "completed", "conclusion": "success" }),
json!({ "workflowName": "Publish", "status": "completed", "conclusion": "failure" }),
];
assert_eq!(publish_state(&rerun, 3), Delivery::Published);
let library = [json!({ "workflowName": "Publish", "status": "completed", "conclusion": "success" })];
assert_eq!(publish_state(&library, 0), Delivery::Published);
}
#[test]
fn the_engine_is_read_by_what_it_did() {
assert_eq!(Delivery::from_engine(true, &["crates.io".into()]), Delivery::Published);
assert_eq!(Delivery::from_engine(false, &["crates.io".into()]), Delivery::Published);
assert_eq!(Delivery::from_engine(true, &[]), Delivery::Released);
assert_eq!(Delivery::from_engine(false, &[]), Delivery::TagOnly);
}
#[test]
fn every_state_reads_back_as_itself() {
for state in [
Delivery::TagOnly,
Delivery::NoAssets,
Delivery::Released,
Delivery::PublishRunning,
Delivery::PublishFailed,
Delivery::Published,
] {
assert_eq!(Delivery::parse(state.as_str()), Some(state));
}
}
#[test]
fn a_tag_must_name_a_version() {
assert_eq!(tag_version("v0.23.0").unwrap(), "v0.23.0");
assert_eq!(tag_version("1.2").unwrap(), "1.2");
assert!(tag_version("latest").is_err());
assert!(tag_version("v3").is_err());
}
}