use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Clone, Default)]
pub struct Pr {
pub number: u64,
pub node_id: String,
pub title: String,
pub base: String,
pub head_oid: String,
pub url: String,
pub base_oid: String,
pub head_branch: String,
pub head_owner: String,
pub author: String,
pub cross_repo: bool,
pub head_repo: String,
pub maintainer_can_modify: bool,
}
pub struct Review {
pub pr_id: String,
pub mine: bool,
pub me: String,
pub pending: Option<String>,
pub pending_body: String,
pub threads: Vec<Thread>,
pub viewed: std::collections::HashMap<String, String>,
}
#[derive(Clone, Default)]
pub struct Thread {
pub id: String,
pub path: String,
pub left: bool,
pub line: Option<u32>,
pub start_line: Option<u32>,
pub original_line: Option<u32>,
pub resolved: bool,
pub outdated: bool,
pub hunk: Vec<String>,
pub comments: Vec<Comment>,
pub claude: Option<String>,
pub severity: String,
pub about: Option<u64>,
pub conversation: String,
pub want: String,
}
#[derive(Clone, Default)]
pub struct Comment {
pub id: u64,
pub node_id: String,
pub can_edit: bool,
pub can_delete: bool,
pub author: String,
pub body: String,
pub created: String,
pub pending: bool,
pub reactions: Vec<Reaction>,
pub hidden: String,
}
#[derive(Clone)]
pub struct Anchor {
pub path: String,
pub line: u32,
pub left: bool,
pub start: Option<(u32, bool)>,
}
fn gh(args: &[&str]) -> Result<String> {
gh_input(args, None)
}
pub fn body(n: u64) -> Result<String> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "body"])?)?;
Ok(v["body"].as_str().unwrap_or("").replace("\r\n", "\n"))
}
pub fn checks(n: u64) -> Result<(usize, usize, usize)> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "statusCheckRollup"])?)?;
let checks = v["statusCheckRollup"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let count = |m| checks.iter().filter(|c| verdict(c) == m).count();
Ok((count('✓'), count('✗'), count('●')))
}
pub fn mark_ready(n: u64) -> Result<()> {
let (_, fail, pending) = checks(n)?;
match (fail, pending) {
(0, 0) => gh(&["pr", "ready", &n.to_string()]).map(drop),
(0, k) => bail!("CI is still running ({k} check{} pending): ready once it passes", if k == 1 { "" } else { "s" }),
(k, _) => bail!("CI fails ({k} check{} failed): fix it first (dv list: a a has Claude look)", if k == 1 { "" } else { "s" }),
}
}
pub fn set_body(n: u64, body: &str) -> Result<()> {
gh_input(&["pr", "edit", &n.to_string(), "--body-file", "-"], Some(body)).map(drop)
}
fn gh_input(args: &[&str], input: Option<&str>) -> Result<String> {
let mut child = Command::new("gh")
.args(args)
.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("running gh")?;
if let (Some(i), Some(mut stdin)) = (input, child.stdin.take()) {
stdin.write_all(i.as_bytes())?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
let body: Value = serde_json::from_slice(&out.stdout).unwrap_or_default();
let err = String::from_utf8_lossy(&out.stderr);
let why = body["errors"].as_array().into_iter().flatten().filter_map(|e| e["message"].as_str());
let why: Vec<&str> = why.filter(|w| !err.contains(w)).collect(); match why.is_empty() {
true => bail!("gh {}: {}", args.first().unwrap_or(&""), err.trim()),
false => bail!("gh {}: {}: {}", args.first().unwrap_or(&""), err.trim(), why.join("; ")),
}
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn checkout(n: u64, force: bool, detach: bool) -> Result<()> {
let n = n.to_string();
let mut args = vec!["pr", "checkout", &n];
if force {
args.push("--force");
}
if detach {
args.push("--detach");
}
gh(&args).map(drop)
}
pub fn pr(n: Option<u64>) -> Result<Pr> {
let n = n.map(|n| n.to_string());
let mut args = vec!["pr", "view", "--json", "number,title,baseRefName,headRefOid,baseRefOid,headRefName,headRepositoryOwner,author,isCrossRepository,headRepository,maintainerCanModify"];
args.extend(n.as_deref());
let v: Value = serde_json::from_str(&gh(&args)?)?;
Ok(Pr {
number: v["number"].as_u64().context("pr number")?,
node_id: String::new(),
title: v["title"].as_str().unwrap_or("").to_owned(),
base: v["baseRefName"].as_str().unwrap_or("main").to_owned(),
head_oid: v["headRefOid"].as_str().unwrap_or("").to_owned(),
url: String::new(),
base_oid: v["baseRefOid"].as_str().unwrap_or("").to_owned(),
head_branch: v["headRefName"].as_str().unwrap_or("").to_owned(),
head_owner: v["headRepositoryOwner"]["login"].as_str().unwrap_or("").to_owned(),
author: v["author"]["login"].as_str().unwrap_or("").to_owned(),
cross_repo: v["isCrossRepository"].as_bool().unwrap_or(false),
head_repo: v["headRepository"]["nameWithOwner"].as_str().unwrap_or("").to_owned(),
maintainer_can_modify: v["maintainerCanModify"] == true,
})
}
pub fn issue_ref(url: &str) -> Option<(&str, &str, u64)> {
let rest = url.strip_prefix("https://github.com/")?;
let mut parts = rest.split(['/', '#', '?']);
let (owner, repo, kind, n) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
matches!(kind, "issues" | "pull").then_some(())?;
Some((owner, repo, n.parse().ok()?))
}
pub fn issue_preview(url: &str) -> Result<(String, String)> {
let (owner, repo, n) = issue_ref(url).context("not a GitHub issue or PR link")?;
let v: Value = serde_json::from_str(&gh(&["api", &format!("repos/{owner}/{repo}/issues/{n}")])?)?;
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let pr = v["pull_request"].is_object();
let state = match (s(&v["state"]).as_str(), pr) {
(_, true) if v["pull_request"]["merged_at"].is_string() => "Merged",
("open", true) if v["draft"] == true => "Draft",
("open", _) => "Open",
_ if s(&v["state_reason"]) == "not_planned" => "Closed (not planned)",
_ => "Closed",
};
let kind = if pr { "Pull request" } else { "Issue" };
let comments = match v["comments"].as_u64().unwrap_or(0) {
1 => " · 1 comment".into(),
c => format!(" · {c} comments"),
};
let labels: Vec<String> = v["labels"].as_array().into_iter().flatten().map(|l| format!("`{}`", s(&l["name"]))).collect();
let body = s(&v["body"]).replace("\r\n", "\n");
let body = if body.trim().is_empty() { "_(no description)_".into() } else { body };
let md = format!(
"**{kind} · {state}** · @{} · opened {}{comments}\n{}\n\n{body}",
s(&v["user"]["login"]),
ago(&s(&v["created_at"])),
labels.join(" "),
);
let title = format!(" {owner}/{repo}#{n} · {} j/k scroll ", s(&v["title"]));
Ok((title, md))
}
#[derive(Clone, Default)]
pub struct Listed {
pub number: u64,
pub title: String,
pub author: String,
pub base: String,
pub branch: String,
pub draft: bool,
pub state: String,
pub created: String,
pub updated: String,
pub additions: u64,
pub deletions: u64,
pub files: u64,
pub decision: String,
pub labels: Vec<(String, String)>,
pub checks: Option<(usize, usize, usize)>,
pub checks_head: String,
}
impl Listed {
pub fn open(&self) -> bool {
self.state == "OPEN" || self.state.is_empty()
}
}
pub const LOOKING: &str = "…";
const LISTED: &str = "number,title,author,baseRefName,headRefName,isDraft,state,createdAt,updatedAt,additions,deletions,changedFiles,reviewDecision,labels";
fn listed(p: &Value) -> Listed {
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let n = |v: &Value| v.as_u64().unwrap_or(0);
Listed {
number: n(&p["number"]),
title: s(&p["title"]),
author: s(&p["author"]["login"]),
base: s(&p["baseRefName"]),
branch: s(&p["headRefName"]),
draft: p["isDraft"] == true,
state: s(&p["state"]),
created: s(&p["createdAt"]),
updated: s(&p["updatedAt"]),
additions: n(&p["additions"]),
deletions: n(&p["deletions"]),
files: n(&p["changedFiles"]),
decision: s(&p["reviewDecision"]),
labels: p["labels"].as_array().into_iter().flatten().map(|l| (s(&l["name"]), s(&l["color"]))).collect(),
checks: None,
checks_head: String::new(),
}
}
pub const LIST_MAX: usize = 1000;
pub fn list() -> Result<Vec<Listed>> {
let v: Value = serde_json::from_str(&gh(&["pr", "list", "--limit", &LIST_MAX.to_string(), "--json", LISTED])?)?;
Ok(v.as_array().context("gh pr list")?.iter().map(listed).collect())
}
pub fn head_branch(n: u64) -> Result<(String, String)> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "headRefName,headRepositoryOwner"])?)?;
Ok((v["headRefName"].as_str().unwrap_or("").to_owned(), v["headRepositoryOwner"]["login"].as_str().unwrap_or("").to_owned()))
}
pub fn brief(n: u64) -> Result<(String, String, String, String)> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "title,author,url,baseRefName"])?)?;
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
Ok((s(&v["title"]), s(&v["author"]["login"]), s(&v["url"]), s(&v["baseRefName"])))
}
pub fn listed_one(n: u64) -> Result<Listed> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", LISTED])?)?;
Ok(listed(&v))
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Extra {
pub my_review: Option<(String, String)>,
pub reviews: Vec<(String, String)>,
pub said: Vec<(String, String)>,
pub requested: bool,
pub mergeable: String,
pub head_oid: String,
pub base: String,
pub unsigned: usize,
pub signatures_required: bool,
pub awaiting: usize,
}
pub fn list_extra() -> Result<(String, std::collections::HashMap<u64, Extra>)> {
let (mut me, mut all, mut after) = (String::new(), std::collections::HashMap::new(), None::<String>);
while all.len() < LIST_MAX {
let from = after.as_ref().map_or(String::new(), |c| format!(", after: \"{c}\""));
let q = format!(
"query($owner: String!, $name: String!) {{ viewer {{ login }} repository(owner: $owner, name: $name) {{
pullRequests(states: OPEN, first: 100{from}, orderBy: {{ field: UPDATED_AT, direction: DESC }}) {{ {PAGE} nodes {{
number headRefOid mergeable baseRefName {SIGNATURE_RULES}
commits(last: 100) {{ nodes {{ commit {{ signature {{ isValid }} }} }} }}
reviewRequests(first: 30) {{ nodes {{ requestedReviewer {{ ... on User {{ login }} }} }} }}
latestReviews(first: 50) {{ nodes {{ author {{ login }} state commit {{ oid }} }} }}
comments(last: 20) {{ nodes {{ author {{ login }} authorAssociation body }} }} }} }} }} }}"
);
let v: Value = serde_json::from_str(&gh(&["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}", "-f", &format!("query={q}")])?)?;
me = v["data"]["viewer"]["login"].as_str().unwrap_or("").to_owned();
let list = &v["data"]["repository"]["pullRequests"];
let prs = list["nodes"].as_array().context("gh api graphql: pull requests")?;
all.extend(prs.iter().map(|p| (p["number"].as_u64().unwrap_or(0), extra(p, &me))));
match next_page(list) {
Some(c) => after = Some(c),
None => break,
}
}
if let Ok(w) = awaiting_approval(None) {
for e in all.values_mut() {
e.awaiting = w.get(&e.head_oid).map_or(0, Vec::len);
}
}
Ok((me, all))
}
fn extra(p: &Value, me: &str) -> Extra {
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let nodes = |k: &str| p[k]["nodes"].as_array().cloned().unwrap_or_default();
let my_review = nodes("latestReviews").into_iter().find(|r| r["author"]["login"] == me).map(|r| (s(&r["state"]), s(&r["commit"]["oid"])));
Extra {
my_review,
reviews: nodes("latestReviews").iter().map(|r| (s(&r["author"]["login"]), s(&r["state"]))).collect(),
said: nodes("comments")
.iter()
.filter(|c| c["author"]["login"] == me || matches!(c["authorAssociation"].as_str(), Some("OWNER" | "MEMBER" | "COLLABORATOR")))
.map(|c| (s(&c["author"]["login"]), s(&c["body"])))
.collect(),
requested: nodes("reviewRequests").iter().any(|r| r["requestedReviewer"]["login"] == me),
mergeable: s(&p["mergeable"]),
head_oid: s(&p["headRefOid"]),
base: s(&p["baseRefName"]),
unsigned: nodes("commits").iter().filter(|c| c["commit"]["signature"]["isValid"] != true).count(),
signatures_required: requires_signatures(p),
awaiting: 0, }
}
pub fn pr_state(n: u64) -> Result<Option<String>> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "state"])?)?;
Ok(v["state"].as_str().map(str::to_owned))
}
pub fn repo_name() -> Result<String> {
Ok(gh(&["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])?.trim().to_owned())
}
pub type ListChecks = std::collections::HashMap<u64, ((usize, usize, usize), String)>;
pub fn list_checks() -> Result<ListChecks> {
let v: Value = serde_json::from_str(&gh(&["pr", "list", "--limit", &LIST_MAX.to_string(), "--json", "number,headRefOid,statusCheckRollup"])?)?;
let prs = v.as_array().context("gh pr list")?;
Ok(prs
.iter()
.map(|p| {
let checks = p["statusCheckRollup"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let count = |m| checks.iter().filter(|c| verdict(c) == m).count();
let head = p["headRefOid"].as_str().unwrap_or("").to_owned();
(p["number"].as_u64().unwrap_or(0), ((count('✓'), count('✗'), count('●')), head))
})
.collect())
}
fn verdict(c: &Value) -> char {
match (c["status"].as_str(), c["conclusion"].as_str().or(c["state"].as_str())) {
(Some(st), _) if st != "COMPLETED" => '●',
(_, Some("SUCCESS" | "NEUTRAL" | "SKIPPED")) => '✓',
(_, Some("PENDING" | "EXPECTED")) | (_, None) => '●',
_ => '✗',
}
}
const COMMENT: &str = "id databaseId author { login } body createdAt diffHunk state viewerCanUpdate viewerCanDelete \
reactionGroups { content viewerHasReacted reactors { totalCount } } isMinimized minimizedReason";
const THREAD: &str = "id path line startLine originalLine diffSide isResolved isOutdated";
const PAGE: &str = "pageInfo { hasNextPage endCursor }";
pub fn threads_json(n: u64) -> Result<Value> {
let num = format!("number={n}");
let run = |q: &str, vars: &[(&str, &str)]| -> Result<Value> {
let q = format!("query={q}");
let vars: Vec<String> = vars.iter().map(|(k, v)| format!("{k}={v}")).collect();
let mut args = vec!["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}", "-F", &num, "-f", &q];
for v in &vars {
args.extend(["-f", v]);
}
let v: Value = serde_json::from_str(&gh(&args)?)?;
if let Some(e) = v["errors"].get(0) {
bail!("GitHub: {}", e["message"].as_str().unwrap_or("GraphQL error"));
}
Ok(v["data"].clone())
};
fetch_review(run)
}
pub fn parse_threads(d: &Value) -> Review {
let mut r = parse_review(&d["repository"]["pullRequest"]);
r.me = d["viewer"]["login"].as_str().unwrap_or("").to_owned();
r
}
fn next_page(list: &Value) -> Option<String> {
let p = &list["pageInfo"];
(p["hasNextPage"] == true).then(|| p["endCursor"].as_str().map(str::to_owned)).flatten()
}
pub fn pr_etag(n: u64, etag: &str) -> Result<Option<String>> {
etag_of(&format!("repos/{{owner}}/{{repo}}/pulls/{n}"), etag)
}
pub fn list_etag(etag: &str) -> Result<Option<String>> {
etag_of("repos/{owner}/{repo}/pulls?state=open&sort=updated&direction=desc&per_page=100", etag)
}
fn etag_of(path: &str, etag: &str) -> Result<Option<String>> {
let header = format!("If-None-Match: {etag}");
let mut args = vec!["api", "-i", path];
if !etag.is_empty() {
args.extend(["-H", &header]);
}
let out = Command::new("gh").args(&args).stdin(Stdio::null()).output().context("running gh")?;
let text = String::from_utf8_lossy(&out.stdout);
if text.lines().next().is_some_and(|l| l.contains(" 304")) {
return Ok(None);
}
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
let tag = text.lines().take_while(|l| !l.trim().is_empty()).find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("etag").then(|| v.trim().to_owned())
});
Ok(Some(tag.unwrap_or_default()))
}
fn fetch_review(mut run: impl FnMut(&str, &[(&str, &str)]) -> Result<Value>) -> Result<Value> {
let pr = "repository(owner: $owner, name: $name) { pullRequest(number: $number)";
let vars = "$owner: String!, $name: String!, $number: Int!";
let threads = |after: &str| format!("reviewThreads(first: 100{after}) {{ {PAGE} nodes {{ {THREAD} comments(first: 100) {{ {PAGE} nodes {{ {COMMENT} }} }} }} }}");
let files = |after: &str| format!("files(first: 100{after}) {{ {PAGE} nodes {{ path viewerViewedState }} }}");
let mut d = run(
&format!(
"query({vars}) {{ viewer {{ login }} {pr} {{ id viewerDidAuthor
reviews(states: PENDING, first: 10) {{ nodes {{ id viewerDidAuthor body }} }} {} {} }} }} }}",
files(""),
threads("")
),
&[],
)?;
let at = |d: &Value, k: &str| d["repository"]["pullRequest"][k].clone();
let append = |list: &mut Value, page: &Value| -> Option<String> {
if let (Some(l), Some(p)) = (list["nodes"].as_array_mut(), page["nodes"].as_array()) {
l.extend(p.iter().cloned());
}
next_page(page)
};
let p = &mut d["repository"]["pullRequest"];
for (key, list) in [("reviewThreads", threads(", after: $after")), ("files", files(", after: $after"))] {
let mut next = next_page(&p[key]);
while let Some(after) = next {
let page = run(&format!("query({vars}, $after: String!) {{ {pr} {{ {list} }} }} }}"), &[("after", &after)])?;
next = append(&mut p[key], &at(&page, key));
}
}
if let Some(ts) = p["reviewThreads"]["nodes"].as_array_mut() {
for t in ts {
let mut next = next_page(&t["comments"]);
let id = t["id"].as_str().unwrap_or("").to_owned();
while let Some(after) = next {
let q = format!(
"query($id: ID!, $after: String!) {{ node(id: $id) {{ ... on PullRequestReviewThread {{
comments(first: 100, after: $after) {{ {PAGE} nodes {{ {COMMENT} }} }} }} }} }}"
);
let page = run(&q, &[("id", &id), ("after", &after)])?;
next = append(&mut t["comments"], &page["node"]["comments"]);
}
}
}
Ok(d)
}
fn parse_review(pr: &Value) -> Review {
let nodes = |v: &Value| v["nodes"].as_array().cloned().unwrap_or_default();
let pending = nodes(&pr["reviews"]).into_iter().find(|r| r["viewerDidAuthor"] == true);
Review {
pr_id: pr["id"].as_str().unwrap_or("").to_owned(),
mine: pr["viewerDidAuthor"] != false,
me: String::new(),
pending: pending.as_ref().and_then(|r| r["id"].as_str().map(str::to_owned)),
pending_body: pending.as_ref().and_then(|r| r["body"].as_str()).unwrap_or("").to_owned(),
threads: nodes(&pr["reviewThreads"]).iter().map(parse_thread).collect(),
viewed: nodes(&pr["files"])
.iter()
.filter(|f| f["viewerViewedState"] != "UNVIEWED")
.filter_map(|f| Some((f["path"].as_str()?.to_owned(), f["viewerViewedState"].as_str()?.to_owned())))
.collect(),
}
}
pub const REACTIONS: [(&str, &str); 8] = [
("THUMBS_UP", "👍"),
("THUMBS_DOWN", "👎"),
("LAUGH", "😄"),
("HOORAY", "🎉"),
("CONFUSED", "😕"),
("HEART", "💖"),
("ROCKET", "🚀"),
("EYES", "👀"),
];
pub fn hidden(c: &Value) -> String {
match c["isMinimized"] == true {
true => Some(c["minimizedReason"].as_str().unwrap_or("").to_lowercase().replace(['-', '_'], " ")).filter(|r| !r.is_empty()).unwrap_or_else(|| "hidden".into()),
false => String::new(),
}
}
pub fn hidden_words(why: &str) -> String {
match why {
"hidden" => "hidden on GitHub".into(),
why => format!("hidden on GitHub ({why})"),
}
}
pub type Reaction = (String, u64, bool);
fn reactions(v: &Value, mine: &[&str]) -> Vec<Reaction> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|g| {
let n = g["reactors"]["totalCount"].as_u64().or_else(|| g["users"]["totalCount"].as_u64())?;
let what = g["content"].as_str().unwrap_or("");
(n > 0).then(|| (what.to_owned(), n, g["viewerHasReacted"] == true || mine.contains(&what)))
})
.collect()
}
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], known: bool) {
for (i, (text, mine)) in reaction_parts(r).into_iter().enumerate() {
let mark = match known {
true => mine.then_some(Mark::Mine),
false => Some(Mark::Dim),
};
self.put(if i == 0 { "" } else { " " }, None).put(&text, mark);
}
}
}
const MINE: &str = "id reactionGroups { content viewerHasReacted } comments(last: 100) { pageInfo { hasPreviousPage startCursor } nodes { id reactionGroups { content viewerHasReacted } } } reviews(last: 100) { pageInfo { hasPreviousPage startCursor } nodes { id reactionGroups { content viewerHasReacted } } }";
fn mine_of(p: &Value, mine: &mut serde_json::Map<String, Value>) {
let nodes = std::iter::once(p).chain(["comments", "reviews"].iter().flat_map(|k| p[k]["nodes"].as_array().into_iter().flatten()));
for node in nodes {
let Some(id) = node["id"].as_str() else { continue };
let yours: Vec<Value> = node["reactionGroups"].as_array().into_iter().flatten().filter(|g| g["viewerHasReacted"] == true).map(|g| g["content"].clone()).collect();
mine.insert(id.to_owned(), yours.into());
}
}
fn mine_all(p: &Value) -> Value {
let mut mine = serde_json::Map::new();
mine_of(p, &mut mine);
let older = |kind: &str| {
let mut out = serde_json::Map::new();
let mut page = p[kind]["pageInfo"].clone();
for _ in 0..20 {
let Some(before) = page["startCursor"].as_str().filter(|_| page["hasPreviousPage"] == true) else { break };
let q = format!(
"query($id: ID!, $b: String) {{ node(id: $id) {{ ... on PullRequest {{ {kind}(last: 100, before: $b) {{ pageInfo {{ hasPreviousPage startCursor }} nodes {{ id reactionGroups {{ content viewerHasReacted }} }} }} }} }} }}"
);
let Ok(d) = mutate(&q, json!({ "id": p["id"], "b": before })) else { break };
mine_of(&json!({ kind: d["node"][kind] }), &mut out);
page = d["node"][kind]["pageInfo"].clone();
}
out
};
let more: Vec<&str> = ["comments", "reviews"].into_iter().filter(|k| p[k]["pageInfo"]["hasPreviousPage"] == true).collect();
std::thread::scope(|s| {
let pages: Vec<_> = more.into_iter().map(|k| s.spawn(move || older(k))).collect();
for p in pages {
mine.extend(p.join().unwrap_or_default());
}
});
mine.into()
}
pub fn reactions_of(node: &str) -> Result<Vec<(String, u64, bool)>> {
let q = "query($id: ID!) { node(id: $id) { ... on Reactable { reactionGroups { content viewerHasReacted reactors { totalCount } } } } }";
let d = mutate(q, json!({ "id": node }))?;
Ok(d["node"]["reactionGroups"]
.as_array()
.into_iter()
.flatten()
.map(|g| (g["content"].as_str().unwrap_or("").to_owned(), g["reactors"]["totalCount"].as_u64().unwrap_or(0), g["viewerHasReacted"] == true))
.collect())
}
pub fn react(node: &str, content: &str, add: bool) -> Result<()> {
let op = if add { "addReaction" } else { "removeReaction" };
let q = format!("mutation($id: ID!, $c: ReactionContent!) {{ {op}(input: {{ subjectId: $id, content: $c }}) {{ clientMutationId }} }}");
mutate(&q, json!({ "id": node, "c": content })).map(drop)
}
fn mutate(query: &str, vars: Value) -> Result<Value> {
let body = json!({ "query": query, "variables": vars }).to_string();
let v: Value = serde_json::from_str(&gh_input(&["api", "graphql", "--input", "-"], Some(&body))?)?;
if let Some(e) = v["errors"].get(0) {
bail!("GitHub: {}", e["message"].as_str().unwrap_or("GraphQL error"));
}
Ok(v["data"].clone())
}
fn side(left: bool) -> &'static str {
if left { "LEFT" } else { "RIGHT" }
}
pub fn start_review(pr_id: &str, commit: &str, body: &str) -> Result<String> {
let d = mutate(
"mutation($pr: ID!, $commit: GitObjectID!, $body: String) {
addPullRequestReview(input: { pullRequestId: $pr, commitOID: $commit, body: $body }) { pullRequestReview { id } } }",
json!({ "pr": pr_id, "commit": commit, "body": (!body.is_empty()).then_some(body) }),
)?;
d["addPullRequestReview"]["pullRequestReview"]["id"].as_str().map(str::to_owned).context("no review id")
}
pub fn review_now(pr_id: &str, commit: &str, event: &str, body: &str) -> Result<()> {
mutate(
"mutation($pr: ID!, $commit: GitObjectID!, $event: PullRequestReviewEvent!, $body: String) {
addPullRequestReview(input: { pullRequestId: $pr, commitOID: $commit, event: $event, body: $body }) {
pullRequestReview { id } } }",
json!({ "pr": pr_id, "commit": commit, "event": event, "body": body }),
)
.map(drop)
}
pub fn add_thread(review: &str, a: &Anchor, body: &str) -> Result<()> {
let (start_line, start_side) = a.start.map_or((None, None), |(l, left)| (Some(l), Some(side(left))));
mutate(
"mutation($review: ID!, $path: String!, $body: String!, $line: Int!, $side: DiffSide!,
$startLine: Int, $startSide: DiffSide) {
addPullRequestReviewThread(input: { pullRequestReviewId: $review, path: $path, body: $body,
line: $line, side: $side, startLine: $startLine, startSide: $startSide }) { thread { id } } }",
json!({ "review": review, "path": a.path, "body": body, "line": a.line, "side": side(a.left),
"startLine": start_line, "startSide": start_side }),
)
.map(drop)
}
pub fn add_reply(review: &str, thread: &str, body: &str) -> Result<()> {
mutate(
"mutation($review: ID!, $thread: ID!, $body: String!) {
addPullRequestReviewThreadReply(input: { pullRequestReviewId: $review,
pullRequestReviewThreadId: $thread, body: $body }) { comment { id } } }",
json!({ "review": review, "thread": thread, "body": body }),
)
.map(drop)
}
pub fn submit_review(review: &str, event: &str, body: &str) -> Result<()> {
mutate(
"mutation($review: ID!, $event: PullRequestReviewEvent!, $body: String) {
submitPullRequestReview(input: { pullRequestReviewId: $review, event: $event, body: $body }) {
pullRequestReview { id } } }",
json!({ "review": review, "event": event, "body": body }),
)
.map(drop)
}
pub fn set_review_body(review: &str, body: &str) -> Result<()> {
mutate(
"mutation($review: ID!, $body: String!) {
updatePullRequestReview(input: { pullRequestReviewId: $review, body: $body }) { pullRequestReview { id } } }",
json!({ "review": review, "body": body }),
)
.map(drop)
}
pub fn delete_review(review: &str) -> Result<()> {
mutate(
"mutation($review: ID!) { deletePullRequestReview(input: { pullRequestReviewId: $review }) {
pullRequestReview { id } } }",
json!({ "review": review }),
)
.map(drop)
}
pub fn delete_comment(comment: &str) -> Result<()> {
mutate(
"mutation($c: ID!) { deletePullRequestReviewComment(input: { id: $c }) { clientMutationId } }",
json!({ "c": comment }),
)
.map(drop)
}
pub fn edit_comment(comment: &str, body: &str) -> Result<()> {
mutate(
"mutation($c: ID!, $body: String!) {
updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $c, body: $body }) {
pullRequestReviewComment { id } } }",
json!({ "c": comment, "body": body }),
)
.map(drop)
}
pub fn set_viewed(pr_id: &str, path: &str, viewed: bool) -> Result<()> {
let q = match viewed {
true => "mutation($pr: ID!, $path: String!) { markFileAsViewed(input: { pullRequestId: $pr, path: $path }) { clientMutationId } }",
false => "mutation($pr: ID!, $path: String!) { unmarkFileAsViewed(input: { pullRequestId: $pr, path: $path }) { clientMutationId } }",
};
mutate(q, json!({ "pr": pr_id, "path": path })).map(drop)
}
pub fn rerun_failed(head: &str) -> Result<String> {
let q = ".[] | select(.conclusion == \"failure\" or .conclusion == \"timed_out\" or .conclusion == \"cancelled\") | .databaseId";
let out = gh(&["run", "list", "--commit", head, "--limit", "100", "--json", "databaseId,conclusion", "-q", q])?;
let ids: Vec<&str> = out.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
if ids.is_empty() {
return Ok("no failed runs to rerun (they may be running already)".into());
}
let mut errors = vec![];
for batch in ids.chunks(4) {
std::thread::scope(|s| {
let runs: Vec<_> = batch.iter().map(|id| s.spawn(move || gh(&["run", "rerun", id, "--failed"]).err().map(|e| format!("run {id}: {e:#}")))).collect();
errors.extend(runs.into_iter().filter_map(|r| r.join().unwrap_or_else(|_| Some("a rerun went wrong".into()))));
});
}
rerun_says(ids.len(), &errors)
}
fn rerun_says(total: usize, errors: &[String]) -> Result<String> {
let rerun = total - errors.len();
match (rerun, errors.first()) {
(0, Some(e)) => anyhow::bail!("no failed run could be rerun: {e}"),
(_, Some(e)) => Ok(format!("{rerun} of {total} failed runs rerunning ({} couldn't be: {e})", errors.len())),
_ => Ok(format!("{rerun} failed run(s) rerunning")),
}
}
fn percent(s: &str) -> String {
s.bytes().map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
b => format!("%{b:02X}"),
}).collect()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Field {
Label,
Assignee,
Reviewer,
}
pub fn pr_fields(n: u64) -> Result<(Choices, Choices, Choices)> {
let v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", "labels,assignees,reviewRequests,author"])?)?;
let on = |k: &str, f: &str| -> Vec<String> { v[k].as_array().into_iter().flatten().filter_map(|x| x[f].as_str().map(str::to_owned)).collect() };
let (labels, assigned) = (on("labels", "name"), on("assignees", "login"));
let asked: Vec<String> = v["reviewRequests"]
.as_array()
.into_iter()
.flatten()
.filter_map(|r| r["login"].as_str().or_else(|| r["slug"].as_str()).map(str::to_owned))
.collect();
let all: Value = serde_json::from_str(&gh(&["label", "list", "--limit", "500", "--json", "name"])?)?;
let all: Vec<String> = all.as_array().into_iter().flatten().filter_map(|x| x["name"].as_str().map(str::to_owned)).collect();
let people = gh(&["api", "--paginate", "repos/{owner}/{repo}/assignees", "-q", ".[].login"]).unwrap_or_default();
let mark = |all: Vec<String>, on: &[String]| {
let mut out: Vec<(String, bool)> = all.into_iter().chain(on.iter().cloned()).map(|x| (x.clone(), on.contains(&x))).collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_lowercase().cmp(&b.0.to_lowercase())));
out.dedup();
out
};
let people: Vec<String> = people.lines().map(str::to_owned).collect();
let author = v["author"]["login"].as_str().unwrap_or("");
let reviewers = people.iter().filter(|p| *p != author).cloned().collect();
Ok((mark(all, &labels), mark(people, &assigned), mark(reviewers, &asked)))
}
pub type Choices = Vec<(String, bool)>;
pub fn set_field(n: u64, field: Field, name: &str, on: bool) -> Result<()> {
if field == Field::Label {
let ep = format!("repos/{{owner}}/{{repo}}/issues/{n}/labels");
return match on {
true => gh(&["api", "-X", "POST", &ep, "-f", &format!("labels[]={name}")]).map(drop),
false => gh(&["api", "-X", "DELETE", &format!("{ep}/{}", percent(name))]).map(drop),
};
}
let flag = match (field, on) {
(Field::Label, _) => unreachable!("labels go through the API, above"),
(Field::Assignee, true) => "--add-assignee",
(Field::Assignee, false) => "--remove-assignee",
(Field::Reviewer, true) => "--add-reviewer",
(Field::Reviewer, false) => "--remove-reviewer",
};
gh(&["pr", "edit", &n.to_string(), flag, name]).map(drop)
}
pub fn set_resolved(thread: &str, resolved: bool) -> Result<()> {
let q = match resolved {
true => "mutation($t: ID!) { resolveReviewThread(input: { threadId: $t }) { thread { isResolved } } }",
false => "mutation($t: ID!) { unresolveReviewThread(input: { threadId: $t }) { thread { isResolved } } }",
};
mutate(q, json!({ "t": thread })).map(drop)
}
impl Thread {
pub fn stale(&self) -> bool {
self.resolved || self.outdated
}
}
fn parse_thread(t: &Value) -> Thread {
let line = |k: &str| t[k].as_u64().map(|n| n as u32);
let comments = t["comments"]["nodes"].as_array().map(Vec::as_slice).unwrap_or(&[]);
Thread {
id: t["id"].as_str().unwrap_or("").to_owned(),
path: t["path"].as_str().unwrap_or("").to_owned(),
left: t["diffSide"] == "LEFT",
line: line("line"),
start_line: line("startLine"),
original_line: line("originalLine"),
resolved: t["isResolved"].as_bool().unwrap_or(false),
outdated: t["isOutdated"].as_bool().unwrap_or(false),
hunk: comments
.first()
.and_then(|c| c["diffHunk"].as_str())
.map(|h| {
let lines: Vec<&str> = h.lines().filter(|l| !l.starts_with("@@")).collect();
lines[lines.len().saturating_sub(4)..].iter().map(|l| l.to_string()).collect()
})
.unwrap_or_default(),
comments: comments
.iter()
.map(|c| Comment {
id: c["databaseId"].as_u64().unwrap_or(0),
node_id: c["id"].as_str().unwrap_or("").to_owned(),
can_edit: c["viewerCanUpdate"] == true,
can_delete: c["viewerCanDelete"] == true,
author: c["author"]["login"].as_str().unwrap_or("ghost").to_owned(),
body: c["body"].as_str().unwrap_or("").replace("\r\n", "\n"),
created: c["createdAt"].as_str().unwrap_or("").to_owned(),
pending: c["state"] == "PENDING",
reactions: reactions(&c["reactionGroups"], &[]),
hidden: hidden(c),
})
.collect(),
claude: None,
severity: String::new(),
about: None,
conversation: String::new(),
want: String::new(),
}
}
fn post(ep: &str, body: &Value) -> Result<()> {
gh_input(&["api", "-X", "POST", ep, "--input", "-"], Some(&body.to_string())).map(drop)
}
pub fn comment(pr: &Pr, a: &Anchor, body: &str) -> Result<()> {
let ep = format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number);
let mut v = json!({ "body": body, "commit_id": pr.head_oid, "path": a.path, "line": a.line, "side": side(a.left) });
if let Some((l, left)) = a.start {
(v["start_line"], v["start_side"]) = (json!(l), json!(side(left)));
}
post(&ep, &v)
}
pub fn conversation_comment(pr: &Pr, body: &str) -> Result<()> {
let ep = format!("repos/{{owner}}/{{repo}}/issues/{}/comments", pr.number);
post(&ep, &json!({ "body": body }))
}
pub fn create_pr(base: &str, head: &str, title: &str, body: &str) -> Result<u64> {
let args = ["pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", "-"];
let out = gh_input(&args, Some(body))?;
let url = out.lines().rev().find(|l| l.contains("/pull/")).context("gh pr create printed no PR URL")?;
url.trim().rsplit('/').next().and_then(|n| n.parse().ok()).with_context(|| format!("no PR number in {url}"))
}
pub fn close_with(n: u64, comment: &str) -> Result<()> {
gh(&["pr", "close", &n.to_string(), "--comment", comment]).map(drop)
}
pub fn close(pr: &Pr) -> Result<()> {
gh(&["pr", "close", &pr.number.to_string()]).map(drop)
}
pub fn reply(pr: &Pr, comment_id: u64, body: &str) -> Result<()> {
let ep = format!("repos/{{owner}}/{{repo}}/pulls/{}/comments/{comment_id}/replies", pr.number);
post(&ep, &json!({ "body": body }))
}
#[derive(Clone, Debug, PartialEq)]
pub enum Mark {
Chip(String),
Pass,
Fail,
Pending,
Dim,
Mine,
Link(String),
}
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 key: String,
}
impl InfoLine {
fn new(heading: bool, md: bool) -> Self {
InfoLine { heading, md, text: String::new(), marks: vec![], hidden: String::new(), key: String::new(), url: String::new(), node: String::new() }
}
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 fn awaiting_approval(sha: Option<&str>) -> Result<std::collections::HashMap<String, Vec<u64>>> {
let mut ep = "repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100".to_owned();
if let Some(s) = sha {
ep += &format!("&head_sha={s}");
}
let v: Value = serde_json::from_str(&gh(&["api", &ep])?)?;
let mut out: std::collections::HashMap<String, Vec<u64>> = Default::default();
for r in v["workflow_runs"].as_array().into_iter().flatten() {
if let (Some(sha), Some(id)) = (r["head_sha"].as_str(), r["id"].as_u64()) {
out.entry(sha.to_owned()).or_default().push(id);
}
}
Ok(out)
}
pub fn approve_runs(sha: &str) -> Result<usize> {
let ids = awaiting_approval(Some(sha))?.remove(sha).unwrap_or_default();
for id in &ids {
gh(&["api", "-X", "POST", &format!("repos/{{owner}}/{{repo}}/actions/runs/{id}/approve")])?;
}
Ok(ids.len())
}
#[derive(Debug)]
pub struct Signatures {
pub commits: Vec<(String, bool)>,
pub total: usize,
pub required: bool,
}
const SIGNATURE_RULES: &str = "baseRef { branchProtectionRule { requiresCommitSignatures } rules(first: 100) { nodes { type } } }";
fn requires_signatures(p: &Value) -> bool {
let b = &p["baseRef"];
b["branchProtectionRule"]["requiresCommitSignatures"] == true || b["rules"]["nodes"].as_array().is_some_and(|r| r.iter().any(|r| r["type"] == "REQUIRED_SIGNATURES"))
}
pub fn signatures(n: u64) -> Result<Signatures> {
signatures_whole(&pr_query(n, "")?).map_err(anyhow::Error::msg)
}
fn signatures_whole(a: &PrAnswer) -> std::result::Result<Signatures, String> {
if let Some((_, why)) = a.refused.iter().find(|r| r.0 == "commits" || r.0 == "?") {
return Err(why.clone());
}
signatures_of(&a.node).map_err(|e| format!("{e:#}"))
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").to_owned()
}
const TOO_BIG: [&str; 3] = ["MAX_NODE_LIMIT_EXCEEDED", "RESOURCE_LIMITS_EXCEEDED", "TIMEOUT"];
fn too_big(r: &Refused) -> bool {
r.kinds.iter().all(|k| TOO_BIG.contains(&k.as_str())) }
fn extras(first: Result<PrAnswer>, alone: impl FnOnce() -> Result<PrAnswer>) -> (Value, std::result::Result<Signatures, String>) {
let answer = match first {
Err(e) if e.downcast_ref::<Refused>().is_some_and(too_big) => alone().map_err(|e2| anyhow::anyhow!("{e:#}; and asked alone: {e2:#}")),
r => r,
};
match answer {
Ok(a) => (a.node.clone(), signatures_whole(&a).map_err(|w| first_line(&w))),
Err(e) => (Value::Null, Err(first_line(&format!("{e:#}")))),
}
}
#[derive(Debug)]
struct PrAnswer {
node: Value,
refused: Vec<(String, String)>,
}
#[derive(Debug)]
struct Refused {
kinds: Vec<String>,
why: String,
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "GitHub: {}", self.why)
}
}
impl std::error::Error for Refused {}
fn pr_answer(v: &Value, stderr: &str) -> Result<PrAnswer> {
let node = &v["data"]["repository"]["pullRequest"];
let errors = v["errors"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let why_of = |e: &Value| e["message"].as_str().unwrap_or("refused").to_owned();
if !node.is_object() {
if let Some(first) = errors.first() {
let kinds: Vec<String> = errors.iter().map(|e| e["type"].as_str().unwrap_or("").to_owned()).collect();
let telling = |i: &usize| !kinds[*i].is_empty() && !TOO_BIG.contains(&kinds[*i].as_str()) && errors[*i]["message"].is_string();
let why = (0..errors.len()).find(telling).map_or(first, |i| &errors[i]);
return Err(Refused { why: why_of(why), kinds }.into());
}
let why = v["message"].as_str().unwrap_or(stderr.trim());
let late = ["HTTP 502", "HTTP 504", "in time"].iter().any(|w| why.contains(w) || stderr.contains(w));
if late {
return Err(Refused { kinds: vec!["TIMEOUT".into()], why: why.trim().to_owned() }.into());
}
anyhow::bail!("GitHub: {}", if why.is_empty() { "no answer" } else { why });
}
let refused = errors
.iter()
.map(|e| (e["path"].get(2).and_then(Value::as_str).unwrap_or("?").to_owned(), why_of(e)))
.collect();
Ok(PrAnswer { node: node.clone(), refused })
}
fn pr_query(n: u64, more: &str) -> Result<PrAnswer> {
let q = format!(
"query($owner: String!, $name: String!, $n: Int!) {{ repository(owner: $owner, name: $name) {{ pullRequest(number: $n) {{
{SIGNATURE_RULES} commits(last: 100) {{ totalCount nodes {{ commit {{ oid signature {{ isValid }} }} }} }} {more} }} }} }}"
);
let out = Command::new("gh")
.args(["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}", "-F", &format!("n={n}"), "-f", &format!("query={q}")])
.stdin(Stdio::null())
.output()
.context("running gh")?;
let v = serde_json::from_slice::<Value>(&out.stdout).unwrap_or_default();
pr_answer(&v, &String::from_utf8_lossy(&out.stderr))
}
fn signatures_of(p: &Value) -> Result<Signatures> {
let nodes = p["commits"]["nodes"].as_array().context("gh api graphql: commits")?;
Ok(Signatures {
commits: nodes.iter().map(|c| (c["commit"]["oid"].as_str().unwrap_or("").to_owned(), c["commit"]["signature"]["isValid"] == true)).collect(),
total: p["commits"]["totalCount"].as_u64().unwrap_or(0) as usize,
required: requires_signatures(p),
})
}
pub fn unsigned_blocking(n: u64) -> Result<Option<String>> {
let s = signatures(n)?;
let unsigned: Vec<String> = s.commits.into_iter().filter(|c| !c.1).map(|c| c.0.chars().take(8).collect()).collect();
Ok((s.required && !unsigned.is_empty()).then(|| {
format!("{} of #{n}'s commits aren't signed ({}), and its base requires signed commits: GitHub would refuse the push; ask the author to sign them", unsigned.len(), unsigned.join(", "))
}))
}
const INFO_FIELDS: &str = "headRefOid,title,author,state,isDraft,url,baseRefName,headRefName,additions,deletions,\
changedFiles,labels,assignees,reviewDecision,reviewRequests,latestReviews,statusCheckRollup,body,comments,reviews,id,reactionGroups";
pub fn keep_signatures(new: &mut Value, old: &Value) {
if new["dvCommitsUnknown"].is_null() || old["dvCommits"].as_array().is_none_or(|c| c.is_empty()) || new["headRefOid"] != old["headRefOid"] {
return;
}
for k in ["dvCommits", "dvCommitsTotal", "dvSignaturesRequired", "dvCommitsAt"] {
new[k] = old[k].clone();
}
new["dvCommitsKept"] = true.into();
if let Some(o) = new.as_object_mut() {
o.remove("dvCommitsUnknown");
}
}
pub fn info_json(n: u64) -> Result<Value> {
let mut v: Value = serde_json::from_str(&gh(&["pr", "view", &n.to_string(), "--json", INFO_FIELDS])?)?;
if let Some(sha) = v["headRefOid"].as_str().map(str::to_owned)
&& let Ok(w) = awaiting_approval(Some(&sha))
{
v["dvAwaiting"] = w.get(&sha).map_or(0, Vec::len).into();
}
let (node, signatures) = extras(pr_query(n, MINE), || pr_query(n, ""));
v["dvMine"] = mine_all(&node);
match signatures {
Ok(s) => {
v["dvCommits"] = s.commits.into_iter().map(|(oid, signed)| json!({ "oid": oid, "signed": signed })).collect();
v["dvCommitsTotal"] = s.total.into();
v["dvSignaturesRequired"] = s.required.into();
v["dvCommitsAt"] = crate::claude::now().into();
}
Err(why) => v["dvCommitsUnknown"] = why.into(),
}
Ok(v)
}
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 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 = v["statusCheckRollup"].as_array().into_iter().flatten().filter(|c| verdict(c) == '✗').count();
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") }
}
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 plain = |text: String| {
let mut l = InfoLine::new(false, false);
l.put(&text, None);
l
};
let author = s(&v["author"]["login"]);
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);
out.push(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"]))));
}
out.push(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!(" @{}", s(&a["login"])), None);
}
out.push(l);
}
let mine = |id: &Value| -> Vec<&str> { id.as_str().and_then(|id| v["dvMine"][id].as_array()).into_iter().flatten().filter_map(Value::as_str).collect() };
let known = |id: &Value| id.as_str().is_some_and(|id| v["dvMine"].get(id).is_some()) || v["dvMineSeen"].as_array().is_some_and(|s| s.contains(id));
let own = reactions(&v["reactionGroups"], &mine(&v["id"]));
if !own.is_empty() {
let mut l = InfoLine::new(false, false);
l.put("reactions ", Some(Mark::Dim)).put_reactions(&own, known(&v["id"]));
out.push(l);
}
let reviews = list(&v["latestReviews"]);
let by = |state: &str| -> Vec<String> { reviews.iter().filter(|r| r["state"] == state).map(|r| s(&r["author"]["login"])).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));
}
out.push(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);
out.push(l);
}
}
let checks = list(&v["statusCheckRollup"]);
if checks.is_empty() {
out.push(plain("checks: none".into()));
} else {
let count = |m| checks.iter().filter(|c| verdict(c) == m).count();
let (pass, fail, pending) = (count('✓'), count('✗'), count('●'));
let mut l = InfoLine::new(false, false);
l.put("checks", Some(Mark::Link(format!("{}/checks", s(&v["url"])))));
l.put(": ", None);
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));
}
out.push(l);
for c in checks.iter().filter(|c| verdict(c) != '✓') {
let name = c["name"].as_str().or(c["context"].as_str()).unwrap_or("?");
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)),
};
out.push(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));
out.push(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));
}
out.push(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));
out.push(l);
}
let heading = |t: String| {
let mut l = InfoLine::new(true, false);
l.put(&t, None);
l
};
let markdown = |t: String| {
let mut l = InfoLine::new(false, true);
l.put(&t, None);
l
};
out.push(InfoLine { url: s(&v["url"]), node: s(&v["id"]), ..heading("description".into()) });
let body = s(&v["body"]).replace("\r\n", "\n");
out.push(markdown(body));
type Said<'a> = (String, String, &'a str, String, String, String, Vec<Reaction>, String);
let mut said: Vec<Said> = list(&v["comments"])
.iter()
.map(|c| (s(&c["createdAt"]), s(&c["author"]["login"]), "", s(&c["url"]), s(&c["body"]), s(&c["id"]), reactions(&c["reactionGroups"], &mine(&c["id"])), hidden(c)))
.collect();
for r in list(&v["reviews"]) {
let what = match s(&r["state"]).as_str() {
"APPROVED" => "✓ approved · ",
"CHANGES_REQUESTED" => "✗ requested changes · ",
"DISMISSED" => "reviewed (dismissed) · ",
"COMMENTED" if !s(&r["body"]).trim().is_empty() => "reviewed · ",
_ => continue, };
let url = format!("{}#review-{}", s(&v["url"]), s(&r["id"]));
said.push((s(&r["submittedAt"]), s(&r["author"]["login"]), what, url, s(&r["body"]), s(&r["id"]), reactions(&r["reactionGroups"], &mine(&r["id"])), String::new()));
}
said.sort_by(|a, b| a.0.cmp(&b.0)); out.push(heading(format!("conversation ({})", said.len())));
for (at, who, what, url, body, node, r, hidden) in said {
let sure = known(&Value::from(node.as_str()));
let mut h = InfoLine { url, node, hidden, ..InfoLine::new(true, false) };
let mark = match what {
"✓ approved · " => Some(Mark::Pass),
"✗ requested changes · " => Some(Mark::Fail),
_ => None,
};
h.put(&format!("@{who} · "), None).put(what.trim_end_matches(" · "), mark).put(if what.is_empty() { "" } else { " · " }, None);
h.put(&ago(&at), None);
if !r.is_empty() {
h.put(" · ", None).put_reactions(&r, sure);
}
out.push(h);
out.push(markdown(body.replace("\r\n", "\n")));
}
for i in 0..out.len() {
out[i].key = line_key(&out[..=i]);
}
out
}
fn line_key(lines: &[InfoLine]) -> String {
let (l, before) = lines.split_last().unwrap_or_else(|| unreachable!());
match before.last().filter(|h| h.heading && !l.heading && !h.node.is_empty()) {
_ if !l.node.is_empty() => l.node.clone(),
Some(h) => format!("{}/text", h.node),
None => l.text.split([':', '(']).next().unwrap_or("").split(|c: char| c.is_ascii_digit()).next().unwrap_or("").trim().to_owned(),
}
}
#[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");
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())); assert!(t.contains(&"reviews: none yet".to_owned()));
}
pub fn ago(iso: &str) -> String {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
ago_at(iso, now)
}
fn ago_at(iso: &str, now: i64) -> String {
let Some(t) = epoch_secs(iso) else { return iso.get(..10).unwrap_or(iso).to_owned() };
let d = (now - t).max(0);
match d {
0..60 => "just now".into(),
60..3600 => format!("{}m ago", d / 60),
3600..86400 => format!("{}h ago", d / 3600),
86400..2592000 => format!("{}d ago", d / 86400),
2592000..31536000 => format!("{}mo ago", d / 2592000),
_ => format!("{}y ago", d / 31536000),
}
}
pub(crate) fn epoch_secs(iso: &str) -> Option<i64> {
let n = |r: std::ops::Range<usize>| iso.get(r)?.parse::<i64>().ok();
let (y, m, d) = (n(0..4)?, n(5..7)?, n(8..10)?);
let (h, mi, s) = (n(11..13).unwrap_or(0), n(14..16).unwrap_or(0), n(17..19).unwrap_or(0));
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some((era * 146097 + doe - 719468) * 86400 + h * 3600 + mi * 60 + s)
}
#[test]
fn issue_links() {
assert_eq!(issue_ref("https://github.com/o/r/issues/12"), Some(("o", "r", 12)));
assert_eq!(issue_ref("https://github.com/o/r/pull/7#discussion_r1"), Some(("o", "r", 7)));
assert_eq!(issue_ref("https://github.com/o/r/pull/7/files?w=1"), Some(("o", "r", 7)));
assert_eq!(issue_ref("https://github.com/amy"), None);
assert_eq!(issue_ref("https://github.com/o/r/blob/main/x.rs"), None);
}
#[test]
fn relative_time() {
assert_eq!(epoch_secs("1970-01-02T00:00:00Z"), Some(86400));
assert_eq!(epoch_secs("2000-03-01"), Some(951868800));
let now = epoch_secs("2026-10-16T12:00:00Z").unwrap();
assert_eq!(ago_at("2026-10-16T11:59:30Z", now), "just now");
assert_eq!(ago_at("2026-10-16T09:00:00Z", now), "3h ago");
assert_eq!(ago_at("2026-10-14T12:00:00Z", now), "2d ago");
assert_eq!(ago_at("2026-08-14T12:00:00Z", now), "2mo ago");
assert_eq!(ago_at("garbage", now), "garbage");
}
#[test]
fn parses_review() {
let r = parse_review(&json!({
"id": "PR_1",
"reviews": { "nodes": [{ "id": "R_other", "viewerDidAuthor": false }, { "id": "R_mine", "viewerDidAuthor": true }] },
"files": { "nodes": [{ "path": "a.rs", "viewerViewedState": "VIEWED" }, { "path": "b.rs", "viewerViewedState": "UNVIEWED" }] },
"reviewThreads": { "nodes": [{ "id": "T_1", "path": "a.rs", "comments": { "nodes": [{ "id": "C_1", "databaseId": 1, "state": "PENDING", "viewerCanUpdate": true }] } }] },
}));
assert_eq!((r.pr_id.as_str(), r.pending.as_deref()), ("PR_1", Some("R_mine")));
assert_eq!(r.viewed.len(), 1);
assert_eq!(r.viewed["a.rs"], "VIEWED");
assert_eq!((r.threads[0].id.as_str(), r.threads[0].comments[0].pending), ("T_1", true));
assert_eq!((r.threads[0].comments[0].node_id.as_str(), r.threads[0].comments[0].can_edit), ("C_1", true));
}
#[test]
fn parses_thread() {
let t = parse_thread(&serde_json::json!({
"path": "src/a.rs", "line": 12, "startLine": 10, "originalLine": 12, "diffSide": "RIGHT",
"isResolved": false, "isOutdated": false,
"comments": { "nodes": [{ "databaseId": 7, "author": { "login": "amy" }, "body": "a\r\nb", "createdAt": "2026-10-01T00:00:00Z",
"diffHunk": "@@ -1,2 +1,2 @@\n fn a() {\n+ let x = 1;" }] }
}));
assert_eq!((t.left, t.line, t.start_line), (false, Some(12), Some(10)));
assert_eq!(t.hunk, [" fn a() {", "+ let x = 1;"]);
assert_eq!((t.comments[0].id, t.comments[0].body.as_str(), t.comments[0].created.as_str()), (7, "a\nb", "2026-10-01T00:00:00Z"));
}
#[test]
fn review_pages() {
let page = |next: Option<&str>, nodes: Value| json!({ "pageInfo": { "hasNextPage": next.is_some(), "endCursor": next }, "nodes": nodes });
let comment = |id: u64| json!({ "id": format!("C{id}"), "databaseId": id, "body": "", "createdAt": "" });
let thread = |id: &str, comments: Value| json!({ "id": id, "path": "a.rs", "line": 1, "comments": comments });
let pr = |k: &str, v: Value| json!({ "repository": { "pullRequest": { k: v } } });
let mut calls = vec![];
let d = fetch_review(|q, vars| {
calls.push(vars.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(" "));
Ok(if q.contains("viewer { login }") {
let mut d = pr("reviewThreads", page(Some("t1"), json!([thread("T1", page(Some("c1"), json!([comment(1)])))])));
d["repository"]["pullRequest"]["files"] = page(Some("f1"), json!([{ "path": "a.rs", "viewerViewedState": "VIEWED" }]));
d["viewer"] = json!({ "login": "me" });
d
} else if q.contains("node(id") {
json!({ "node": { "comments": page(None, json!([comment(2)])) } })
} else if q.contains("reviewThreads") {
pr("reviewThreads", page(None, json!([thread("T2", page(None, json!([comment(3)])))])))
} else {
pr("files", page(None, json!([{ "path": "b.rs", "viewerViewedState": "DISMISSED" }])))
})
})
.unwrap();
assert_eq!(calls, ["", "after=t1", "after=f1", "id=T1 after=c1"]);
let r = parse_review(&d["repository"]["pullRequest"]);
let ids: Vec<Vec<u64>> = r.threads.iter().map(|t| t.comments.iter().map(|c| c.id).collect()).collect();
assert_eq!(ids, [vec![1, 2], vec![3]]);
assert_eq!(r.viewed.len(), 2);
}
#[test]
fn reviews_in_the_conversation() {
let v = serde_json::json!({
"url": "https://github.com/o/r/pull/1", "body": "",
"comments": [{ "author": { "login": "amy" }, "body": "first", "createdAt": "2026-01-01T00:00:00Z", "url": "c1" }],
"reviews": [
{ "id": "R3", "author": { "login": "me" }, "state": "COMMENTED", "body": "looks close", "submittedAt": "2026-01-03T00:00:00Z" },
{ "id": "R2", "author": { "login": "bo" }, "state": "APPROVED", "body": "", "submittedAt": "2026-01-02T00:00:00Z" },
{ "author": { "login": "cy" }, "state": "COMMENTED", "body": "", "submittedAt": "2026-01-04T00:00:00Z" },
{ "author": { "login": "me" }, "state": "PENDING", "body": "draft", "submittedAt": "" },
],
});
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("conversation")).unwrap();
assert_eq!(text[at], "conversation (3)"); let rest = &text[at + 1..];
assert!(rest[0].starts_with("@amy · ") && rest[1] == "first");
assert!(rest[2].starts_with("@bo · ✓ approved · ") && rest[3].is_empty());
assert!(rest[4].starts_with("@me · reviewed · ") && rest[5] == "looks close");
let urls: Vec<&str> = lines[lines.len() - 6..].iter().step_by(2).map(|l| l.url.as_str()).collect();
assert_eq!(urls, ["c1", "https://github.com/o/r/pull/1#review-R2", "https://github.com/o/r/pull/1#review-R3"]);
}
#[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 } }]);
let v = json!([{ "content": "HOORAY", "users": { "totalCount": 1 } }]);
assert_eq!(reactions(&g, &[]), [("THUMBS_UP".to_owned(), 2, false)]);
assert_eq!(reactions_text(&[reactions(&g, &[]), reactions(&v, &[])].concat()), "👍 2 🎉 1");
let g = json!([{ "content": "EYES", "viewerHasReacted": true, "reactors": { "totalCount": 1 } }]);
assert_eq!(reactions(&g, &[]), [("EYES".to_owned(), 1, true)]);
assert_eq!(reactions(&v, &["HOORAY"]), [("HOORAY".to_owned(), 1, true)]);
}
#[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)));
}
}
#[test]
#[ignore]
fn live_info() {
let n: u64 = std::env::var("DV_LIVE_PR").expect("DV_LIVE_PR").parse().unwrap();
let v = info_json(n).unwrap();
assert!(v["dvCommits"].is_array() && v["dvMine"].is_object(), "{}", v["dvCommits"]);
}
#[test]
fn rerun_summary() {
assert_eq!(rerun_says(3, &[]).unwrap(), "3 failed run(s) rerunning");
assert_eq!(rerun_says(3, &["run 7: too old".into()]).unwrap(), "2 of 3 failed runs rerunning (1 couldn't be: run 7: too old)");
assert!(rerun_says(1, &["run 7: too old".into()]).is_err());
}
#[test]
fn github_refusals() {
let commits = json!({ "totalCount": 1, "nodes": [{ "commit": { "oid": "a", "signature": null } }] });
let answer = |errors: Value| json!({ "data": { "repository": { "pullRequest": { "commits": commits, "baseRef": null } } }, "errors": errors });
let a = pr_answer(&answer(json!([{ "path": ["repository", "pullRequest", "comments"] }])), "").unwrap();
assert!(signatures_whole(&a).is_ok());
for e in [json!([{ "path": ["repository", "pullRequest", "commits", "nodes", 0] }]), json!([{ "message": "timeout" }])] {
assert!(signatures_whole(&pr_answer(&answer(e), "").unwrap()).is_err());
}
let e = pr_answer(&json!({ "data": null, "errors": [{ "type": "RATE_LIMITED", "message": "API rate limit exceeded" }] }), "").unwrap_err();
assert_eq!(format!("{e:#}"), "GitHub: API rate limit exceeded");
assert!(e.is::<Refused>());
let both = json!({ "data": null, "errors": [{ "type": "MAX_NODE_LIMIT_EXCEEDED", "message": "too many nodes" }, { "type": "RATE_LIMITED", "message": "API rate limit exceeded" }] });
let e = pr_answer(&both, "").unwrap_err();
let r = e.downcast_ref::<Refused>().unwrap();
assert_eq!((r.kinds.len(), too_big(r), format!("{e:#}").as_str()), (2, false, "GitHub: API rate limit exceeded"));
let e = pr_answer(&json!({ "message": "Bad credentials" }), "gh: Bad credentials (HTTP 401)").unwrap_err();
assert_eq!(format!("{e:#}"), "GitHub: Bad credentials");
assert!(!e.is::<Refused>());
let a = pr_answer(&answer(json!([{ "path": ["repository", "pullRequest", "commits"], "message": "timed out\nmore" }])), "").unwrap();
assert_eq!(extras(Ok(a), || unreachable!()).1.unwrap_err(), "timed out");
let refused = |kinds: &[&str]| -> Result<PrAnswer> { Err(Refused { kinds: kinds.iter().map(|k| k.to_string()).collect(), why: "too big".into() }.into()) };
let alone = || pr_answer(&answer(json!([])), "");
for kinds in [&["MAX_NODE_LIMIT_EXCEEDED"][..], &["TIMEOUT"], &["RESOURCE_LIMITS_EXCEEDED", "TIMEOUT"]] {
let (node, sig) = extras(refused(kinds), alone);
let mut mine = serde_json::Map::new();
mine_of(&node, &mut mine);
assert!(sig.is_ok() && mine.is_empty(), "{kinds:?}"); }
let never: [&[&str]; 7] = [&[""], &["RATE_LIMITED"], &["SECONDARY_RATE_LIMIT"], &["FORBIDDEN"], &["NOT_FOUND"], &["EXCESSIVE_PAGINATION"], &["MAX_NODE_LIMIT_EXCEEDED", "RATE_LIMITED"]];
for kinds in never {
let (_, sig) = extras(refused(kinds), || unreachable!());
assert_eq!(sig.unwrap_err(), "GitHub: too big");
}
}
#[test]
fn gateway_timeout_asked_again_smaller() {
let refused = |v: Value, stderr: &str| pr_answer(&v, stderr).err().and_then(|e| e.downcast::<Refused>().ok());
let late = refused(serde_json::json!({ "message": "We couldn't respond to your request in time. Sorry about that." }), "gh: HTTP 504");
assert!(late.is_some_and(|r| too_big(&r)));
assert!(refused(serde_json::json!(null), "HTTP 502: Bad Gateway").is_some_and(|r| too_big(&r)));
assert!(refused(serde_json::json!({ "message": "Bad credentials" }), "HTTP 401").is_none());
}
#[test]
fn info_lines_keep_their_keys() {
let keys = |v: Value| info_lines(&v).into_iter().map(|l| l.key).collect::<Vec<_>>();
let pr = |n: usize| json!({ "id": "PR_1", "statusCheckRollup": [], "comments": (0..n).map(|i| json!({ "id": format!("IC_{i}"), "author": { "login": "amy" }, "body": "hi", "createdAt": "2026-10-01T00:00:00Z" })).collect::<Vec<_>>() });
let (one, two) = (keys(pr(1)), keys(pr(2)));
for k in ["conversation", "IC_0", "IC_0/text"] {
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_signatures(&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_signatures(&mut moved, &old);
assert!(moved["dvCommitsKept"].is_null() && !moved["dvCommitsUnknown"].is_null());
}