use jiff::{SignedDuration, Timestamp};
use serde_json::Value;
use crate::bail;
use crate::error::{Error, MANIFEST, NETWORK, Result};
use crate::http::Http;
use crate::lock::Dist;
use crate::manifest::{GitHub, Repository, SkillReq};
use crate::registry::{self, Candidate};
use crate::skill::{FileEntry, SKILL_MD};
use crate::version::{Constraint, Version};
const API: &str = "https://api.github.com";
const TAG_PAGES: usize = 10;
pub fn api() -> String {
std::env::var("SKILLCASK_GITHUB_API").unwrap_or_else(|_| API.into()).trim_end_matches('/').to_string()
}
pub fn repository(name: &str) -> Repository {
Repository { name: "github".into(), url: format!("{}/repos/{name}", api()) }
}
pub fn is_github(locked_repository: &str) -> bool {
locked_repository.starts_with(&format!("{}/repos/", api()))
}
pub fn is_sha(r: &str) -> bool {
r.len() == 40 && r.bytes().all(|b| b.is_ascii_hexdigit())
}
fn get(http: &Http, name: &str, path: &str) -> Result<Value> {
let url = format!("{}{path}", repository(name).url);
http.get_json(&url)?
.ok_or_else(|| Error::new(MANIFEST, format!("GitHub: {name}: {path:?} not found (no such repository, ref or tag?)")))
}
fn commit_of(v: &Value, name: &str) -> Result<(String, Timestamp)> {
let sha = v.get("sha").and_then(Value::as_str).filter(|s| is_sha(s));
let date = v.pointer("/commit/committer/date").and_then(Value::as_str).and_then(|d| d.parse().ok());
match (sha, date) {
(Some(s), Some(d)) => Ok((s.to_ascii_lowercase(), d)),
_ => bail!(NETWORK, "GitHub: {name}: unexpected commit response"),
}
}
fn commit(http: &Http, name: &str, r: &str) -> Result<(String, Timestamp)> {
commit_of(&get(http, name, &format!("/commits/{r}"))?, name)
}
fn candidate(name: &str, version: String, parsed: Version, sha: &str, time: Option<Timestamp>) -> Candidate {
Candidate {
name: name.to_string(),
version,
parsed,
time,
dist: Dist { kind: "zip".into(), url: format!("{}/zipball/{sha}", repository(name).url), reference: Some(sha.to_string()) },
dist_shasum: None,
skill_paths: Vec::new(),
}
}
pub fn tags(http: &Http, name: &str) -> Result<Vec<Candidate>> {
let mut out = Vec::new();
for page in 1..=TAG_PAGES {
let v = get(http, name, &format!("/tags?per_page=100&page={page}"))?;
let list = v.as_array().ok_or_else(|| Error::new(NETWORK, format!("GitHub: {name}: unexpected tags response")))?;
for t in list {
let (Some(tag), Some(sha)) = (t.get("name").and_then(Value::as_str), t.pointer("/commit/sha").and_then(Value::as_str)) else {
continue;
};
if let Some(parsed) = Version::parse(tag).filter(|_| is_sha(sha)) {
out.push(candidate(name, tag.to_string(), parsed, &sha.to_ascii_lowercase(), None));
}
}
if list.len() < 100 {
break;
}
}
out.sort_by(|a, b| b.parsed.cmp(&a.parsed));
Ok(out)
}
pub fn resolve(http: &Http, req: &SkillReq, min_age: SignedDuration, now: Timestamp) -> Result<Candidate> {
let name = &req.name;
let cutoff = now.checked_sub(min_age).unwrap_or(now);
match req.github {
Some(GitHub::Tags) => {
let mut cands = tags(http, name)?;
if cands.is_empty() {
bail!(MANIFEST, "{name}: the repository has no version tags; use `ref` instead of `version`");
}
let c = Constraint::parse(&req.constraint).map_err(|e| Error::new(MANIFEST, format!("{name}: {e}")))?;
for x in cands.iter_mut().filter(|x| c.allows(&x.parsed)) {
let t = commit(http, name, x.dist.reference.as_deref().expect("set by candidate"))?.1;
x.time = Some(t);
if t <= cutoff {
break;
}
}
registry::select(&cands, name, &req.constraint, min_age, now)
}
Some(GitHub::Ref) => {
let r = &req.constraint;
let (mut sha, mut time) = commit(http, name, r)?;
if time > cutoff {
let eligible = time.checked_add(min_age).unwrap_or(time).round(jiff::Unit::Second).unwrap_or(time);
if is_sha(r) {
bail!(
MANIFEST,
"{name}: commit {r} is newer than policy.min-release-age ({min_age:#}); it becomes eligible at {eligible}"
);
}
let older =
get(http, name, &format!("/commits?sha={r}&per_page=1&until={}", cutoff.round(jiff::Unit::Second).unwrap_or(cutoff)))?;
let first = older.as_array().and_then(|a| a.first()).ok_or_else(|| {
Error::new(MANIFEST, format!("{name}: every commit on {r:?} is newer than policy.min-release-age ({min_age:#}); the newest becomes eligible at {eligible}"))
})?;
(sha, time) = commit_of(first, name)?;
eprintln!("note: {name}: newer commits on {r:?} are within policy.min-release-age; using {}", &sha[..12]);
}
let parsed = Version::parse("0.0.0").expect("valid");
Ok(candidate(name, sha[..12].to_string(), parsed, &sha, Some(time)))
}
None => unreachable!("only GitHub entries are resolved here"),
}
}
pub fn newest(http: &Http, req: &SkillReq) -> Result<(Option<String>, Option<String>)> {
if req.github == Some(GitHub::Ref) {
let head = commit(http, &req.name, &req.constraint)?.0[..12].to_string();
return Ok((Some(head.clone()), Some(head)));
}
let cands = tags(http, &req.name)?;
let c = Constraint::parse(&req.constraint).map_err(|e| Error::new(MANIFEST, format!("{}: {e}", req.name)))?;
let v = |x: Option<&Candidate>| x.map(|x| x.version.clone());
Ok((v(registry::newest(&cands, Some(&c))), v(registry::newest(&cands, None))))
}
pub fn discover(entries: &[FileEntry]) -> Vec<String> {
let mut dirs: Vec<&str> =
entries.iter().filter_map(|e| if e.path == SKILL_MD { Some("") } else { e.path.strip_suffix(&format!("/{SKILL_MD}")) }).collect();
dirs.sort();
let mut out: Vec<&str> = Vec::new();
for d in dirs {
if !out.iter().any(|o| o.is_empty() || d.starts_with(&format!("{o}/"))) {
out.push(d);
}
}
out.into_iter().map(|d| if d.is_empty() { SKILL_MD.to_string() } else { format!("{d}/{SKILL_MD}") }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovers_outermost_skills() {
let e = |p: &str| FileEntry::new(p.into(), false, b"");
let found = discover(&[
e("README.md"),
e("skills/b/SKILL.md"),
e("skills/b/examples/x/SKILL.md"),
e("skills/a/SKILL.md"),
e("a/SKILL.mdx"),
]);
assert_eq!(found, ["skills/a/SKILL.md", "skills/b/SKILL.md"]);
assert_eq!(discover(&[e("SKILL.md"), e("sub/SKILL.md")]), ["SKILL.md"]);
assert!(is_sha(&"a".repeat(40)) && !is_sha("main") && !is_sha(&"g".repeat(40)));
}
}