use serde_json::Value;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(12);
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pr {
pub state: String,
pub merge: String,
pub merged_by: String,
pub merged_at: i64,
pub head: String,
pub ci: String,
pub url: String,
}
fn gh(dir: &Path, args: &[&str]) -> Option<String> {
let out = crate::git::run_for("gh", dir, args, WAIT);
if out.is_none() {
said_once();
}
out
}
fn said_once() {
static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if !SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!("snyvi: gh gave no answer (not installed, signed out, or offline); merges are read from git");
}
}
pub fn view(dir: &Path, n: &str) -> Option<Pr> {
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let out = gh(
dir,
&[
"pr",
"view",
n,
"--json",
"state,mergeCommit,mergedBy,mergedAt,headRefName,statusCheckRollup,url",
],
)?;
parse_view(&out)
}
pub fn by_head(dir: &Path, branch: &str) -> Option<String> {
if !crate::thread::branch_ok(branch) {
return None;
}
let out = gh(
dir,
&[
"pr", "list", "--state", "all", "--head", branch, "--json", "number", "--limit", "1",
],
)?;
let v: Value = serde_json::from_str(&out).ok()?;
v.get(0)?.get("number")?.as_u64().map(|n| n.to_string())
}
pub fn viewer(dir: &Path) -> Option<String> {
static KNOWN: Mutex<(Option<String>, Option<Instant>)> = Mutex::new((None, None));
{
let k = KNOWN.lock().unwrap_or_else(|e| e.into_inner());
if k.0.is_some() {
return k.0.clone();
}
if k.1
.is_some_and(|at| at.elapsed() < Duration::from_secs(600))
{
return None;
}
}
let login = gh(dir, &["api", "user", "--jq", ".login"])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && s.len() <= 64 && !s.contains(char::is_whitespace));
let mut k = KNOWN.lock().unwrap_or_else(|e| e.into_inner());
*k = (login.clone(), Some(Instant::now()));
login
}
pub fn parse_view(out: &str) -> Option<Pr> {
let v: Value = serde_json::from_str(out).ok()?;
let s = |p: &[&str]| {
p.iter()
.try_fold(&v, |v, k| v.get(*k))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let state = match s(&["state"]).as_str() {
"OPEN" => "open",
"MERGED" => "merged",
"CLOSED" => "closed",
_ => return None,
};
let rollup = v
.get("statusCheckRollup")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Some(Pr {
state: state.into(),
merge: s(&["mergeCommit", "oid"]).to_ascii_lowercase(),
merged_by: s(&["mergedBy", "login"]),
merged_at: iso_secs(&s(&["mergedAt"])).unwrap_or(0),
head: s(&["headRefName"]),
ci: ci_of(&rollup),
url: s(&["url"]),
})
}
pub fn ci_of(rollup: &[Value]) -> String {
if rollup.is_empty() {
return String::new();
}
let s = |r: &Value, k: &str| r.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let bad = rollup.iter().any(|r| {
let c = s(r, "conclusion");
let c = if c.is_empty() { s(r, "state") } else { c };
["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT"].contains(&c.as_str())
});
if bad {
return "failing".into();
}
let done = rollup
.iter()
.filter(|r| {
let st = s(r, "status");
st.is_empty() || st == "COMPLETED" || !s(r, "state").is_empty()
})
.count();
if done < rollup.len() {
format!("{done}/{}", rollup.len())
} else {
"passing".into()
}
}
pub fn iso_secs(t: &str) -> Option<i64> {
let b = t.as_bytes();
if b.len() < 20 || b[4] != b'-' || b[10] != b'T' || !t.ends_with('Z') {
return None;
}
let n = |r: std::ops::Range<usize>| t.get(r)?.parse::<i64>().ok();
let (y, m, d) = (n(0..4)?, n(5..7)?, n(8..10)?);
let (hh, mm, ss) = (n(11..13)?, n(14..16)?, n(17..19)?);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some(days * 86400 + hh * 3600 + mm * 60 + ss)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_merged_pr_says_who_and_when() {
let p = parse_view(r#"{"headRefName":"claude/foot-cards","mergeCommit":{"oid":"5294D9E8EC86AF8C7631F23F3098D54A612DB5CD"},"mergedAt":"2026-10-15T19:20:56Z","mergedBy":{"login":"snymrova"},"state":"MERGED","statusCheckRollup":[]}"#).unwrap();
assert_eq!(p.state, "merged");
assert_eq!(p.merge, "5294d9e8ec86af8c7631f23f3098d54a612db5cd");
assert_eq!(p.merged_by, "snymrova");
assert_eq!(p.merged_at, 1792092056);
assert_eq!(p.head, "claude/foot-cards");
assert_eq!(p.ci, "");
}
#[test]
fn an_open_pr_has_its_checks() {
let p = parse_view(r#"{"state":"OPEN","mergeCommit":null,"mergedBy":null,"mergedAt":null,"headRefName":"b","statusCheckRollup":[{"status":"COMPLETED","conclusion":"SUCCESS"},{"status":"IN_PROGRESS","conclusion":""}]}"#).unwrap();
assert_eq!(
(p.state.as_str(), p.ci.as_str(), p.merge.as_str()),
("open", "1/2", "")
);
assert!(parse_view(r#"{"state":"DRAFTY"}"#).is_none());
assert!(parse_view("not json").is_none());
}
#[test]
fn checks_in_one_word() {
let v: Vec<Value> = serde_json::from_str(
r#"[{"status":"COMPLETED","conclusion":"FAILURE"},{"status":"QUEUED"}]"#,
)
.unwrap();
assert_eq!(ci_of(&v), "failing");
let v: Vec<Value> = serde_json::from_str(
r#"[{"status":"COMPLETED","conclusion":"SUCCESS"},{"state":"SUCCESS"}]"#,
)
.unwrap();
assert_eq!(ci_of(&v), "passing");
}
#[test]
fn times_from_github() {
assert_eq!(iso_secs("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(iso_secs("2000-03-01T00:00:00Z"), Some(951868800));
assert_eq!(iso_secs("2026-10-15T19:20:56Z"), Some(1792092056));
assert_eq!(iso_secs("yesterday"), None);
}
}