use crate::gh::{awaiting_approval, etag_of, gh_json, next_page, requires_signatures, PAGE, SIGNATURE_RULES};
use super::*;
use anyhow::{Context, Result};
use serde_json::Value;
#[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 optional_failing: 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: crate::gh::login(&p["author"]),
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,
optional_failing: 0,
checks_head: String::new(),
}
}
pub const LIST_MAX: usize = 1000;
pub fn list() -> Result<Vec<Listed>> {
let v: Value = gh_json(&["pr", "list", "--limit", &LIST_MAX.to_string(), "--json", LISTED])?;
Ok(v.as_array().context("gh pr list")?.iter().map(listed).collect())
}
pub fn listed_one(n: u64) -> Result<Listed> {
let v: Value = gh_json(&["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, i64)>,
pub said: Vec<(String, String, i64)>,
pub requested: bool,
pub mergeable: String,
pub head_oid: String,
pub base: String,
pub unsigned: usize,
pub signatures_required: bool,
pub awaiting: usize,
pub unanswered: Option<(i64, String, bool)>,
}
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 author {{ login }} {SIGNATURE_RULES}
commits(last: 100) {{ nodes {{ commit {{ committedDate authoredDate committer {{ user {{ login }} }} author {{ user {{ login }} }} signature {{ isValid }} }} }} }}
reviewRequests(first: 30) {{ nodes {{ requestedReviewer {{ ... on User {{ login }} }} }} }}
latestReviews(first: 50) {{ nodes {{ author {{ login }} state commit {{ oid }} }} }}
reviews(last: 50) {{ nodes {{ author {{ login }} authorAssociation state submittedAt }} }}
comments(last: 20) {{ nodes {{ author {{ login }} authorAssociation body createdAt }} }} }} }} }} }}"
);
let v: Value = gh_json(&["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}", "-f", &format!("query={q}")])?;
crate::gh::refused(&v)?; 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))
}
pub(super) 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"])));
let at = |v: &Value| crate::gh::epoch_secs(&s(v)).unwrap_or(0);
let author = crate::gh::login(&p["author"]);
let trusted = |c: &Value| c["author"]["login"] == me || matches!(c["authorAssociation"].as_str(), Some("OWNER" | "MEMBER" | "COLLABORATOR"));
let theirs = |c: &Value| {
let by = &c["commit"]["author"];
by.is_object() && (by["user"]["login"] == author.as_str() || by["user"].is_null())
};
let when = |c: &Value| {
let by = &c["commit"]["committer"]["user"]["login"];
let other = by.as_str().is_some_and(|by| by != author && by != "web-flow");
at(&c["commit"][if other { "authoredDate" } else { "committedDate" }])
};
let pushed = nodes("commits").iter().filter(|c| theirs(c)).map(when).max().unwrap_or(0);
let by_author = |k: &str, t: &str| nodes(k).into_iter().filter(|c| c["author"]["login"] == author.as_str()).map(|c| at(&c[t])).max().unwrap_or(0);
let last_word = pushed.max(by_author("comments", "createdAt")).max(by_author("reviews", "submittedAt"));
let others = |c: &Value| c["author"]["login"] != author.as_str() && trusted(c);
let reviews = nodes("reviews").into_iter().filter(|r| others(r) && matches!(r["state"].as_str(), Some("CHANGES_REQUESTED" | "COMMENTED")));
let said = reviews
.map(|r| (at(&r["submittedAt"]), crate::gh::login(&r["author"]), true))
.chain(nodes("comments").into_iter().filter(others).map(|c| (at(&c["createdAt"]), crate::gh::login(&c["author"]), false)))
.filter(|(t, ..)| *t > last_word)
.max_by_key(|(t, ..)| *t);
let approved_last = nodes("reviews").iter().filter(|r| others(r) && r["state"] == "APPROVED").map(|r| at(&r["submittedAt"])).max().unwrap_or(0);
let unanswered = said.filter(|(t, ..)| *t > approved_last);
Extra {
my_review,
reviews: nodes("reviews").iter().map(|r| (crate::gh::login(&r["author"]), s(&r["state"]), crate::gh::epoch_secs(&s(&r["submittedAt"])).unwrap_or(0))).collect(),
said: nodes("comments")
.iter()
.filter(|c| trusted(c))
.map(|c| (crate::gh::login(&c["author"]), s(&c["body"]), crate::gh::epoch_secs(&s(&c["createdAt"])).unwrap_or(0)))
.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, unanswered,
}
}
pub type ListChecks = std::collections::HashMap<u64, ((usize, usize, usize), usize, String)>;
pub fn list_checks(ns: Vec<u64>) -> Result<ListChecks> {
Ok(crate::gh::rollups(&ns)?
.into_iter()
.map(|(n, r)| {
let gated = crate::gh::gate(&r.checks);
let optional_failing = r.checks.iter().filter(|c| crate::gh::optional(c, &r.checks) && crate::gh::verdict(c) == '✗').count();
(n, (gated, optional_failing, r.head))
})
.collect())
}
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)
}
impl List {
pub(super) fn refresh(&mut self, term: &mut DefaultTerminal) -> Result<()> {
self.msg = "loading PRs…".into();
term.draw(|f| self.draw(f))?;
self.apply_list(github::list(), false);
Ok(())
}
pub(super) fn watch_github(&mut self) {
let (tx, rx) = mpsc::channel();
let sender = tx.clone();
std::thread::spawn(move || {
let mut etag = github::list_etag("").ok().flatten().unwrap_or_default();
let mut fetched = std::time::Instant::now();
loop {
std::thread::sleep(Duration::from_secs(5));
let changed = match github::list_etag(&etag) {
Ok(Some(e)) => (etag = e, true).1,
_ => false,
};
if changed || fetched.elapsed() >= Duration::from_secs(300) {
fetched = std::time::Instant::now();
if sender.send(github::list()).is_err() {
return; }
}
}
});
self.fresh = Some((tx, rx));
}
pub(super) fn apply_list(&mut self, res: Result<Vec<Listed>>, quiet: bool) {
let keep = self.selected();
self.local = crate::pr_worktrees().into_iter().map(|(n, dir, b)| (n, (dir, b))).collect();
self.scan_unpushed();
self.find_others();
self.reviewed = crate::batch::reviewed();
self.load_reps();
match res {
Ok(mut prs) => {
if quiet {
for p in &mut prs {
if let Some(o) = self.prs.iter().find(|o| o.number == p.number) {
(p.checks, p.optional_failing, p.checks_head) = (o.checks, o.optional_failing, o.checks_head.clone());
}
}
}
let (tx, rx) = mpsc::channel();
let ns: Vec<u64> = prs.iter().map(|p| p.number).collect();
std::thread::spawn(move || tx.send(github::list_checks(ns)));
self.checks = Some(rx);
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || tx.send(github::list_extra()));
self.extra_rx = Some(rx);
if !quiet {
self.msg = if prs.is_empty() { "no open PRs".into() } else { String::new() };
}
let mut wanted: Vec<u64> = self.local.keys().chain(&self.marked).filter(|n| !prs.iter().any(|p| p.number == **n)).copied().collect();
wanted.sort();
wanted.dedup();
let mut missing = vec![];
for n in wanted {
match self.gone.get(&n).filter(|p| !p.open()) {
Some(p) => prs.push(p.clone()),
None => {
if self.gone_pending.insert(n) {
missing.push(n);
}
prs.push(Listed { number: n, title: "(looking it up…)".into(), state: LOOKING.into(), ..Listed::default() });
}
}
}
if !missing.is_empty() {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || _ = tx.send(missing.into_iter().map(|n| (n, github::listed_one(n).ok())).collect()));
self.gone_rx.push(rx);
}
self.prs = prs;
self.prune_marks();
self.find_new();
self.find_todo();
self.select_card(keep);
}
Err(e) => self.msg = format!("{e:#}"),
}
}
pub(super) fn watch_reviews(&mut self) {
let Some(dir) = &self.reviews_dir else { return };
let mtime = std::fs::metadata(dir).and_then(|m| m.modified()).ok();
if mtime != self.reviews_mtime {
self.reviews_mtime = mtime;
self.reviewed = crate::batch::reviewed_in(dir);
self.find_new();
self.find_todo();
}
}
pub(super) fn prefetch(&mut self) {
let n = self.selected().filter(|s| !s.1).map(|s| s.0);
if n != self.prefetched.0 {
self.prefetched = (n, Some(std::time::Instant::now()), false);
}
if self.prefetched.2 || self.prefetched.1.is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(300)) {
return;
}
self.prefetched.2 = true;
let Some(n) = n else { return };
if self.pr_cache.lock().is_ok_and(|c| c.get(&n).is_some_and(|(t, _)| t.elapsed().as_secs() < 120)) {
return;
}
let cache = self.pr_cache.clone();
std::thread::spawn(move || {
if let (Ok(p), Ok(mut c)) = (gh::pr(Some(n)), cache.lock()) {
c.insert(n, (std::time::Instant::now(), p));
}
crate::view::prefetch_github(n);
});
}
}