use crate::gh::{ago, check_name, gate, hidden, optional, reactions, tally, verdict, Reaction, REACTIONS};
use serde_json::Value;
pub fn hidden_words(why: &str) -> String {
match why {
"hidden" => "hidden on GitHub".into(),
why => format!("hidden on GitHub ({why})"),
}
}
pub fn reaction_parts(r: &[Reaction]) -> Vec<(String, bool)> {
let emoji = |c: &str| REACTIONS.iter().find(|e| e.0 == c).map_or("?", |e| e.1);
r.iter().map(|(c, n, mine)| (format!("{} {n}", emoji(c)), *mine)).collect()
}
#[cfg(test)]
fn reactions_text(r: &[Reaction]) -> String {
reaction_parts(r).into_iter().map(|p| p.0).collect::<Vec<_>>().join(" ")
}
impl InfoLine {
fn put_reactions(&mut self, r: &[Reaction]) {
for (i, (text, mine)) in reaction_parts(r).into_iter().enumerate() {
self.put(if i == 0 { "" } else { " " }, None).put(&text, mine.then_some(Mark::Mine));
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Mark {
Chip(String),
Pass,
Fail,
Pending,
Dim,
Mine,
Link(String),
}
#[derive(Default)]
pub struct InfoLine {
pub heading: bool,
pub md: bool,
pub text: String,
pub marks: Vec<(usize, usize, Mark)>,
pub url: String,
pub node: String,
pub hidden: String,
pub event: bool,
pub key: String,
}
impl InfoLine {
fn new(heading: bool, md: bool) -> Self {
InfoLine { heading, md, ..Default::default() }
}
fn put(&mut self, s: &str, mark: Option<Mark>) -> &mut Self {
let a = self.text.chars().count();
self.text.push_str(s);
if let Some(m) = mark {
self.marks.push((a, a + s.chars().count(), m));
}
self
}
}
pub struct Info {
pub lines: Vec<InfoLine>,
pub title: String,
pub head: String,
pub url: String,
pub state: State,
pub unsigned: Option<Vec<String>>,
pub awaiting: usize,
pub failing: usize,
pub base: String,
pub conflicts: bool,
}
pub fn parse_info(v: &Value) -> Info {
let s = |k: &str| v[k].as_str().unwrap_or("").to_owned();
let unsigned = v["dvCommits"].as_array().map(|cs| cs.iter().filter(|c| c["signed"] != true).filter_map(|c| c["oid"].as_str().map(str::to_owned)).collect());
let failing = gate(v["statusCheckRollup"].as_array().map(Vec::as_slice).unwrap_or(&[])).1;
let awaiting = v["dvAwaiting"].as_u64().unwrap_or(0) as usize;
Info { lines: info_lines(v), title: s("title"), head: s("headRefOid"), url: s("url"), state: state(v), unsigned, awaiting, failing, base: s("baseRefName"), conflicts: s("mergeable") == "CONFLICTING" }
}
pub type State = (&'static str, &'static str);
pub fn state(v: &Value) -> State {
match (v["state"].as_str().unwrap_or(""), v["isDraft"] == true) {
("MERGED", _) => ("MERGED", "8957e5"),
("CLOSED", _) => ("CLOSED", "da3633"),
(_, true) => ("DRAFT", "6e7681"),
_ => ("OPEN", "238636"),
}
}
fn info_lines(v: &Value) -> Vec<InfoLine> {
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let list = |v: &Value| v.as_array().map(Vec::as_slice).unwrap_or(&[]).to_vec();
let mut out = vec![];
let mut push = |key: String, mut l: InfoLine| {
l.key = key;
out.push(l);
};
let plain = |text: String| {
let mut l = InfoLine::new(false, false);
l.put(&text, None);
l
};
let author = crate::gh::login(&v["author"]);
let mut l = InfoLine::new(false, false);
l.put(&format!("@{author}"), Some(Mark::Link(format!("https://github.com/{author}"))))
.put(&format!(" ยท {} โ {} ยท ", s(&v["baseRefName"]), s(&v["headRefName"])), None)
.put(&format!("+{}", v["additions"]), Some(Mark::Pass))
.put(" ", None)
.put(&format!("โ{}", v["deletions"]), Some(Mark::Fail))
.put(&format!(" in {} files", v["changedFiles"]), None);
if v["mergeable"] == "CONFLICTING" {
l.put(" ยท ", None).put(&format!("conflicts with {}", s(&v["baseRefName"])), Some(Mark::Fail));
}
push("author".into(), l);
let labels = list(&v["labels"]);
if !labels.is_empty() {
let mut l = InfoLine::new(false, false);
l.put("labels", Some(Mark::Dim));
for lb in &labels {
l.put(" ", None).put(&format!(" {} ", s(&lb["name"])), Some(Mark::Chip(s(&lb["color"]))));
}
push("labels".into(), l);
}
let assigned = list(&v["assignees"]);
if !assigned.is_empty() {
let mut l = InfoLine::new(false, false);
l.put("assigned", Some(Mark::Dim));
for a in &assigned {
l.put(&format!(" @{}", crate::gh::login(a)), None);
}
push("assigned".into(), l);
}
let activity = &v["dvActivity"];
let own = reactions(&activity["reactions"]);
if !own.is_empty() {
let mut l = InfoLine::new(false, false);
l.put("reactions ", Some(Mark::Dim)).put_reactions(&own);
push("reactions".into(), l);
}
let reviews = list(&v["latestReviews"]);
let by = |state: &str| -> Vec<String> { reviews.iter().filter(|r| r["state"] == state).map(|r| crate::gh::login(&r["author"])).collect() };
let requested: Vec<String> = list(&v["reviewRequests"]).iter().map(|r| r["login"].as_str().or(r["slug"].as_str()).or(r["name"].as_str()).unwrap_or("?").to_owned()).collect();
let groups = [
("โ", "requested changes", by("CHANGES_REQUESTED"), Mark::Fail),
("โ", "approved", by("APPROVED"), Mark::Pass),
("โ", "requested", requested, Mark::Pending),
];
let mut l = InfoLine::new(false, false);
l.put("reviews:", None);
if groups.iter().all(|g| g.2.is_empty()) {
let decision = s(&v["reviewDecision"]).to_lowercase().replace('_', " ");
l.put(" ", None).put(if decision.is_empty() { "none yet" } else { &decision }, Some(Mark::Dim));
}
push("reviews".into(), l);
for (sign, what, who, mark) in groups {
for name in who {
let mut l = InfoLine::new(false, false);
l.put(&format!("{sign} @{name}"), Some(mark.clone())).put(&format!(" {what}"), None);
push(format!("reviewer/{name}"), l);
}
}
let checks = list(&v["statusCheckRollup"]);
if checks.is_empty() {
push("checks".into(), plain("checks: none".into()));
} else {
let optional = |c: &&Value| optional(c, &checks);
let any_required = checks.iter().any(|c| c["isRequired"] == true);
let mut l = InfoLine::new(false, false);
l.put("checks", Some(Mark::Link(format!("{}/checks", s(&v["url"])))));
l.put(": ", None);
let counts = |l: &mut InfoLine, of: &[Value]| {
let (pass, fail, pending) = tally(of);
let parts = [(pass, "โ", "passed", Mark::Pass), (fail, "โ", "failed", Mark::Fail), (pending, "โ", "pending", Mark::Pending)];
for (k, (n, sym, what, m)) in parts.into_iter().filter(|p| p.0 > 0).enumerate() {
if k > 0 {
l.put(" ยท ", None);
}
l.put(&format!("{sym} {n} {what}"), Some(m));
}
};
match !any_required {
true => counts(&mut l, &checks),
false => {
let (opt, req): (Vec<Value>, Vec<Value>) = checks.iter().cloned().partition(|c| optional(&c));
l.put("required ", None);
counts(&mut l, &req);
if !opt.is_empty() {
l.put(" ยท optional ", None);
counts(&mut l, &opt);
}
}
}
if v["dvRequiredUnknown"] == true {
l.put(" ยท can't read which its base requires", Some(Mark::Dim));
}
push("checks".into(), l);
for c in checks.iter().filter(|c| verdict(c) != 'โ') {
let name = check_name(c);
let m = if verdict(c) == 'โ' { Mark::Fail } else { Mark::Pending };
let mut l = InfoLine::new(false, false);
l.put(&format!("{} ", verdict(c)), Some(m.clone()));
match c["detailsUrl"].as_str().or(c["targetUrl"].as_str()).filter(|u| !u.is_empty()) {
Some(u) => l.put(name, Some(Mark::Link(u.to_owned()))),
None => l.put(name, Some(m)),
};
if optional(&c) {
l.put(" (optional)", Some(Mark::Dim));
}
push(format!("check/{name}"), l);
}
}
match v["dvAwaiting"].as_u64().unwrap_or(0) {
0 => {}
k => {
let mut l = InfoLine::new(false, false);
let s = if k == 1 { "" } else { "s" };
l.put(&format!("โธ {k} workflow run{s} awaiting approval"), Some(Mark::Pending)).put(" (Space g approves)", Some(Mark::Dim));
push("awaiting".into(), l);
}
}
if let Some(cs) = v["dvCommits"].as_array().filter(|c| !c.is_empty()) {
let unsigned: Vec<String> = cs.iter().filter(|c| c["signed"] != true).map(|c| s(&c["oid"]).chars().take(8).collect()).collect();
let required = v["dvSignaturesRequired"] == true;
let mut l = InfoLine::new(false, false);
l.put("commits: ", None);
match unsigned.len() {
0 => l.put(&format!("โ {} signed", if cs.len() == 1 { "1".to_owned() } else { format!("all {}", cs.len()) }), Some(Mark::Pass)),
k => l.put(&format!("โ {k} of {} unsigned: {}", cs.len(), unsigned.join(", ")), Some(if required { Mark::Fail } else { Mark::Pending })),
};
let total = v["dvCommitsTotal"].as_u64().unwrap_or(0) as usize;
if total > cs.len() {
l.put(&format!(" (the last {} of {total})", cs.len()), Some(Mark::Dim));
}
if required {
let st = if unsigned.is_empty() { Mark::Dim } else { Mark::Fail };
l.put(" ยท ", None).put(&format!("{} requires signed commits", s(&v["baseRefName"])), Some(st));
}
if v["dvCommitsKept"] == true {
let when = Some(s(&v["dvCommitsAt"])).filter(|t| !t.is_empty()).map_or("an earlier look".to_owned(), |t| ago(&t));
l.put(&format!(" (as of {when})"), Some(Mark::Dim));
}
push("commits".into(), l);
} else if let Some(why) = v["dvCommitsUnknown"].as_str() {
let mut l = InfoLine::new(false, false);
l.put("commits: ", None).put(&format!("signatures unknown ({why})"), Some(Mark::Dim));
push("commits".into(), l);
}
let heading = |t: String| {
let mut l = InfoLine::new(true, false);
l.put(&t, None);
l
};
push("description".into(), InfoLine { url: s(&v["url"]), node: s(&v["id"]), ..heading("description".into()) });
let body = s(&v["body"]).replace("\r\n", "\n");
push("description/text".into(), markdown(body));
if let Some(why) = activity["error"].as_str() {
push("activity".into(), plain(format!("activity: not known ({why})")));
return out;
}
let items = activity_items(v, &list(&activity["items"]));
let kept = if activity["kept"] == true { " (as last fetched)" } else { "" };
push("activity".into(), heading(format!("activity ({}){kept}", items.len())));
for item in items {
for (key, l) in item {
push(key, l);
}
}
out
}
#[cfg(test)]
pub(crate) fn info_lines_of(v: &Value) -> Vec<String> {
info_lines(v).into_iter().map(|l| l.text).collect()
}
fn markdown(t: String) -> InfoLine {
let mut l = InfoLine::new(false, true);
l.put(&t, None);
l
}
fn activity_items(v: &Value, items: &[Value]) -> Vec<Vec<(String, InfoLine)>> {
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let mut out: Vec<Vec<(String, InfoLine)>> = vec![];
let review_of = |r: &Value| -> Vec<String> {
let n = r["comments"]["totalCount"].as_u64().unwrap_or(0);
let comments = (n > 0).then(|| format!("{n} comment{}", if n == 1 { "" } else { "s" }));
let older = r["commit"]["oid"].as_str().filter(|o| v["headRefOid"].as_str().is_some_and(|h| h != *o));
let on = older.map(|_| format!("on {} (an older commit)", s(&r["commit"]["abbreviatedOid"])));
comments.into_iter().chain(on).collect()
};
let said = |c: &Value, url: String, what: &str, mark: Option<Mark>, hidden: String| {
let node = s(&c["id"]);
let at = c["submittedAt"].as_str().or(c["createdAt"].as_str()).unwrap_or("").to_owned();
let mut h = InfoLine { url, node: node.clone(), hidden, ..InfoLine::new(true, false) };
h.put(&format!("@{} ยท ", crate::gh::login(&c["author"])), None);
if !what.is_empty() {
h.put(what, mark).put(" ยท ", None);
}
for m in review_of(c) {
h.put(&m, Some(Mark::Dim)).put(" ยท ", None);
}
h.put(&ago(&at), None);
let r = reactions(&c["reactionGroups"]);
if !r.is_empty() {
h.put(" ยท ", None).put_reactions(&r);
}
vec![(node.clone(), h), (format!("{node}/text"), markdown(s(&c["body"]).replace("\r\n", "\n")))]
};
let event = |key: String, who: &str, parts: &[(String, Option<Mark>)], at: &str| {
let mut l = InfoLine { event: true, ..InfoLine::new(true, false) };
l.put(&format!("โ @{who} "), Some(Mark::Dim));
for (t, m) in parts {
l.put(t, m.clone().or(Some(Mark::Dim)));
}
l.put(&format!(" ยท {}", ago(at)), Some(Mark::Dim));
vec![(key, l)]
};
let t = |t: &str| (t.to_owned(), None);
for it in items {
let at = s(&it["createdAt"]);
let who = &*crate::gh::login(&it["actor"]);
let key = s(&it["id"]);
let item = match it["__typename"].as_str().unwrap_or("") {
"IssueComment" => said(it, s(&it["url"]), "", None, hidden(it)),
"PullRequestReview" => {
let url = format!("{}#review-{}", s(&v["url"]), s(&it["id"]));
let reviewer = &*crate::gh::login(&it["author"]);
let at = it["submittedAt"].as_str().unwrap_or(&at);
match s(&it["state"]).as_str() {
"APPROVED" => said(it, url, "โ approved", Some(Mark::Pass), String::new()),
"CHANGES_REQUESTED" => said(it, url, "โ requested changes", Some(Mark::Fail), String::new()),
"DISMISSED" => said(it, url, "reviewed (dismissed)", None, String::new()),
"COMMENTED" if !s(&it["body"]).trim().is_empty() => said(it, url, "reviewed", None, String::new()),
"COMMENTED" => {
let more = review_of(it).iter().map(|m| format!(" ยท {m}")).collect::<String>();
event(key, reviewer, &[t(&format!("reviewed{more}"))], at)
}
_ => continue, }
}
"PullRequestCommit" => {
let c = &it["commit"];
let by = c["author"]["user"]["login"].as_str().or(c["author"]["name"].as_str()).unwrap_or("ghost").to_owned();
let oid = s(&c["abbreviatedOid"]);
let mut line = InfoLine { event: true, ..InfoLine::new(false, false) };
line.put(" ", None).put(&oid, Some(Mark::Dim)).put(&format!(" {}", s(&c["messageHeadline"])), None);
match out.last_mut().filter(|i| i.first().is_some_and(|f| f.0.starts_with(&format!("push/{by}/")))) {
Some(push) => {
push.push((format!("commit/{oid}"), line));
let n = push.len() - 1;
let head = &mut push[0].1;
let when = head.text.rsplit(" ยท ").next().unwrap_or("").to_owned();
*head = InfoLine { event: true, ..InfoLine::new(true, false) };
head.put(&format!("โ @{by} pushed {n} commits ยท {when}"), Some(Mark::Dim));
}
None => {
let mut head = InfoLine { event: true, ..InfoLine::new(true, false) };
head.put(&format!("โ @{by} pushed 1 commit ยท {}", ago(&s(&c["committedDate"]))), Some(Mark::Dim));
out.push(vec![(format!("push/{by}/{oid}"), head), (format!("commit/{oid}"), line)]);
}
}
continue;
}
"HeadRefForcePushedEvent" => {
let (a, b) = (s(&it["beforeCommit"]["abbreviatedOid"]), s(&it["afterCommit"]["abbreviatedOid"]));
event(key, who, &[t(&format!("force-pushed {a} โ {b}"))], &at)
}
k @ ("LabeledEvent" | "UnlabeledEvent") => {
let what = if k == "LabeledEvent" { "added" } else { "removed" };
let l = &it["label"];
event(key, who, &[t(&format!("{what} ")), (format!(" {} ", s(&l["name"])), Some(Mark::Chip(s(&l["color"]))))], &at)
}
k @ ("ReviewRequestedEvent" | "ReviewRequestRemovedEvent") => {
let r = &it["requestedReviewer"];
let whom = r["login"].as_str().map(|l| format!("@{l}")).or(r["name"].as_str().map(str::to_owned)).unwrap_or("?".into());
let what = if k == "ReviewRequestedEvent" { "requested a review from" } else { "removed the review request for" };
event(key, who, &[t(&format!("{what} {whom}"))], &at)
}
k @ ("AssignedEvent" | "UnassignedEvent") => {
let what = if k == "AssignedEvent" { "assigned" } else { "unassigned" };
event(key, who, &[t(&format!("{what} @{}", crate::gh::login(&it["assignee"])))], &at)
}
"RenamedTitleEvent" => event(key, who, &[t(&format!("changed the title: {} โ {}", s(&it["previousTitle"]), s(&it["currentTitle"])))], &at),
"MergedEvent" => {
let what = format!("merged {} into {}", s(&it["commit"]["abbreviatedOid"]), s(&it["mergeRefName"]));
event(key, who, &[(what, Some(Mark::Chip("8957e5".into())))], &at)
}
"ClosedEvent" => event(key, who, &[("closed it".into(), Some(Mark::Fail))], &at),
"ReopenedEvent" => event(key, who, &[("reopened it".into(), Some(Mark::Pass))], &at),
"ReadyForReviewEvent" => event(key, who, &[t("marked it ready for review")], &at),
"ConvertToDraftEvent" => event(key, who, &[t("marked it as a draft")], &at),
"BaseRefChangedEvent" => event(key, who, &[t(&format!("changed the base from {} to {}", s(&it["previousRefName"]), s(&it["currentRefName"])))], &at),
"CrossReferencedEvent" => {
let src = &it["source"];
let what = format!("#{} {}", src["number"], s(&src["title"]));
event(key, who, &[t("mentioned this in "), (what, Some(Mark::Link(s(&src["url"]))))], &at)
}
"ReviewDismissedEvent" => event(key, who, &[t("dismissed a review")], &at),
_ => continue,
};
out.push(item);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gh::*;
use serde_json::json;
#[test]
fn info_summarises_checks() {
let v = serde_json::json!({
"title": "t", "author": { "login": "amy" }, "state": "OPEN", "isDraft": true,
"baseRefName": "main", "headRefName": "feat", "additions": 3, "deletions": 1, "changedFiles": 2,
"labels": [{ "name": "bug", "color": "d73a4a" }], "reviewDecision": "CHANGES_REQUESTED",
"latestReviews": [{ "author": { "login": "bo" }, "state": "CHANGES_REQUESTED" }], "reviewRequests": [],
"statusCheckRollup": [
{ "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS" },
{ "name": "test", "status": "COMPLETED", "conclusion": "FAILURE" },
{ "context": "ci/legacy", "state": "PENDING" },
],
"body": "", "comments": [],
});
let lines = info_lines(&v);
assert_eq!(state(&v), ("DRAFT", "6e7681")); assert_eq!(state(&serde_json::json!({ "state": "CLOSED", "isDraft": true })).0, "CLOSED");
assert_eq!(lines[1].marks[1], (7, 12, Mark::Chip("d73a4a".into()))); let t: Vec<String> = lines.into_iter().map(|l| l.text).collect();
assert_eq!(t[0], "@amy ยท main โ feat ยท +3 โ1 in 2 files");
let mut c = v.clone();
c["mergeable"] = "CONFLICTING".into();
assert_eq!(info_lines(&c)[0].text, "@amy ยท main โ feat ยท +3 โ1 in 2 files ยท conflicts with main");
assert_eq!(&t[2..4], ["reviews:", "โ @bo requested changes"]); assert_eq!(&t[4..7], ["checks: โ 1 passed ยท โ 1 failed ยท โ 1 pending", "โ test", "โ ci/legacy"]);
let v = serde_json::json!({ "statusCheckRollup": [{ "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS" }] });
let t: Vec<String> = info_lines(&v).into_iter().map(|l| l.text).collect();
assert!(t.contains(&"checks: โ 1 passed".to_owned())); let v = serde_json::json!({ "statusCheckRollup": [
{ "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS", "isRequired": true },
{ "name": "codecov/patch", "status": "COMPLETED", "conclusion": "FAILURE", "isRequired": false },
] });
let t: Vec<String> = info_lines(&v).into_iter().map(|l| l.text).collect();
assert!(t.contains(&"checks: required โ 1 passed ยท optional โ 1 failed".to_owned()) && t.contains(&"โ codecov/patch (optional)".to_owned()), "{t:?}");
assert_eq!(parse_info(&v).failing, 0);
assert!(t.contains(&"reviews: none yet".to_owned()));
}
#[test]
fn activity_as_github_has_it() {
let item = |t: &str, more: Value| {
let mut v = json!({ "__typename": t, "createdAt": "2026-01-01T00:00:00Z", "actor": { "login": "amy" } });
v.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
v
};
let commit = |oid: &str, by: &str| item("PullRequestCommit", json!({ "commit": { "abbreviatedOid": oid, "messageHeadline": format!("subject {oid}"), "committedDate": "2026-01-01T00:00:00Z", "author": { "user": { "login": by } } } }));
let v = json!({
"url": "https://github.com/o/r/pull/1", "body": "", "headRefOid": "head2",
"dvActivity": { "reactions": [], "items": [
item("IssueComment", json!({ "id": "IC_1", "author": { "login": "amy" }, "body": "first", "url": "c1" })),
commit("aaa1", "bo"), commit("aaa2", "bo"),
item("LabeledEvent", json!({ "label": { "name": "bug", "color": "d73a4a" } })),
item("PullRequestReview", json!({ "id": "R2", "author": { "login": "bo" }, "state": "APPROVED", "body": "", "submittedAt": "2026-01-02T00:00:00Z", "comments": { "totalCount": 3 }, "commit": { "oid": "head1", "abbreviatedOid": "head1" } })),
item("PullRequestReview", json!({ "id": "R3", "author": { "login": "cy" }, "state": "COMMENTED", "body": "", "submittedAt": "2026-01-04T00:00:00Z", "comments": { "totalCount": 1 }, "commit": { "oid": "head2", "abbreviatedOid": "head2" } })),
item("PullRequestReview", json!({ "author": { "login": "me" }, "state": "PENDING", "body": "draft" })),
item("SubscribedEvent", json!({})),
] },
});
let lines = info_lines(&v);
let text: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
let at = text.iter().position(|t| t.starts_with("activity")).unwrap();
assert_eq!(text[at], "activity (5)");
let rest = &text[at + 1..];
assert!(rest[0].starts_with("@amy ยท ") && rest[1] == "first");
assert!(rest[2].starts_with("โ @bo pushed 2 commits ยท ") && rest[3] == " aaa1 subject aaa1" && rest[4] == " aaa2 subject aaa2", "{rest:?}");
assert!(rest[5].starts_with("โ @amy added bug "));
assert!(rest[6].starts_with("@bo ยท โ approved ยท 3 comments ยท on head1 (an older commit) ยท ") && rest[7].is_empty(), "{}", rest[6]);
assert!(rest[8].starts_with("โ @cy reviewed ยท 1 comment ยท ") && rest.len() == 9, "{}", rest[8]);
assert!(lines[at + 3].event && lines[at + 6].event && !lines[at + 1].event);
assert_eq!(lines[at + 7].url, "https://github.com/o/r/pull/1#review-R2");
}
#[test]
fn signed_commits_in_the_pr_info() {
let v = |commits: Value, required: bool| {
let v = json!({ "baseRefName": "master", "dvCommits": commits, "dvSignaturesRequired": required });
info_lines(&v).into_iter().map(|l| l.text).find(|t| t.starts_with("commits: "))
};
let both = json!([{ "oid": "aaaaaaaa11", "signed": true }, { "oid": "eb35a46622", "signed": false }]);
assert_eq!(v(both.clone(), true).unwrap(), "commits: โ 1 of 2 unsigned: eb35a466 ยท master requires signed commits");
assert_eq!(v(both, false).unwrap(), "commits: โ 1 of 2 unsigned: eb35a466");
assert_eq!(v(json!([{ "oid": "a", "signed": true }, { "oid": "b", "signed": true }]), false).unwrap(), "commits: โ all 2 signed");
assert_eq!(v(json!([]), false), None); let w = json!({ "baseRefName": "master", "dvCommits": [{ "oid": "a", "signed": true }], "dvCommitsTotal": 150 });
assert!(info_lines(&w).iter().any(|l| l.text == "commits: โ 1 signed (the last 1 of 150)"));
let rules = |r: Value| requires_signatures(&json!({ "baseRef": r }));
assert!(rules(json!({ "rules": { "nodes": [{ "type": "PULL_REQUEST" }, { "type": "REQUIRED_SIGNATURES" }] } })));
assert!(rules(json!({ "branchProtectionRule": { "requiresCommitSignatures": true }, "rules": { "nodes": [] } })));
assert!(!rules(json!({ "branchProtectionRule": null, "rules": { "nodes": [{ "type": "DELETION" }] } })));
let x = json!({ "dvCommits": [{ "oid": "aa", "signed": true }, { "oid": "bb", "signed": false }] });
assert_eq!(parse_info(&x).unsigned, Some(vec!["bb".to_owned()]));
assert_eq!(parse_info(&json!({})).unsigned, None); }
#[test]
fn workflow_runs_awaiting_approval() {
let v = json!({ "dvAwaiting": 2, "headRefOid": "abc" });
assert!(info_lines(&v).iter().any(|l| l.text == "โธ 2 workflow runs awaiting approval (Space g approves)"));
assert_eq!((parse_info(&v).awaiting, parse_info(&v).failing), (2, 0));
assert!(!info_lines(&json!({})).iter().any(|l| l.text.contains("awaiting approval")));
}
#[test]
fn reactions_both_ways() {
let g = json!([{ "content": "THUMBS_UP", "reactors": { "totalCount": 2 } }, { "content": "EYES", "reactors": { "totalCount": 0 } }, { "content": "HOORAY", "viewerHasReacted": true, "reactors": { "totalCount": 1 } }]);
assert_eq!(reactions(&g), [("THUMBS_UP".to_owned(), 2, false), ("HOORAY".to_owned(), 1, true)]);
assert_eq!(reactions_text(&reactions(&g)), "๐ 2 ๐ 1");
}
#[test]
fn info_lines_keep_their_keys() {
let keys = |v: Value| info_lines(&v).into_iter().map(|l| l.key).collect::<Vec<_>>();
let comment = |i: usize| json!({ "__typename": "IssueComment", "id": format!("IC_{i}"), "author": { "login": "amy" }, "body": "hi", "createdAt": "2026-10-01T00:00:00Z" });
let pr = |n: usize| json!({ "id": "PR_1", "statusCheckRollup": [], "dvActivity": { "items": (0..n).map(comment).collect::<Vec<_>>() } });
let (one, two) = (keys(pr(1)), keys(pr(2)));
for k in ["activity", "IC_0", "IC_0/text", "checks", "description"] {
assert!(one.iter().any(|x| x == k) && two.iter().any(|x| x == k), "{k}: {one:?} {two:?}");
}
}
#[test]
fn signatures_kept_when_github_cant_say() {
let old = json!({ "headRefOid": "abc", "dvCommits": [{ "oid": "abc12345", "signed": true }], "dvCommitsTotal": 1, "dvSignaturesRequired": false, "dvCommitsAt": "2026-10-19T07:00:00Z" });
let mut new = json!({ "headRefOid": "abc", "dvCommitsUnknown": "timed out" });
keep_known(&mut new, &old);
assert!(new["dvCommitsUnknown"].is_null() && new["dvCommitsKept"] == true);
let line = info_lines(&new).into_iter().find(|l| l.text.starts_with("commits:")).unwrap().text;
assert!(line.contains("โ 1 signed") && line.contains("(as of"), "{line}");
let mut moved = json!({ "headRefOid": "def", "dvCommitsUnknown": "timed out" });
keep_known(&mut moved, &old);
assert!(moved["dvCommitsKept"].is_null() && !moved["dvCommitsUnknown"].is_null());
}
#[test]
#[ignore]
fn live_reactions() {
let nodes = std::env::var("DV_LIVE_NODES").expect("DV_LIVE_NODES");
let pr: u64 = std::env::var("DV_LIVE_PR").expect("DV_LIVE_PR").parse().unwrap();
for node in nodes.split(',') {
let mine = |n: &str| reactions_of(n).unwrap().into_iter().find(|r| r.0 == "EYES").map(|r| (r.1, r.2));
react(node, "EYES", true).unwrap();
assert_eq!(mine(node), Some((1, true)), "{node}: added");
if node.starts_with("PR_") {
let lines = info_lines(&info_json(pr).unwrap());
assert!(lines.iter().any(|l| l.text == "reactions ๐ 1"), "PR's reactions not shown");
} else {
let lines = info_lines(&info_json(pr).unwrap());
assert!(lines.iter().any(|l| l.node == node && l.text.ends_with("ยท ๐ 1")), "comment's reactions not shown");
}
react(node, "EYES", false).unwrap();
assert_eq!(mine(node), Some((0, false)), "{node}: taken back");
}
}
#[test]
#[ignore]
fn live_labels() {
let n: u64 = std::env::var("DV_LIVE_PR").expect("DV_LIVE_PR").parse().unwrap();
let (labels, people, _) = pr_fields(n).unwrap();
println!("labels: {labels:?}\npeople: {people:?}");
let (label, me) = (labels[0].0.clone(), people[0].0.clone());
set_field(n, Field::Label, &label, true).unwrap();
set_field(n, Field::Assignee, &me, true).unwrap();
let (labels, people, _) = pr_fields(n).unwrap();
assert!(labels.contains(&(label.clone(), true)) && people.contains(&(me.clone(), true)));
let lines = info_lines(&info_json(n).unwrap());
assert!(lines.iter().any(|l| l.text == format!("assigned @{me}")), "assignee not among the facts");
set_field(n, Field::Label, &label, false).unwrap();
set_field(n, Field::Assignee, &me, false).unwrap();
let (labels, people, _) = pr_fields(n).unwrap();
assert!(labels.iter().all(|l| !l.1) && people.iter().all(|p| !p.1));
if let Ok(who) = std::env::var("DV_LIVE_REVIEWER") {
set_field(n, Field::Reviewer, &who, true).unwrap();
assert!(pr_fields(n).unwrap().2.contains(&(who.clone(), true)));
set_field(n, Field::Reviewer, &who, false).unwrap();
assert!(!pr_fields(n).unwrap().2.contains(&(who, true)));
}
}
}
#[cfg(test)]
#[test]
fn activity_kept_when_github_cant_say() {
let old = serde_json::json!({ "dvActivity": { "items": [{ "__typename": "IssueComment", "id": "IC_1", "author": { "login": "a" }, "body": "hi" }] } });
let mut new = serde_json::json!({ "dvActivity": { "error": "timed out" } });
crate::gh::keep_known(&mut new, &old);
assert!(info_lines(&new).iter().any(|l| l.text == "activity (1) (as last fetched)"));
}