use std::process::Command;
use anyhow::{Context, Result, bail};
use serde_json::Value;
use crate::cargo::Workspace;
use crate::gh::Gh;
use crate::manifest::{Needs, Repo};
const TAP: &str = "homebrew-tap";
#[derive(Default)]
pub struct Findings {
pub problems: Vec<String>,
pub pending: Vec<String>,
}
pub fn check(gh: &Gh, owner: &str, repo: &Repo, needs: &Needs) -> Result<Findings> {
let base = format!("repos/{owner}/{}", repo.name);
let mut findings = Findings::default();
let release = match gh.get(&format!("{base}/releases/latest")) {
Ok(release) => release,
Err(err) if err.to_string().contains("(HTTP 404)") => {
if !needs.release_targets.is_empty() {
findings.problems.push("release: none published yet".into());
}
return Ok(findings);
}
Err(err) => return Err(err),
};
let tag = release["tag_name"].as_str().unwrap_or_default();
let problems = &mut findings.problems;
if let Some(prefix) = &repo.release_tag_prefix {
let version_ok = tag.strip_prefix(prefix.as_str()).is_some_and(is_version);
if !version_ok {
problems.push(format!("release {tag}: tag should look like {prefix}X.Y.Z"));
}
}
let compare = gh.get(&format!("{base}/compare/HEAD...{tag}?per_page=1"))?;
if matches!(compare["status"].as_str(), Some("ahead" | "diverged")) {
problems.push(format!("release {tag}: tag isn't on the default branch"));
}
let assets: Vec<&str> = release["assets"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| a["name"].as_str())
.collect();
for target in &needs.release_targets {
let stem = format!("{}-{tag}-{target}", repo.name);
let found = assets.iter().any(|name| {
[".tar.gz", ".zip"].iter().any(|ext| {
name.strip_suffix(ext)
.is_some_and(|name| glob_match(&stem, name))
})
});
if !found {
problems.push(format!("release {tag}: no archive for {target}"));
}
}
let Some(version) = repo
.release_tag_prefix
.as_deref()
.and_then(|prefix| tag.strip_prefix(prefix))
.filter(|v| is_version(v))
else {
return Ok(findings);
};
let has = |tag: &str| needs.tags.iter().any(|t| t == tag);
if has("crates-io") {
for name in published_crates(gh, &format!("{owner}/{}", repo.name))? {
crates_io(&name, version, &mut findings)?;
}
}
if has("homebrew") {
homebrew(gh, owner, &repo.name, version, &mut findings)?;
}
if has("winget") {
winget(gh, owner, &repo.name, version, &mut findings)?;
}
Ok(findings)
}
fn published_crates(gh: &Gh, repo: &str) -> Result<Vec<String>> {
let published = |table: &toml::Table| -> Option<String> {
let package = table.get("package")?;
let publish = package.get("publish");
let off = publish
.is_some_and(|p| p.as_bool() == Some(false) || p.as_array().is_some_and(Vec::is_empty));
if off {
return None;
}
package.get("name")?.as_str().map(str::to_owned)
};
let mut names: Vec<String> = Vec::new();
for (_, package) in Workspace::read(gh, repo)?.map_or_default(|w| w.packages) {
if let Some(name) = published(&package)
&& !names.contains(&name)
{
names.push(name);
}
}
Ok(names)
}
fn crates_io(name: &str, version: &str, findings: &mut Findings) -> Result<()> {
let url = format!("https://crates.io/api/v1/crates/{name}");
let out = Command::new("curl")
.args([
"-sS",
"--user-agent",
"forge (github.com/lkurcak/forge)",
&url,
])
.output()
.context("running curl")?;
if !out.status.success() {
bail!("GET {url}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
let info: Value =
serde_json::from_slice(&out.stdout).with_context(|| format!("GET {url}: not JSON"))?;
match info["crate"]["max_version"].as_str() {
None => findings
.problems
.push(format!("crates.io {name}: not published")),
Some(v) if v != version => findings.problems.push(format!(
"crates.io {name}: has {v}, latest release is {version}"
)),
Some(_) => {}
}
Ok(())
}
fn homebrew(
gh: &Gh,
owner: &str,
name: &str,
version: &str,
findings: &mut Findings,
) -> Result<()> {
let tap = format!("{owner}/{TAP}");
let Some(formula) = gh.file(&tap, &format!("Formula/{name}.rb"), None)? else {
findings
.problems
.push(format!("homebrew: no Formula/{name}.rb in {tap}"));
return Ok(());
};
let mut urls = formula
.lines()
.filter_map(|l| l.trim().strip_prefix("url \""))
.peekable();
if urls.peek().is_some() && urls.all(|url| url.contains(version)) {
return Ok(());
}
let title = format!("chore({name}): update to {version}");
let legacy_title = format!("{name} {version}");
let pr = gh
.get(&format!("repos/{tap}/pulls?state=open&per_page=100"))?
.as_array()
.into_iter()
.flatten()
.find(|pr| pr["title"] == title.as_str() || pr["title"] == legacy_title.as_str())
.and_then(|pr| pr["number"].as_u64());
match pr {
Some(n) => findings
.pending
.push(format!("homebrew: {version} waiting in {tap}#{n}")),
None => findings.problems.push(format!(
"homebrew: formula isn't at {version}, and no PR bumps it"
)),
}
Ok(())
}
fn winget(gh: &Gh, owner: &str, name: &str, version: &str, findings: &mut Findings) -> Result<()> {
let id = format!("{owner}.{name}");
let initial = owner
.chars()
.next()
.unwrap_or_default()
.to_ascii_lowercase();
let dir = format!("repos/microsoft/winget-pkgs/contents/manifests/{initial}/{owner}/{name}");
let versions: Vec<String> = match gh.get(&dir) {
Ok(entries) => entries
.as_array()
.into_iter()
.flatten()
.filter_map(|e| e["name"].as_str().map(str::to_owned))
.collect(),
Err(err) if err.to_string().contains("(HTTP 404)") => Vec::new(),
Err(err) => return Err(err),
};
if versions.iter().any(|v| v == version) {
return Ok(());
}
let query = format!("repo:microsoft/winget-pkgs+is:pr+is:open+in:title+{id}+{version}");
let pr = gh.get(&format!("search/issues?q={query}"))?["items"]
.as_array()
.into_iter()
.flatten()
.find(|pr| {
pr["title"]
.as_str()
.is_some_and(|t| t.contains(&id) && t.ends_with(&format!("version {version}")))
})
.and_then(|pr| pr["number"].as_u64());
match (pr, versions.is_empty()) {
(Some(n), _) => findings.pending.push(format!(
"winget: {version} waiting in microsoft/winget-pkgs#{n}"
)),
(None, true) => findings.problems.push(format!(
"winget: {id} isn't in winget-pkgs; submit the first version with komac"
)),
(None, false) => findings.problems.push(format!(
"winget: has {}, latest release is {version}, and no PR adds it",
versions.last().map_or("nothing", String::as_str)
)),
}
Ok(())
}
fn is_version(s: &str) -> bool {
let core = s.split_once('-').map_or(s, |(core, _)| core);
let parts: Vec<_> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
fn glob_match(pattern: &str, text: &str) -> bool {
match pattern.split_once('*') {
None => pattern == text,
Some((head, rest)) => {
let Some(text) = text.strip_prefix(head) else {
return false;
};
(0..=text.len())
.filter(|&i| text.is_char_boundary(i))
.any(|i| glob_match(rest, &text[i..]))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions() {
assert!(is_version("1.2.3"));
assert!(is_version("0.1.0-rc.1"));
assert!(!is_version("1.2"));
assert!(!is_version("1.2.x"));
assert!(!is_version(""));
}
#[test]
fn globs() {
assert!(glob_match(
"ocalc-v1-x86_64-pc-windows-*",
"ocalc-v1-x86_64-pc-windows-gnu"
));
assert!(glob_match("a*c*e", "abcde"));
assert!(!glob_match(
"ocalc-v1-x86_64-pc-windows-*",
"ocalc-v1-aarch64-pc-windows-gnu"
));
assert!(glob_match("exact", "exact"));
assert!(!glob_match("exact", "exactly"));
}
}