use crate::gh;
use crate::theme::{Theme, Ui};
use anyhow::{Context, Result};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{DefaultTerminal, Frame};
use crate::batch;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
mod card;
use card::*;
mod todo;
pub(crate) mod github;
mod draw;
mod keys;
mod marks;
use {keys::*, marks::*};
pub(crate) use github::{Extra, Listed};
use crate::gh::ago_secs;
#[cfg(test)]
mod tests;
type Lookups = Vec<(u64, Option<Listed>)>;
type Unpushed = HashMap<u64, (usize, usize, Option<i64>)>;
type Checks = anyhow::Result<github::ListChecks>;
type Fetched = Result<Vec<Listed>>;
type Extras = anyhow::Result<(String, HashMap<u64, Extra>)>;
#[derive(Default)]
pub struct List {
tab_hits: Vec<(std::ops::Range<u16>, Tab)>,
last_click: Option<(std::time::Instant, usize)>,
prs: Vec<Listed>,
ui: Ui,
flags: String,
sel: usize,
top: usize,
query: String,
filtering: bool,
pending_g: bool,
help: bool,
order: Order,
rev: bool,
checks: Option<Receiver<Checks>>,
fresh: Option<(mpsc::Sender<Fetched>, Receiver<Fetched>)>,
local: HashMap<u64, (PathBuf, Option<String>)>,
tab: Tab,
reps: Vec<Listed>,
rep_of: HashMap<u64, crate::replace::Record>,
drafting: HashMap<u64, batch::Status>,
draft_rx: Vec<Receiver<(u64, batch::Status)>>,
drafting_elsewhere: HashSet<u64>,
reps_changed: Option<std::time::SystemTime>,
group_rx: Option<Receiver<anyhow::Result<batch::Groups>>>,
confirm: Option<(char, u64)>,
marked: HashSet<u64>,
marked_saved: HashSet<u64>,
batch: HashMap<u64, batch::Status>,
batch_rx: Vec<Receiver<(u64, batch::Status)>>,
batch_task: HashMap<u64, batch::Task>,
me: String,
extra: HashMap<u64, Extra>,
extra_rx: Option<Receiver<Extras>>,
pr_cache: PrCache,
prefetched: (Option<u64>, Option<std::time::Instant>, bool),
gone: HashMap<u64, Listed>,
gone_rx: Vec<Receiver<Lookups>>,
gone_pending: HashSet<u64>,
todo: Vec<crate::todo::Item>,
since: crate::todo::Since,
ranked: Option<crate::todo::Ranked>,
rank_rx: Option<Receiver<Result<crate::todo::Ranked>>>,
want_rank: bool,
running: HashMap<u64, String>,
running_reps: HashMap<u64, String>,
running_checked: Option<std::time::Instant>,
auto: bool,
auto_done: HashSet<(u64, String, &'static str)>,
auto_ranked: Option<std::time::Instant>,
unpushed: Unpushed,
unpushed_rx: Option<Receiver<Unpushed>>,
rescan: bool,
deferred: crate::todo::Deferred,
deferring: Option<String>,
until_rx: Option<(String, Receiver<Result<crate::todo::Until>>)>,
others: HashMap<u64, String>,
others_rx: Vec<Receiver<Vec<(u64, String)>>>,
others_pending: HashSet<u64>,
deferred_file: Option<PathBuf>,
wide: bool,
reviewed: HashSet<u64>,
reviews_dir: Option<std::path::PathBuf>,
reviews_mtime: Option<std::time::SystemTime>,
new: HashSet<u64>,
root: PathBuf,
pub msg: String,
}
pub struct Pick {
pub number: u64,
pub worktree: Option<bool>,
pub replacement: bool,
pub merge: bool,
pub pr: Option<gh::Pr>,
}
type PrCache = std::sync::Arc<std::sync::Mutex<HashMap<u64, (std::time::Instant, gh::Pr)>>>;
impl List {
pub fn new(flags: String) -> List {
List {
ui: Theme::load().ui(),
flags,
ranked: crate::todo::load(),
auto: crate::diff::git(&["config", "--get", "dv.auto"]).is_ok_and(|v| v.trim() == "true"),
deferred: crate::todo::load_deferred(crate::todo::deferred_path().as_deref()),
deferred_file: crate::todo::deferred_path(),
wide: true,
reviews_dir: crate::claude::dv_dir().ok().map(|d| d.join("reviews")),
root: std::env::current_dir().unwrap_or_default(),
..Default::default()
}
}
pub fn run(&mut self, term: &mut DefaultTerminal) -> Result<Option<Pick>> {
self.marked = load_marks();
self.prefetched = (None, None, true); self.marked_saved = self.marked.clone();
match &self.fresh {
None => self.watch_github(),
Some((tx, _)) => {
let tx = tx.clone();
std::thread::spawn(move || _ = tx.send(github::list()));
}
}
if self.prs.is_empty() {
self.refresh(term)?;
if !self.marked.is_empty() && self.msg.is_empty() {
self.msg = format!("{} marked from before: a a or A A acts on them; m unmarks", self.marked.len());
}
}
self.find_new(); self.load_reps();
self.scan_unpushed(); self.find_todo(); loop {
if self.marked != self.marked_saved {
match save_marks(&self.marked_saved, &self.marked) {
Ok(now) => self.marked = now,
Err(e) => self.msg = format!("couldn't save the marks: {e}"),
}
self.marked_saved = self.marked.clone();
}
term.draw(|f| self.draw(f))?;
self.batch_news();
self.watch_reviews();
self.todo_news();
self.unpushed_news();
self.gone_news();
self.prefetch();
self.others_news();
self.until_news();
if self.watch_running() {
self.scan_unpushed(); self.find_todo(); }
let changed = crate::replace::changed();
if changed != self.reps_changed {
self.reps_changed = changed;
self.load_reps();
self.find_todo(); }
if let Some(Ok(res)) = self.group_rx.as_ref().map(Receiver::try_recv) {
self.group_rx = None;
self.draft_groups(term, res);
}
if let Some(Ok(Ok(prs))) = self.fresh.as_ref().map(|(_, rx)| rx.try_recv()) {
self.apply_list(Ok(prs), true);
}
if let Some(Ok(res)) = self.checks.as_ref().map(Receiver::try_recv) {
self.checks = None;
match res {
Ok(mut by_pr) => self.prs.iter_mut().for_each(|p| (p.checks, p.checks_head) = by_pr.remove(&p.number).map_or((None, String::new()), |(c, h)| (Some(c), h))),
Err(e) => self.msg = format!("checks: {e:#}"),
}
self.find_todo(); }
if !event::poll(Duration::from_millis(100))? {
continue;
}
let k = match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => k,
Event::Mouse(m) => match self.mouse(m) {
true => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
false => continue,
},
_ => continue,
};
match self.key(k) {
Some(Do::Quit) => return Ok(None),
Some(Do::Open(n)) => {
let replacement = self.selected().is_some_and(|(m, rep)| m == n && rep);
let worktree = (self.tab == Tab::Worktrees).then(|| self.local.get(&n).is_some_and(|w| w.1.is_some()));
let pr = self.pr_cache.lock().ok().and_then(|mut c| c.remove(&n)).filter(|(t, _)| t.elapsed().as_secs() < 120).map(|(_, p)| p);
return Ok(Some(Pick { number: n, worktree, replacement, merge: false, pr }));
}
Some(Do::Merge(n)) => {
return Ok(Some(Pick { number: n, worktree: None, replacement: false, merge: true, pr: None }));
}
Some(Do::Refresh) => {
self.refresh(term)?;
}
Some(Do::Review(prs)) => {
let which = prs.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(" ");
let what = format!("instructions for Claude's reviews of {which} (optional)");
let Some(extra) = self.instructions(term, "review", &what) else { continue };
let prs: Vec<(u64, batch::Task)> = prs.into_iter().map(|n| (n, if self.tab == Tab::Todo { self.todo_task(n) } else { batch::Task::Review })).collect();
let with = if extra.is_empty() { "" } else { ", with your instructions" };
self.msg = format!("Claude is {}{with}, {} at a time", self.start_batch(prs, extra), batch::AT_ONCE);
self.marked.clear();
}
Some(Do::Replace(prs)) if prs.len() == 1 => self.draft_groups(term, Ok(vec![(prs, String::new())])),
Some(Do::Replace(prs)) => {
let about = |n: u64| self.prs.iter().find(|p| p.number == n).map(|p| (n, p.title.clone(), p.author.clone(), p.base.clone()));
let prs = prs.into_iter().map(|n| about(n).unwrap_or((n, String::new(), String::new(), String::new()))).collect();
self.msg = "Claude is looking at how to group them (one replacement per group)…".into();
self.group_rx = Some(batch::group(prs, self.root.clone()));
}
Some(Do::AskRebase(n)) => self.ask_rebase(term, n),
Some(Do::PostWait(n, text)) => {
let what = format!("comment on #{n} saying what it waits on (dv reads it back, yours and others')");
if let Some(body) = self.edit_text(term, &what, &text, false) {
self.msg = match gh::conversation_comment(&gh::Pr { number: n, ..Default::default() }, &body) {
Ok(()) => format!("posted on #{n}: {}", body.lines().next().unwrap_or("")),
Err(e) => format!("not posted: {e:#}"),
};
}
}
Some(Do::DeferUntil(key)) => {
let what = "defer it until… (e.g. #2380 is merged, CI is green, @amy approves, 3 days, monday)";
if let Some(text) = self.edit_text(term, what, "", false) {
self.defer_until(key, &text);
}
}
Some(Do::NewPr) => self.new_pr(term),
Some(Do::RemoveReplacement(id)) => {
self.msg = crate::replace::remove(id, &self.root).unwrap_or_else(|e| format!("{e:#}"));
self.load_reps();
self.sel = self.sel.min(self.shown().len().saturating_sub(1));
}
Some(Do::Remove(n)) => {
self.msg = match crate::remove_worktree(n, false) {
Ok(m) => {
self.local.remove(&n);
self.prs.retain(|p| p.number != n || p.open());
self.sel = self.sel.min(self.shown().len().saturating_sub(1));
m
}
Err(e) => format!("{e:#}"),
}
}
None => {}
}
}
}
fn instructions(&mut self, term: &mut DefaultTerminal, kind: &str, what: &str) -> Option<String> {
let last = crate::claude::instructions(kind);
let extra = self.edit_text(term, what, last.trim(), true)?;
_ = crate::claude::save_instructions(kind, &extra);
Some(extra)
}
fn edit_text(&mut self, term: &mut DefaultTerminal, what: &str, prefill: &str, allow_empty: bool) -> Option<String> {
let in_pane = crate::view::in_zellij();
if !in_pane {
crate::view::restore_terminal();
}
let text = crate::view::compose_with(what, allow_empty, prefill, true);
if !in_pane {
*term = crate::view::init_terminal();
}
match text {
Ok(Some(t)) => Some(t),
Ok(None) => {
self.msg = "cancelled".into();
None
}
Err(e) => {
self.msg = format!("{e:#}");
None
}
}
}
pub(super) fn prune_marks(&mut self) {
self.marked.retain(|n| self.prs.iter().find(|p| p.number == *n).is_none_or(|p| !matches!(p.state.as_str(), "CLOSED" | "MERGED")));
}
pub(super) fn start_batch(&mut self, prs: Vec<(u64, batch::Task)>, extra: String) -> String {
let on_branch = |n| self.local.get(&n).map_or(self.flags.contains('b'), |w| w.1.is_some());
let prs: Vec<(u64, bool, batch::Task)> = prs.into_iter().map(|(n, t)| (n, on_branch(n) || t == batch::Task::FixChecks, t)).collect();
let doing: Vec<String> = prs.iter().map(|(n, _, t)| format!("{} #{n}", t.doing())).collect();
for (n, _, t) in &prs {
self.batch_task.insert(*n, t.clone());
self.batch.insert(*n, batch::Status::Queued);
if *t == batch::Task::FixChecks {
crate::claude::ci_note_path(*n).map(std::fs::remove_file).ok();
}
}
self.batch_rx.push(batch::start(prs, self.root.clone(), extra));
doing.join(", ")
}
fn new_pr(&mut self, term: &mut DefaultTerminal) {
let what = "a new PR for Claude to draft: what it should do (issues by #N: Claude reads them); unpublished until you publish it";
let n = self.shown().get(self.sel).map(|p| p.number).filter(|_| self.tab == Tab::Todo);
let head = |n: u64| self.extra.get(&n).map(|e| e.head_oid.clone());
let note = n.and_then(|n| Some((n, crate::claude::ci_note(n, &head(n)?)?))).filter(|(_, c)| !c.task.is_empty());
let prefill = note.as_ref().map_or(String::new(), |(n, c)| c.prefill(*n));
let Some(task) = self.edit_text(term, what, &prefill, false).filter(|t| !t.trim().is_empty()) else { return };
match crate::replace::create_new(&task, &self.root) {
Ok(id) => {
if let Some((n, _)) = note {
crate::claude::ci_fix_drafted(n); }
self.draft_rx.push(batch::replace(vec![id], self.root.clone(), String::new()));
self.load_reps();
self.msg = format!("Claude is drafting {}: the drafts tab shows it (Enter there reviews it, S publishes it)", crate::replace::label(id));
}
Err(e) => self.msg = format!("couldn't start a new PR: {e:#}"),
}
}
fn ask_rebase(&mut self, term: &mut DefaultTerminal, n: u64) {
let base = self.prs.iter().find(|p| p.number == n).map_or("its base".to_owned(), |p| format!("`{}`", p.base));
let what = format!("comment asking for a rebase of #{n} (posted on its conversation)");
let Some(body) = self.edit_text(term, &what, &format!("Needs rebase: this conflicts with {base} now."), false) else { return };
let pr = gh::Pr { number: n, ..Default::default() };
if let Err(e) = gh::conversation_comment(&pr, &body) {
return self.msg = format!("not posted: {e:#}");
}
self.msg = format!("asked for a rebase of #{n}");
if let Some(it) = self.todo.iter().find(|it| !it.rep && it.number == n).cloned() {
self.deferred.insert(it.key(), crate::todo::Deferral { until: crate::claude::now_secs() + 7 * 86400, needs: it.needs_key(), cond: None });
self.msg += "; deferred on the todo tab until it changes";
self.save_defer();
}
}
fn draft_groups(&mut self, term: &mut DefaultTerminal, groups: Result<batch::Groups>) {
let (groups, why) = match groups {
Ok(g) => (g, String::new()),
Err(e) => (vec![], format!("{e:#}")),
};
let suggested = batch::groups_text(&self.group_notes(&groups));
let mut groups: Vec<Vec<u64>> = groups.iter().map(|g| g.0.clone()).collect();
if groups.len() > 1 || groups.first().is_some_and(|g| g.len() > 1) || !why.is_empty() {
let what = match why.is_empty() {
true => "replacements to draft: one per line, the PRs it replaces (Claude's suggestion; change it as you like, # notes are ignored)".to_owned(),
false => format!("replacements to draft, one per line (Claude couldn't suggest any: {why})"),
};
let in_pane = crate::view::in_zellij();
if !in_pane {
crate::view::restore_terminal();
}
let edited = crate::view::compose_with(&what, false, &suggested, true);
if !in_pane {
*term = crate::view::init_terminal();
}
match edited {
Ok(Some(text)) => groups = batch::parse_groups_text(&text),
Ok(None) => return self.msg = "cancelled".into(),
Err(e) => return self.msg = format!("{e:#}"),
}
}
groups.retain(|g| !g.is_empty() && !self.drafting_elsewhere.contains(&g[0]) && !self.drafting.get(&g[0]).is_some_and(batch::Status::running));
if groups.is_empty() {
return self.msg = "nothing to draft".into();
}
let which: Vec<String> = groups.iter().map(|g| g.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join("+")).collect();
let what = format!("instructions for Claude's replacement of {} (optional)", which.join(", "));
let Some(extra) = self.instructions(term, "replace", &what) else { return };
self.msg = format!("Claude is drafting {} replacement(s): the replacements tab shows them", groups.len());
self.marked.clear();
for g in groups {
self.draft_rx.push(batch::replace(g, self.root.clone(), extra.clone()));
}
}
fn group_notes(&self, groups: &batch::Groups) -> batch::Groups {
groups
.iter()
.map(|(g, why)| {
let titles: Vec<String> = g.iter().filter_map(|n| self.prs.iter().find(|p| p.number == *n).map(|p| p.title.clone())).collect();
(g.clone(), if why.is_empty() { titles.join(" / ") } else { why.clone() })
})
.collect()
}
fn load_reps(&mut self) {
let records = crate::replace::all();
self.drafting_elsewhere = records.iter().filter(|r| r.drafting_now()).map(|r| r.id).collect();
self.reps = records.iter().map(|r| rep_card(r, &self.root)).collect();
self.rep_of = records.into_iter().map(|r| (r.id, r)).collect();
}
fn draft_news(&mut self) {
let news: Vec<(u64, batch::Status)> = self.draft_rx.iter().flat_map(|rx| rx.try_iter()).collect();
for (id, s) in news {
match &s {
batch::Status::Done(took) => self.msg = format!("Claude drafted a replacement for #{id} ({took}): it's in the replacements tab"),
batch::Status::Failed(e) => self.msg = format!("Claude's draft of {} failed: {e}", crate::replace::label(id)),
_ => {}
}
let done = !s.running();
self.drafting.insert(id, s);
if done || !self.rep_of.contains_key(&id) {
self.load_reps(); }
self.find_todo(); }
}
fn draft_state(&self, id: u64, of: &str) -> Option<(String, bool)> {
let (what, ok) = match self.drafting.get(&id) {
_ if self.drafting_elsewhere.contains(&id) => ("drafting…", true),
Some(batch::Status::Failed(e)) => return Some((format!("{of}: draft failed: {}", cut(e.lines().next().unwrap_or(""), 60)), false)),
Some(s) if s.running() => ("drafting…", true),
_ => match self.rep_of.get(&id)? {
r if r.published.is_some() => return Some((format!("{of}: published as #{}", r.published.unwrap_or(0)), true)),
r if r.title.is_empty() => ("no draft yet", false),
_ => ("✓", true),
},
};
Some((format!("{of}{}{what}", if what == "✓" { " " } else { ": " }), ok))
}
fn batch_news(&mut self) {
self.draft_news();
let news: Vec<(u64, batch::Status)> = self.batch_rx.iter().flat_map(|rx| rx.try_iter()).collect();
let mut worktrees = false;
let done = !news.is_empty();
for (n, s) in news {
let doing = self.batch_task.get(&n).map_or("reviewing", batch::Task::doing);
match &s {
batch::Status::Done(took) => {
self.reviewed.insert(n);
self.new.insert(n);
self.msg = match doing {
"reviewing" => format!("Claude reviewed #{n} ({took}): Enter opens it, in its worktree from the worktrees tab"),
d => format!("Claude is done {d} on #{n} ({took})"),
};
}
batch::Status::Failed(e) => self.msg = format!("Claude's {doing} on #{n} failed: {e} (a a asks again)"),
batch::Status::Reviewing => worktrees = true, _ => {}
}
self.batch.insert(n, s);
}
if worktrees {
self.local = crate::pr_worktrees().into_iter().map(|(n, dir, b)| (n, (dir, b))).collect();
}
if done {
self.scan_unpushed();
self.find_todo(); }
}
fn find_new(&mut self) {
self.new = self
.prs
.iter()
.filter(|p| {
let claude = crate::claude::review_changed(p.number);
match crate::claude::looked(p.number) {
Some(t) => gh::epoch_secs(&p.updated).is_some_and(|u| u > t) || claude.is_some_and(|c| c > t),
None => claude.is_some(),
}
})
.map(|p| p.number)
.collect();
}
fn again(&mut self, armed: Option<(char, u64)>, key: (char, u64), ask: impl Into<String>) -> bool {
if armed == Some(key) {
return true;
}
(self.msg, self.confirm) = (ask.into(), Some(key));
false
}
fn selected(&self) -> Option<(u64, bool)> {
let rep = |it: Option<&crate::todo::Item>| self.tab == Tab::Replacements || it.is_some_and(|it| it.rep);
self.shown_cards().get(self.sel).map(|(c, it)| (c.number, rep(*it)))
}
pub(super) fn selected_item(&self) -> Option<crate::todo::Item> {
self.shown_cards().get(self.sel).and_then(|(_, it)| it.cloned())
}
fn selected_pr(&mut self) -> Option<u64> {
match self.selected()? {
(_, true) => {
self.msg = "a draft replacement, not on GitHub: Enter opens it (its own keys are on the drafts tab)".into();
None
}
(n, false) => Some(n),
}
}
fn shown(&self) -> Vec<&Listed> {
self.shown_cards().into_iter().map(|(card, _)| card).collect()
}
fn shown_cards(&self) -> Vec<(&Listed, Option<&crate::todo::Item>)> {
let in_tab = |p: &&Listed| match self.tab {
Tab::Worktrees => self.local.contains_key(&p.number),
_ => p.open(),
};
let all: Vec<(&Listed, Option<&crate::todo::Item>)> = match self.tab {
Tab::Replacements => self.reps.iter().map(|r| (r, None)).collect(),
Tab::Todo => self.todo_shown().into_iter().map(|(card, it)| (card, Some(it))).collect(),
_ => self.prs.iter().filter(in_tab).map(|p| (p, None)).collect(),
};
let mut prs: Vec<(&Listed, Option<&crate::todo::Item>)> = all.into_iter().filter(|p| self.query.split_whitespace().all(|t| term(p.0, t))).collect();
if self.tab == Tab::Todo {
return prs; }
prs.sort_by(|(a, _), (b, _)| {
let o = match self.order {
Order::Updated => b.updated.cmp(&a.updated), Order::Created => b.created.cmp(&a.created),
Order::Number => b.number.cmp(&a.number),
Order::Size => (b.additions + b.deletions).cmp(&(a.additions + a.deletions)),
};
if self.rev { o.reverse() } else { o }
});
prs
}
fn sort(&mut self, order: Order, rev: bool) {
let keep = self.shown().get(self.sel).map(|p| p.number);
(self.order, self.rev) = (order, rev);
self.sel = keep.and_then(|n| self.shown().iter().position(|p| p.number == n)).unwrap_or(0);
}
}
enum Do {
Quit,
NewPr,
Open(u64),
Refresh,
Remove(u64),
Review(Vec<u64>),
Replace(Vec<u64>),
RemoveReplacement(u64),
Merge(u64),
AskRebase(u64),
DeferUntil(String),
PostWait(u64, String),
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
enum Tab {
Todo,
#[default]
Open,
Worktrees,
Replacements,
}
impl Tab {
fn next(self) -> Tab {
match self {
Tab::Todo => Tab::Open,
Tab::Open => Tab::Worktrees,
Tab::Worktrees => Tab::Replacements,
Tab::Replacements => Tab::Todo,
}
}
fn prev(self) -> Tab {
self.next().next().next()
}
}
#[derive(Clone, Copy, Default)]
enum Order {
Updated,
#[default]
Created,
Number,
Size,
}
impl Order {
fn next(self) -> Order {
match self {
Order::Updated => Order::Created,
Order::Created => Order::Number,
Order::Number => Order::Size,
Order::Size => Order::Updated,
}
}
fn name(self) -> &'static str {
match self {
Order::Updated => "updated",
Order::Created => "created",
Order::Number => "number",
Order::Size => "size",
}
}
}
fn term(p: &Listed, t: &str) -> bool {
let (neg, t) = match t.strip_prefix('-') {
Some(rest) if !rest.is_empty() => (true, rest),
_ => (false, t),
};
let fold = |s: &str| if t.chars().any(char::is_uppercase) { s.to_owned() } else { s.to_lowercase() };
let has = |field: &str, v: &str| fold(field).contains(v);
let labels = || p.labels.iter().map(|l| l.0.as_str()).collect::<Vec<_>>().join(" ");
let hit = match t.split_once(':') {
Some(("author", v)) => has(&p.author, v),
Some(("label", v)) => p.labels.iter().any(|l| has(&l.0, v)),
Some(("base", v)) => has(&p.base, v),
Some(("branch", v)) => has(&p.branch, v),
Some(("is", "draft")) => p.draft,
Some(("review", v)) => match p.decision.as_str() {
"APPROVED" => "approved".starts_with(v),
"CHANGES_REQUESTED" => "changes".starts_with(v),
"REVIEW_REQUIRED" => "required".starts_with(v),
_ => v == "none",
},
Some(("ci", v)) => match p.checks {
Some((0, 0, 0)) | None => v == "none",
Some((pass, fail, pending)) => {
("pass".starts_with(v) && pass > 0 && fail == 0 && pending == 0)
|| ("fail".starts_with(v) && fail > 0)
|| ("pending".starts_with(v) && pending > 0)
}
},
_ => has(&format!("#{} {} @{} {} {}", p.number, p.title, p.author, p.branch, labels()), t),
};
hit != neg
}