use jiff::{RoundMode, SignedDuration, Timestamp, TimestampRound, Unit};
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};
pub const API: &str = "https://api.github.com";
static CONFIGURED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
const TAG_PAGES: usize = 10;
const ACTIVITY: usize = 100;
pub fn api() -> String {
let set = || CONFIGURED.get().cloned().unwrap_or_else(|| API.into());
std::env::var("SKILLCASK_GITHUB_API").ok().filter(|v| !v.is_empty()).unwrap_or_else(set).trim_end_matches('/').to_string()
}
pub fn trusted_api() -> String {
["SKILLCASK_GITHUB_API", "GITHUB_API_URL"]
.into_iter()
.find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty()))
.map_or_else(|| API.to_string(), |v| v.trim_end_matches('/').to_string())
}
pub fn on_trusted_host(url: &url::Url) -> bool {
url::Url::parse(&trusted_api()).is_ok_and(|a| a.origin() == url.origin())
}
pub fn token_hint() -> String {
let Ok(u) = url::Url::parse(&api()) else { return "GITHUB_TOKEN".into() };
if on_trusted_host(&u) {
return "GITHUB_TOKEN".into();
}
let host = u.host_str().unwrap_or_default();
format!(
"a token for {host} (`skillcask auth set {} --username oauth2`, or github-oauth in auth.json)",
u.origin().ascii_serialization()
)
}
pub fn set_api(url: &str) {
let _ = CONFIGURED.set(url.to_string());
}
pub fn repository(name: &str) -> Repository {
Repository { name: "github".into(), url: format!("{}/repos/{name}", api()), ..Default::default() }
}
pub fn repository_of(req: &SkillReq) -> Repository {
match &req.git {
Some(url) => crate::git::repository(url),
None => repository(&req.name),
}
}
pub fn versions(http: &Http, req: &SkillReq) -> Result<Vec<Candidate>> {
match &req.git {
Some(url) => crate::git::tags(http, req, url),
None => tags(http, &req.name),
}
}
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(|| {
let hint = if http.has_credential(&url) { String::new() } else { format!("; private repositories need {}", token_hint()) };
Error::new(MANIFEST, format!("GitHub: {name}: {path:?} not found (no such repository, ref or tag?{hint})"))
})
}
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(),
abandoned: None,
description: None,
license: Vec::new(),
}
}
pub fn tags(http: &Http, name: &str) -> Result<Vec<Candidate>> {
static SEEN: std::sync::Mutex<Vec<(String, Vec<Candidate>)>> = std::sync::Mutex::new(Vec::new());
if let Some((_, c)) = SEEN.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {
return Ok(c.clone());
}
let out = read_tags(http, name)?;
SEEN.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), out.clone()));
Ok(out)
}
fn read_tags(http: &Http, name: &str) -> Result<Vec<Candidate>> {
let mut out = Vec::new();
let mut full = false;
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(|v| is_sha(sha) && !out.iter().any(|c: &Candidate| &c.parsed == v)) {
out.push(candidate(name, tag.to_string(), parsed, &sha.to_ascii_lowercase(), None));
}
}
full = list.len() >= 100;
if !full {
break;
}
}
if full {
crate::ui::warn(format!("{name}: more than {} tags; only the first {} were read", TAG_PAGES * 100, TAG_PAGES * 100));
}
out.sort_by(|a, b| b.parsed.cmp(&a.parsed));
Ok(out)
}
fn releases(http: &Http, name: &str) -> Result<Vec<(String, Timestamp)>> {
let url = format!("{}/releases?per_page=100", repository(name).url);
let list = http.get_json(&url)?.and_then(|v| v.as_array().cloned()).unwrap_or_default();
Ok(list
.iter()
.filter_map(|r| Some((r.get("tag_name")?.as_str()?.to_string(), r.get("published_at")?.as_str()?.parse().ok()?)))
.collect())
}
fn pushes(http: &Http, name: &str, r: &str) -> Result<Option<Vec<(String, String, Timestamp)>>> {
let branch = match r {
"HEAD" => match http.get_json(&repository(name).url)?.as_ref().and_then(|v| v.get("default_branch")?.as_str()) {
Some(b) => b.to_string(),
None => return Ok(None),
},
_ => r.to_string(),
};
let per_page = ACTIVITY.to_string();
let url =
url::Url::parse_with_params(&format!("{}/activity", repository(name).url), [("ref", branch.as_str()), ("per_page", &per_page)])
.map_err(|e| Error::new(MANIFEST, format!("GitHub: {name}: {e}")))?;
let list = http.get_json(url.as_str())?.and_then(|v| v.as_array().cloned()).unwrap_or_default();
let out: Vec<_> = list
.iter()
.filter_map(|a| {
let sha = |k: &str| a.get(k).and_then(Value::as_str).filter(|s| is_sha(s)).map(str::to_ascii_lowercase);
Some((sha("before")?, sha("after")?, a.get("timestamp")?.as_str()?.parse().ok()?))
})
.collect();
Ok((!out.is_empty()).then_some(out))
}
pub fn resolve(http: &Http, req: &SkillReq, min_age: SignedDuration, now: Timestamp) -> Result<Candidate> {
if let Some(url) = &req.git {
return crate::git::resolve(http, req, url, min_age, now);
}
let name = &req.name;
let cutoff = now.checked_sub(min_age).unwrap_or(Timestamp::MIN);
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}")))?;
let published = releases(http, name)?;
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;
let t = published.iter().find(|(tag, _)| *tag == x.version).map_or(t, |(_, p)| t.max(*p));
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)?;
let pushed = if is_sha(r) { None } else { pushes(http, name, r)? };
if let Some(list) = &pushed {
let eligible = list.iter().find(|p| p.2 <= cutoff).map(|p| (p.1.clone(), Some(p.2)));
let eligible = eligible.or_else(|| (list.len() < ACTIVITY).then(|| (list[list.len() - 1].0.clone(), None)));
let Some((at, pushed_at)) = eligible.filter(|(sha, _)| sha.bytes().any(|b| b != b'0')) else {
let newest = list[0].2.checked_add(min_age).unwrap_or(Timestamp::MAX).round(jiff::Unit::Second).unwrap_or(list[0].2);
bail!(
MANIFEST,
"{name}: every push to {r:?} is newer than policy.min-release-age ({min_age:#}); the newest becomes eligible at {newest}"
);
};
if at != sha {
let t = commit(http, name, &at)?.1;
crate::ui::note(format!("{name}: newer pushes to {r:?} are within policy.min-release-age; using {}", &at[..12]));
(sha, time) = (at, t);
}
time = pushed_at.unwrap_or_else(|| time.min(list[list.len() - 1].2));
}
if time > cutoff && pushed.is_none() {
let eligible = time.checked_add(min_age).unwrap_or(Timestamp::MAX).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 until = cutoff.round(TimestampRound::new().smallest(Unit::Second).mode(RoundMode::Trunc)).unwrap_or(cutoff);
let older = get(http, name, &format!("/commits?sha={r}&per_page=1&until={until}"))?;
let none = || {
Error::new(
MANIFEST,
format!(
"{name}: every commit on {r:?} is newer than policy.min-release-age ({min_age:#}); the newest becomes eligible at {eligible}"
),
)
};
let first = older.as_array().and_then(|a| a.first()).ok_or_else(none)?;
(sha, time) = commit_of(first, name)?;
if time > cutoff {
return Err(none());
}
crate::ui::note(format!("{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 let Some(url) = &req.git {
return crate::git::newest(http, req, url);
}
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)));
}
}