use crate::batch::Task;
use crate::list::{Extra, Listed};
use anyhow::{Context, Result};
use serde_json::{Value, json};
use std::sync::mpsc::{self, Receiver};
#[derive(Clone, Debug, PartialEq)]
pub enum Need {
Publish,
CloseOriginals(usize),
Unpushed(usize, usize),
ClaudeChanged(usize, usize),
PushedSinceReview,
ClaudeAnswered(usize),
ReviewRequested,
ReadyToMerge,
MarkReady,
Conflicts(bool),
ApproveRuns(usize),
ChecksFailing(usize, bool),
CiNotThisPr(String, bool),
Unsigned(usize, bool),
ClaudeReviewNew,
ClaudeComments(usize),
NoReview,
NewActivity,
FixMerged(u64),
WouldClose(String),
Assigned,
FixOpen(u64),
Retriage,
Untriaged,
}
impl Need {
pub fn weight(&self) -> u8 {
match self {
Need::Publish => 90,
Need::ClaudeChanged(..) => 88,
Need::Unpushed(..) => 85,
Need::PushedSinceReview => 80,
Need::ClaudeAnswered(_) => 75,
Need::ReviewRequested => 70,
Need::CloseOriginals(_) => 65,
Need::MarkReady => 63,
Need::ReadyToMerge => 60,
Need::Conflicts(_) => 55,
Need::ApproveRuns(_) => 66,
Need::Unsigned(..) => 54,
Need::ChecksFailing(..) | Need::CiNotThisPr(..) => 52,
Need::ClaudeReviewNew => 50,
Need::ClaudeComments(_) => 40,
Need::NoReview => 30,
Need::NewActivity => 20,
Need::FixMerged(_) => 37,
Need::WouldClose(_) => 58,
Need::Assigned => 56,
Need::FixOpen(_) => 45,
Need::Retriage => 36,
Need::Untriaged => 31,
}
}
pub fn tag(&self) -> Option<String> {
let s = |n: usize| if n == 1 { "" } else { "s" };
Some(match self {
Need::ApproveRuns(n) => format!("⏸ {n} run{} to approve (W W)", s(*n)),
Need::ChecksFailing(n, _) => format!("✗ {n} check{} failing", s(*n)),
Need::CiNotThisPr(..) => "✗ CI failing (not this PR)".into(),
Need::Unsigned(n, _) => format!("{n} unsigned commit{}", s(*n)),
Need::Conflicts(_) => "conflicts".into(),
_ => return None,
})
}
pub fn says(&self) -> String {
let s = |n: usize| if n == 1 { "" } else { "s" };
match self {
Need::Publish => "replacement drafted: publish it".into(),
Need::CloseOriginals(n) => format!("published: close the {n} PR{} it replaces", s(*n)),
Need::Unpushed(files, commits) | Need::ClaudeChanged(files, commits) => {
let what = match (files, commits) {
(0, c) => format!("{c} commit{} the PR doesn't have", s(*c)),
(f, 0) => format!("{f} uncommitted file{}", s(*f)),
(f, c) => format!("{f} uncommitted file{} and {c} commit{} the PR doesn't have", s(*f), s(*c)),
};
match self {
Need::ClaudeChanged(..) => format!("Claude made changes, not pushed: {what}; check them, push them (Space g there)"),
_ => format!("in its worktree: {what} (Space g there)"),
}
}
Need::PushedSinceReview => "pushed since your review".into(),
Need::ClaudeAnswered(n) => format!("Claude answered you on {n} thread{}", s(*n)),
Need::ReviewRequested => "your review is requested".into(),
Need::ReadyToMerge => "approved, checks green: merge it".into(),
Need::MarkReady => "your draft, checks green: mark it ready for review (Space g there)".into(),
Need::Conflicts(true) => "conflicts with its base: rebase it".into(),
Need::ApproveRuns(n) => format!("{n} workflow run{} awaiting approval: approve {} (W W)", s(*n), if *n == 1 { "it" } else { "them" }),
Need::ChecksFailing(n, true) => format!("{n} check{} failing: fix {}", s(*n), if *n == 1 { "it" } else { "them" }),
Need::ChecksFailing(n, false) => format!("{n} check{} failing: see why, tell the author", s(*n)),
Need::CiNotThisPr(why, true) => format!("CI fails, not this PR's doing (Claude: {why}): N N drafts a PR fixing it, a a has it try again"),
Need::CiNotThisPr(why, false) => format!("CI fails, not this PR's doing (Claude: {why}): rerun it, or a a has it try again"),
Need::Unsigned(n, true) => format!("{n} of your commits unsigned, and its base requires signed ones: sign them, force-push"),
Need::Unsigned(n, false) => format!("{n} commit{} unsigned, and its base requires signed ones: ask the author to sign them", s(*n)),
Need::Conflicts(false) => "conflicts with its base: ask for a rebase (b b)".into(),
Need::ClaudeReviewNew => "Claude's review is new to you".into(),
Need::ClaudeComments(n) => format!("{n} comment{} from Claude to go through", s(*n)),
Need::NoReview => "no review from you yet".into(),
Need::NewActivity => "new activity since you looked".into(),
Need::FixMerged(n) => format!("#{n}, linked as its fix, merged: check it's fixed (a a)"),
Need::WouldClose(why) => format!("Claude would close it: {why}"),
Need::Assigned => "assigned to you".into(),
Need::FixOpen(n) => format!("#{n} would fix it: review that"),
Need::Retriage => "changed since Claude triaged it (a a)".into(),
Need::Untriaged => "not triaged yet (a a)".into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Kind {
Pr,
Rep,
Issue,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Item {
pub kind: Kind,
pub number: u64,
pub needs: Vec<Need>,
pub claude: Option<Task>,
pub updated: String,
pub reason: Option<String>,
pub changed: bool,
pub deferred: Option<Deferral>,
pub busy: Option<String>,
pub waits: Option<(Until, String)>,
}
impl Item {
pub fn key(&self) -> String {
let kind = match self.kind {
Kind::Pr => "pr",
Kind::Rep => "rep",
Kind::Issue => "issue",
};
format!("{kind}-{}", self.number)
}
pub fn is_rep(&self) -> bool {
self.kind == Kind::Rep
}
pub fn needs_key(&self) -> String {
self.needs.iter().map(|n| n.weight().to_string()).collect::<Vec<_>>().join(",")
}
fn top(&self) -> u8 {
self.needs.iter().map(Need::weight).max().unwrap_or(0)
}
pub fn says(&self) -> String {
self.needs.iter().map(Need::says).collect::<Vec<_>>().join(" · ")
}
pub fn tags(&self) -> Vec<String> {
self.needs.iter().filter_map(Need::tag).collect()
}
}
pub struct Facts<'a> {
pub pr: &'a Listed,
pub extra: Option<&'a Extra>,
pub me: &'a str,
pub review: Option<&'a crate::claude::Review>,
pub looked: Option<i64>,
pub review_changed: Option<i64>,
pub unpushed: (usize, usize),
pub head_at: Option<i64>,
pub ci_note: Option<&'a crate::claude::CiNote>,
}
pub fn green(checks: Option<(usize, usize, usize)>) -> bool {
checks.is_some_and(|(pass, fail, pending)| pass > 0 && fail == 0 && pending == 0)
}
pub fn needs(f: &Facts) -> (Vec<Need>, Option<Task>) {
let (pr, mut out) = (f.pr, vec![]);
if f.unpushed != (0, 0) {
let claude = f.review.is_some_and(|r| {
r.threads.iter().flat_map(|t| &t.comments).any(|c| {
c.author == crate::claude::AUTHOR && c.body.starts_with("Changed:") && crate::gh::epoch_secs(&c.created).is_some_and(|t| f.head_at.is_none_or(|h| t > h))
})
});
out.push(match claude {
true => Need::ClaudeChanged(f.unpushed.0, f.unpushed.1),
false => Need::Unpushed(f.unpushed.0, f.unpushed.1),
});
}
let mine = !f.me.is_empty() && pr.author == f.me;
if let Some(e) = f.extra.filter(|e| e.awaiting > 0) {
out.push(Need::ApproveRuns(e.awaiting));
}
let head = f.extra.map_or("", |e| e.head_oid.as_str());
let not_this = f.ci_note.filter(|c| mine && !head.is_empty() && c.head == head);
if let Some((_, fail, _)) = pr.checks.filter(|c| c.1 > 0 && (!pr.draft || mine)) {
out.push(match not_this {
Some(c) if c.rerun => Need::CiNotThisPr(format!("{}; it reran it once already", c.why), !c.task.is_empty()),
Some(c) => Need::CiNotThisPr(c.why.clone(), !c.task.is_empty()),
None => Need::ChecksFailing(fail, mine),
});
}
if let Some(e) = f.extra.filter(|e| e.signatures_required && e.unsigned > 0 && (!pr.draft || mine)) {
out.push(Need::Unsigned(e.unsigned, mine));
}
if mine && pr.draft && f.unpushed == (0, 0) && green(pr.checks) {
out.push(Need::MarkReady);
}
let my_review = f.extra.and_then(|e| e.my_review.as_ref());
if let Some((_, commit)) = my_review
&& !mine
&& !commit.is_empty()
&& !head.is_empty()
&& commit != head
{
out.push(Need::PushedSinceReview);
}
let threads = f.review.map_or(&[][..], |r| r.threads.as_slice());
let count = |p: &dyn Fn(&crate::gh::Thread) -> bool| threads.iter().filter(|t| p(t)).count();
let answered = count(&|t| t.answered());
if answered > 0 {
out.push(Need::ClaudeAnswered(answered));
}
let requested = f.extra.is_some_and(|e| e.requested);
if requested && !mine {
out.push(Need::ReviewRequested);
}
let green = pr.checks.is_some_and(|(_, fail, pending)| fail == 0 && pending == 0);
let refused = f.extra.is_some_and(|e| e.signatures_required && e.unsigned > 0);
if pr.decision == "APPROVED" && f.extra.is_some_and(|e| e.mergeable == "MERGEABLE") && green && !pr.draft && !refused {
out.push(Need::ReadyToMerge);
}
if f.extra.is_some_and(|e| e.mergeable == "CONFLICTING") && !pr.draft {
out.push(Need::Conflicts(mine));
}
if f.review.is_some() && f.review_changed.is_some_and(|c| c > f.looked.unwrap_or(0)) {
out.push(Need::ClaudeReviewNew);
}
let open = count(&|t| t.by_claude() && !t.resolved && !t.waiting() && !t.answered());
if open > 0 {
out.push(Need::ClaudeComments(open));
}
if f.extra.is_some() && my_review.is_none() && !requested && !mine && !pr.draft {
out.push(Need::NoReview);
}
let updated = crate::gh::epoch_secs(&pr.updated);
if out.is_empty() && f.looked.is_some_and(|l| updated.is_some_and(|u| u > l)) {
out.push(Need::NewActivity);
}
let waiting = count(&|t| t.waiting());
let claude = match f.review {
_ if mine && f.unpushed == (0, 0) && not_this.is_none() && pr.checks.is_some_and(|c| c.1 > 0) => Some(Task::FixChecks),
_ if pr.draft => None,
Some(_) if waiting > 0 => Some(Task::Answer { theirs: !mine }),
Some(r) if !r.head.is_empty() && !head.is_empty() && r.head != head => Some(Task::Update(r.head.clone())),
Some(_) => None,
None => Some(Task::Review),
};
(out, claude)
}
pub fn rep_needs(r: &crate::replace::Record) -> Vec<Need> {
match r.published {
None if !r.title.is_empty() && !r.drafting_now() => vec![Need::Publish],
Some(_) => match r.replaces.iter().filter(|o| !r.closed.contains(&o.number)).count() {
0 => vec![],
n => vec![Need::CloseOriginals(n)],
},
None => vec![],
}
}
pub fn rule_order(items: &mut [Item]) {
items.sort_by(|a, b| b.top().cmp(&a.top()).then_with(|| a.updated.cmp(&b.updated)));
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Ranked {
pub at: i64,
pub cost: f64,
pub fingerprint: String,
pub items: Vec<(String, String)>,
pub waits: std::collections::HashMap<String, Until>,
pub closes: std::collections::HashMap<String, String>,
}
pub fn fingerprint(items: &[Item]) -> String {
let mut keys: Vec<String> = items.iter().map(|i| format!("{}:{}", i.key(), i.needs_key())).collect();
keys.sort();
keys.join(";")
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Since {
pub new: usize,
pub changed: usize,
pub gone: usize,
}
pub fn order(mut items: Vec<Item>, ranked: Option<&Ranked>) -> (Vec<Item>, Since) {
let fp = ranked.map_or("", |r| r.fingerprint.as_str());
let then: std::collections::HashMap<&str, &str> = fp.split(';').filter_map(|e| e.split_once(':')).collect();
let now = |it: &Item| it.needs_key();
let mut since = Since { gone: then.keys().filter(|k| !items.iter().any(|it| it.key() == **k)).count(), ..Since::default() };
for it in items.iter_mut().filter(|_| ranked.is_some()) {
match then.get(it.key().as_str()) {
None => (it.changed, since.new) = (true, since.new + 1),
Some(n) if *n != now(it) => (it.changed, since.changed) = (true, since.changed + 1),
Some(_) => {}
}
}
let mut out = vec![];
for (key, word) in ranked.map_or(&[][..], |r| r.items.as_slice()) {
if let Some(i) = items.iter().position(|it| &it.key() == key) {
let mut it = items.remove(i);
it.reason = Some(word.clone());
out.push(it);
}
}
rule_order(&mut items);
for it in items {
let at = out.iter().position(|o: &Item| o.top() < it.top()).unwrap_or(out.len());
out.insert(at, it);
}
(out, since)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Of {
Prs,
Issues,
}
fn path(of: Of) -> Option<std::path::PathBuf> {
let name = match of {
Of::Prs => "todo.json",
Of::Issues => "todo-issues.json",
};
crate::claude::dv_dir().ok().map(|d| d.join(name))
}
pub fn in_secs(s: i64) -> String {
match s.max(0) {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", (s + 30) / 60),
s if s < 86400 => format!("{}h", (s + 1800) / 3600),
s => format!("{}d", (s + 43200) / 86400),
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Until {
Time(i64),
Merged(u64),
Closed(u64),
Green,
Approved(Option<String>),
Reviewed(Option<String>),
Pushed(String),
PushedTo(u64, String),
}
impl Until {
pub fn unwatchable(&self) -> bool {
matches!(self, Until::Pushed(h) | Until::PushedTo(_, h) if h.is_empty())
}
pub fn moved_past(head: &str, now: &str) -> bool {
!head.is_empty() && !now.is_empty() && !now.starts_with(head)
}
pub fn says(&self) -> String {
match self {
Until::Time(t) => format!("in {}", in_secs(t - crate::claude::now_secs())),
Until::Merged(n) => format!("#{n} is merged"),
Until::Closed(n) => format!("#{n} is closed"),
Until::Green => "its checks pass".into(),
Until::Approved(Some(who)) => format!("@{who} approves it"),
Until::Approved(None) => "it's approved".into(),
Until::Pushed(h) if h.is_empty() => "the author pushes".into(),
Until::Pushed(h) => format!("the author pushes (past {})", h.get(..7).unwrap_or(h)),
Until::PushedTo(n, h) if h.is_empty() => format!("#{n} gets a push"),
Until::PushedTo(n, h) => format!("#{n} gets a push (past {})", h.get(..7).unwrap_or(h)),
Until::Reviewed(Some(who)) => format!("@{who} reviews it"),
Until::Reviewed(None) => "someone else reviews it".into(),
}
}
fn to_json(&self) -> Value {
match self {
Until::Time(t) => json!({ "at": t }),
Until::Merged(n) => json!({ "merged": n }),
Until::Closed(n) => json!({ "closed": n }),
Until::Green => json!({ "green": true }),
Until::Approved(who) => json!({ "approved": who }),
Until::Reviewed(who) => json!({ "reviewed": who }),
Until::Pushed(head) => json!({ "pushed": head }),
Until::PushedTo(n, head) => json!({ "pushed_to": n, "head": head }),
}
}
pub fn from_json(v: &Value) -> Option<Until> {
let s = |k: &str| v[k].as_str().map(|s| s.trim_start_matches('@').to_owned());
Some(if let Some(t) = v["at"].as_i64() {
Until::Time(t)
} else if let Some(t) = v["at"].as_str() {
Until::Time(crate::gh::epoch_secs(t)?)
} else if let Some(n) = v["merged"].as_u64() {
Until::Merged(n)
} else if let Some(n) = v["closed"].as_u64() {
Until::Closed(n)
} else if v["green"] == true {
Until::Green
} else if v.get("approved").is_some() {
Until::Approved(s("approved"))
} else if let Some(n) = v["pushed_to"].as_u64().or_else(|| v["pushed"].as_u64()).or_else(|| v["pushed"].as_str()?.strip_prefix('#')?.parse().ok()) {
Until::PushedTo(n, s("head").unwrap_or_default()) } else if v.get("pushed").is_some() {
Until::Pushed(s("pushed").unwrap_or_default()) } else if v.get("reviewed").is_some() {
Until::Reviewed(s("reviewed"))
} else {
return None;
})
}
pub fn parse(text: &str, now: i64) -> Option<Until> {
let t = text.trim().to_lowercase();
match t.rsplit_once("until ") {
Some((before, after)) => {
let named = before.split(|c: char| c.is_whitespace() || c == ',').find_map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '#').strip_prefix('#')?.parse::<u64>().ok());
Self::parse_one(after, named, now)
}
None => Self::parse_one(&t, None, now),
}
}
fn parse_one(t: &str, named: Option<u64>, now: i64) -> Option<Until> {
let t = t.trim();
let words: Vec<&str> = t.split(|c: char| c.is_whitespace() || c == ',').filter(|w| !w.is_empty()).collect();
let has = |w: &str| words.iter().any(|x| x.starts_with(w));
let said = words.iter().find_map(|w| w.trim_start_matches('#').parse::<u64>().ok().filter(|_| w.starts_with('#') || words.len() <= 4));
let num = said.or(named);
let another = num.is_some() || words.iter().any(|w| w.contains('#'));
let who = words.iter().find_map(|w| w.strip_prefix('@')).and_then(|w| w.split(|c: char| !c.is_alphanumeric() && c != '-').next()).filter(|w| !w.is_empty()).map(str::to_owned);
let word = |set: &[&str]| words.iter().any(|w| set.contains(&w.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'')));
let unit = |w: &str| match w.trim_end_matches('s') {
"h" | "hr" | "hour" => Some(3600),
"d" | "day" => Some(86400),
"w" | "wk" | "week" => Some(7 * 86400),
_ => None,
};
let dur = words.windows(2).find_map(|p| Some(p[0].replace('a', "1").parse::<i64>().ok()? * unit(p[1])?)).or_else(|| {
words.iter().find_map(|w| {
let at = w.find(|c: char| !c.is_ascii_digit())?;
Some(w[..at].parse::<i64>().ok()? * unit(&w[at..])?)
})
});
Some(match () {
_ if has("merge") => Until::Merged(num?),
_ if has("close") => Until::Closed(num?),
_ if (has("ci") || has("check") || has("test")) && (has("green") || has("pass")) && !another => Until::Green,
_ if has("approv") && !another => Until::Approved(who),
_ if who.is_none() && dur.is_none() && ["address", "push", "updat", "respond", "fixes", "rebase", "progress"].iter().any(|w| has(w)) => {
let head = words.windows(2).find(|p| p[0].trim_start_matches('(') == "past").map(|p| p[1].trim_matches(|c: char| !c.is_ascii_alphanumeric())).filter(|w| (7..=40).contains(&w.len()) && w.chars().all(|c| c.is_ascii_hexdigit()));
let head = head.unwrap_or_default().to_owned();
match (said, num) {
(Some(n), _) => Until::PushedTo(n, head),
(None, None) => Until::Pushed(head),
(None, Some(_)) => return None,
}
}
_ if has("review") && dur.is_none() && !another && !has("comment") && (who.is_some() || word(&["someone", "someone's", "somebody", "anyone", "anyone's", "anybody", "else", "else's"])) => Until::Reviewed(who),
_ => Until::Time(now + dur?),
})
}
}
pub fn or_time(text: &str, now: i64) -> (String, Option<i64>) {
let t = text.trim();
let lower = t.to_ascii_lowercase(); match lower.rfind(" or ").and_then(|i| Some((i, Until::parse_one(&lower[i + 4..], None, now)?))) {
Some((i, Until::Time(at))) => (t[..i].trim_end_matches([',', ' ']).to_owned(), Some(at)),
_ => (t.to_owned(), None),
}
}
pub fn until_or(c: Until, or: Option<i64>) -> (i64, Option<Until>, String) {
let now = crate::claude::now_secs();
match c {
Until::Time(t) => {
let t = or.map_or(t, |o| o.min(t));
(t, None, in_secs(t - now))
}
c => {
let says = match or {
Some(o) => format!("{}, or in {}", c.says(), in_secs(o - now)),
None => c.says(),
};
(or.unwrap_or(i64::MAX), Some(c), says)
}
}
}
pub fn waiting_on(said: &[(String, String, i64)], now: i64) -> Option<(Until, String, i64)> {
const LEADS: [&str; 8] = ["waiting on", "waiting for", "blocked on", "blocked by", "depends on", "deferred until", "defer until", "until"];
for (who, body, at) in said.iter().rev() {
for line in body.lines().rev() {
let l = line.trim().trim_start_matches(['>', '*', '-', ' ']).replace("**", "").to_lowercase();
if l.starts_with("unblocked") || l.starts_with("no longer waiting") {
return None;
}
let Some((lead, rest)) = LEADS.iter().find_map(|p| Some((*p, l.strip_prefix(p).filter(|r| r.is_empty() || r.starts_with([':', ' ']))?))) else { continue };
let rest = rest.trim_start_matches([':', ' ']);
let bare = rest.trim_end_matches(['.', ' ', ')']).trim_start_matches('#').parse::<u64>().ok();
let ws: Vec<&str> = rest.split_whitespace().collect();
let head = ws.iter().find(|w| !["a", "an", "the", "your", "more", "another", "code", "maintainer", "final"].contains(w));
let review = head.is_some_and(|w| w.starts_with("review")) && !ws.iter().any(|w| w.starts_with("comment")) && !rest.contains(['#', '@']);
let c = bare.map(Until::Merged).or_else(|| Until::parse(rest, now)).or_else(|| review.then_some(Until::Reviewed(None)));
let c = c.filter(|c| !c.unwatchable());
match c {
None if lead == "until" => continue,
c => return c.map(|c| (c, who.clone(), *at)),
}
}
}
None
}
#[derive(Clone, Debug, PartialEq)]
pub struct Deferral {
pub until: i64,
pub needs: String,
pub cond: Option<Until>,
pub at: i64,
}
pub type Deferred = std::collections::HashMap<String, Deferral>;
pub fn deferred_path_of(of: Of) -> Option<std::path::PathBuf> {
let name = match of {
Of::Prs => "deferred.json",
Of::Issues => "deferred-issues.json",
};
crate::claude::dv_dir().ok().map(|d| d.join(name))
}
pub fn load_deferred(p: Option<&std::path::Path>) -> Deferred {
let v: Value = p.and_then(|p| serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()).unwrap_or_default();
let now = crate::claude::now_secs();
v.as_object()
.into_iter()
.flatten()
.filter_map(|(k, e)| {
let cond = match e.get("cond") {
Some(c) => Some(Until::from_json(c)?),
None => None,
};
Some((k.clone(), Deferral { until: e["until"].as_i64()?, needs: e["needs"].as_str().unwrap_or("").to_owned(), cond, at: e["at"].as_i64().unwrap_or(0) }))
})
.filter(|(_, d)| d.until > now)
.collect()
}
pub fn save_deferred(p: Option<&std::path::Path>, d: &Deferred) -> Result<()> {
let v: serde_json::Map<String, Value> = d
.iter()
.map(|(k, d)| {
let mut e = json!({ "until": d.until, "needs": d.needs, "at": d.at });
if let Some(c) = &d.cond {
e["cond"] = c.to_json();
}
(k.clone(), e)
})
.collect();
let p = p.context("no dv directory")?;
std::fs::create_dir_all(p.parent().unwrap_or(p))?;
Ok(std::fs::write(p, Value::Object(v).to_string())?)
}
pub fn defer_secs(c: char) -> (i64, &'static str) {
match c {
'1' => (3600, "an hour"),
'4' => (4 * 3600, "four hours"),
'd' => (86400, "a day"),
_ => (7 * 86400, "a week"),
}
}
pub fn defer(items: Vec<Item>, deferred: &Deferred, now: i64, met: impl Fn(&Item, &Until, i64) -> bool) -> Vec<Item> {
let waiting = |it: &Item| {
deferred.get(&it.key()).filter(|d| d.until > now && d.needs == it.needs_key() && !d.cond.as_ref().is_some_and(|c| met(it, c, d.at)))
};
let (mut later, mut out): (Vec<Item>, Vec<Item>) = items.into_iter().partition(|it| waiting(it).is_some());
later.iter_mut().for_each(|it| it.deferred = deferred.get(&it.key()).cloned());
out.append(&mut later);
out
}
pub fn interpret(text: String, about: String, root: std::path::PathBuf) -> Receiver<Result<Until>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let prompt = format!(
"A maintainer defers {about} until: \"{text}\". It's {} now (UTC). Reply with only one JSON object \
saying when, one of: {{\"merged\": N}} (PR N merged), {{\"closed\": N}} (PR N closed), {{\"green\": true}} \
(this PR's checks pass), {{\"approved\": \"login\"}} or {{\"approved\": null}} (an approval), \
{{\"reviewed\": \"login\"}} (a review from them) or {{\"reviewed\": null}} (a review from anyone but the maintainer and the PR's author), {{\"pushed\": true}} (the author pushes to it: \
addresses the reviews, updates it), {{\"pushed\": N}} (PR N gets a push: its author makes progress on it), {{\"at\": \"2026-10-20T09:00:00Z\"}} (a time). If it's \
none of these, {{\"none\": \"why, in a few words\"}}. No other text.",
crate::claude::now()
);
let res = crate::batch::guarded(|| {
let out = crate::claude::claude_reader(crate::claude::Work::Interpret, &prompt, &root, &[]).stdin(std::process::Stdio::null()).output()?;
let v: Value = serde_json::from_slice(&out.stdout).unwrap_or_default();
anyhow::ensure!(v["is_error"] == false, "Claude couldn't read it ({})", out.status);
let answer = v["result"].as_str().unwrap_or("");
let w = crate::claude::json_in(answer, '{').filter(Value::is_object).context("no answer")?;
if let Some(why) = w["none"].as_str() {
anyhow::bail!("not something dv can watch for: {why}");
}
Until::from_json(&w).context("not something dv can watch for")
});
_ = tx.send(res);
});
rx
}
pub fn load_of(of: Of) -> Option<Ranked> {
let v: Value = serde_json::from_str(&std::fs::read_to_string(path(of)?).ok()?).ok()?;
let items = v["items"].as_array()?.iter().map(|i| (i["key"].as_str().unwrap_or("").to_owned(), i["says"].as_str().unwrap_or("").to_owned())).collect();
let waits = v["waits"].as_object().into_iter().flatten().filter_map(|(k, w)| Some((k.clone(), Until::from_json(w)?))).collect();
let closes = v["closes"].as_object().into_iter().flatten().filter_map(|(k, w)| Some((k.clone(), w.as_str()?.to_owned()))).collect();
Some(Ranked { at: v["at"].as_i64().unwrap_or(0), cost: v["cost"].as_f64().unwrap_or(0.0), fingerprint: v["fingerprint"].as_str().unwrap_or("").to_owned(), items, waits, closes })
}
fn save(r: &Ranked, of: Of) -> Result<()> {
let items: Vec<Value> = r.items.iter().map(|(k, s)| json!({ "key": k, "says": s })).collect();
let p = path(of).context("no dv directory")?;
std::fs::create_dir_all(p.parent().unwrap_or(&p))?;
let waits: serde_json::Map<String, Value> = r.waits.iter().map(|(k, w)| (k.clone(), w.to_json())).collect();
Ok(std::fs::write(p, json!({ "at": r.at, "cost": r.cost, "fingerprint": r.fingerprint, "items": items, "waits": waits, "closes": r.closes }).to_string())?)
}
pub fn about(it: &Item, pr: &Listed, summary: &str) -> String {
let mut v = json!({
"key": it.key(),
"title": pr.title,
"author": pr.author,
"size": format!("+{} -{} in {} files", pr.additions, pr.deletions, pr.files),
"opened": crate::gh::ago(&pr.created),
"updated": crate::gh::ago(&pr.updated),
"labels": pr.labels.iter().map(|l| l.0.clone()).collect::<Vec<_>>(),
"needs": it.needs.iter().map(Need::says).collect::<Vec<_>>(),
});
if let Some((pass, fail, pending)) = pr.checks {
v["checks"] = json!(format!("{pass} passed, {fail} failed, {pending} pending"));
}
if !summary.trim().is_empty() {
v["claude_review"] = json!(summary.chars().take(400).collect::<String>());
}
v.to_string()
}
pub fn rank_of(lines: Vec<String>, fingerprint: String, root: std::path::PathBuf, of: Of) -> Receiver<Result<Ranked>> {
let (tx, rx) = mpsc::channel();
let at = crate::claude::now_secs();
std::thread::spawn(move || {
let res = crate::batch::guarded(|| ask(&lines, &root, of)).map(|((items, waits, closes), cost)| Ranked { at, cost, fingerprint, items, waits, closes });
if let Ok(r) = &res {
_ = save(r, of);
}
_ = tx.send(res);
});
rx
}
type Order = (Vec<(String, String)>, std::collections::HashMap<String, Until>, std::collections::HashMap<String, String>);
fn ask(lines: &[String], root: &std::path::Path, of: Of) -> Result<(Order, f64)> {
let notes = crate::claude::notes_preamble();
let (what, judge, key) = match of {
Of::Prs => (
"These open PRs (and replacements: PRs you drafted to replace others) are waiting on me",
"fixes for bugs and security over refactors and features; contributors who've waited longest; quick ones \
first among equals; a PR that blocks others before them. Read a PR (`gh pr view N`) only where its line \
isn't enough to judge. If a PR's conversation (`gh pr view N --comments`, where you read it)",
"pr-12",
),
Of::Issues => (
"These open issues are waiting on me (to triage, decide on, close, or get fixed)",
"bugs users hit and security over features and chores; what Claude's triage says (its priority, whether to \
close it); one a merged PR already fixes first (quick to close); issues that block others before them. \
Read an issue (`gh issue view N`) only where its line isn't enough to judge.",
"issue-12",
),
};
let waits = match of {
Of::Prs => " says it's waiting on something dv can watch for, add \"waits_on\", one of: {\"merged\": N}, \
{\"closed\": N}, {\"green\": true}, {\"approved\": \"login\"}, {\"reviewed\": \"login\"} or {\"reviewed\": null} (anyone but the maintainer and the author).",
Of::Issues => " For one I should close (a duplicate, fixed by a merged PR, answered, moot), add \"close\": \"why, \
a few words\".",
};
let prompt = format!(
"{notes}I maintain this repo. {what}, one JSON object each, with what each needs from me:\n{}\n\n\
Put them in the order I should take them, most important first. Judge what matters: {judge}{waits} \
Change nothing. Reply with only \
a JSON array, every key exactly once, no other text: [{{\"key\": \"{key}\", \"reason\": \"why it's here, a \
few words\", \"next\": \"what I should do, a few words\"}}]",
lines.join("\n")
);
let tools = ["Bash(gh pr view:*)", "Bash(gh pr diff:*)", "Bash(gh issue view:*)"];
let out = crate::claude::claude_reader(crate::claude::Work::Rank, &prompt, root, &tools).stdin(std::process::Stdio::null()).output()?;
let v: Value = serde_json::from_slice(&out.stdout).unwrap_or_default();
anyhow::ensure!(v["is_error"] == false, "Claude couldn't rank them ({})", out.status);
Ok((parse(v["result"].as_str().unwrap_or(""))?, v["total_cost_usd"].as_f64().unwrap_or(0.0)))
}
fn parse(answer: &str) -> Result<Order> {
let v = crate::claude::json_in(answer, '[').filter(Value::is_array).context("no ranking in Claude's answer")?;
let s = |v: &Value| v.as_str().unwrap_or("").trim().to_owned();
let all = v.as_array().context("no ranking")?;
let order = all
.iter()
.map(|i| {
let (reason, next) = (s(&i["reason"]), s(&i["next"]));
let says = if next.is_empty() { reason } else { format!("{reason}\n{next}") };
(s(&i["key"]), says)
})
.filter(|(k, _)| !k.is_empty())
.collect();
let waits = all.iter().filter_map(|i| Some((s(&i["key"]), Until::from_json(i.get("waits_on")?)?))).collect();
let closes = all.iter().map(|i| (s(&i["key"]), s(&i["close"]))).filter(|(k, c)| !k.is_empty() && !c.is_empty()).collect();
Ok((order, waits, closes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gh::{Comment, Thread};
fn pr(n: u64, author: &str) -> Listed {
Listed { number: n, author: author.into(), state: "OPEN".into(), updated: "2026-10-01T00:00:00Z".into(), ..Listed::default() }
}
fn thread(comments: &[&str], resolved: bool) -> Thread {
let c = |a: &&str| Comment { author: (*a).into(), ..Comment::default() };
Thread { claude: Some("c1".into()), comments: comments.iter().map(c).collect(), resolved, ..Thread::default() }
}
const LOOKED: i64 = 1_795_000_000;
fn leak<T>(x: T) -> &'static T {
Box::leak(Box::new(x))
}
fn review(threads: Vec<Thread>, head: &str) -> crate::claude::Review {
crate::claude::Review { head: head.into(), summary: String::new(), description: String::new(), closing: String::new(), replace: String::new(), close: String::new(), threads }
}
#[test]
fn what_needs_you() {
let extra = |mine: Option<(&str, &str)>, requested, head: &str| Extra {
my_review: mine.map(|(s, c)| (s.into(), c.into())),
reviews: vec![],
said: vec![],
requested,
mergeable: "MERGEABLE".into(),
head_oid: head.into(),
base: "main".into(),
unsigned: 0,
signatures_required: false,
awaiting: 0,
unanswered: None,
};
let facts = |p: &'static Listed, e: Option<&'static Extra>, r: Option<&'static crate::claude::Review>| Facts { pr: p, extra: e, me: "me", review: r, looked: Some(LOOKED), review_changed: Some(50), unpushed: (0, 0), head_at: None, ci_note: None };
let (n, c) = needs(&facts(leak(pr(1, "amy")), Some(leak(extra(None, false, "h1"))), None));
assert_eq!((n, c), (vec![Need::NoReview], Some(Task::Review)));
let r = leak(review(vec![thread(&["claude", "you", "claude"], false), thread(&["claude"], false), thread(&["claude"], true)], "h2"));
let (n, c) = needs(&facts(leak(pr(2, "amy")), Some(leak(extra(Some(("CHANGES_REQUESTED", "h1")), false, "h2"))), Some(r)));
assert_eq!((n, c), (vec![Need::PushedSinceReview, Need::ClaudeAnswered(1), Need::ClaudeComments(1)], None));
let r = leak(review(vec![thread(&["claude", "you"], false)], "old"));
let (_, c) = needs(&facts(leak(pr(3, "me")), Some(leak(extra(None, false, "new"))), Some(r)));
assert_eq!(c, Some(Task::Answer { theirs: false }));
let r = leak(review(vec![], "old"));
let (_, c) = needs(&facts(leak(pr(3, "me")), Some(leak(extra(None, false, "new"))), Some(r)));
assert_eq!(c, Some(Task::Update("old".into())));
let mut p = pr(4, "amy");
(p.decision, p.checks) = ("APPROVED".into(), Some((3, 0, 0)));
let (n, _) = needs(&facts(leak(p), Some(leak(extra(None, true, "h"))), None));
assert_eq!(n, vec![Need::ReviewRequested, Need::ReadyToMerge]);
let (n, _) = needs(&facts(leak(pr(5, "me")), Some(leak(extra(None, false, "h"))), None));
assert_eq!(n, vec![]);
let mut p = pr(5, "me");
p.updated = "2026-12-01T00:00:00Z".into();
let (n, _) = needs(&facts(leak(p), Some(leak(extra(None, false, "h"))), None));
assert_eq!(n, vec![Need::NewActivity]);
let mut f = facts(leak(pr(8, "me")), Some(leak(extra(None, false, "h"))), None);
f.unpushed = (2, 1);
assert_eq!(needs(&f).0, vec![Need::Unpushed(2, 1)]);
assert_eq!(Need::Unpushed(2, 1).says(), "in its worktree: 2 uncommitted files and 1 commit the PR doesn't have (Space g there)");
let mut t = Thread { claude: Some("c1".into()), ..Default::default() };
t.comments = vec![crate::gh::Comment { author: crate::claude::AUTHOR.into(), body: "Changed: renamed it".into(), created: "2026-10-18T10:00:00Z".into(), ..Default::default() }];
let r = leak(crate::claude::Review { head: String::new(), summary: String::new(), description: String::new(), closing: String::new(), replace: String::new(), close: String::new(), threads: vec![t] });
let mut f = facts(leak(pr(8, "me")), Some(leak(extra(None, false, "h"))), Some(r));
f.unpushed = (2, 0);
f.head_at = crate::gh::epoch_secs("2026-10-18T09:00:00Z");
assert_eq!(needs(&f).0[0], Need::ClaudeChanged(2, 0));
assert!(Need::ClaudeChanged(2, 0).says().starts_with("Claude made changes, not pushed: 2 uncommitted files"));
assert!(Need::ClaudeChanged(2, 0).weight() > Need::Unpushed(2, 0).weight());
f.head_at = crate::gh::epoch_secs("2026-10-18T11:00:00Z");
assert_eq!(needs(&f).0[0], Need::Unpushed(2, 0));
let draft = |checks| {
let mut p = pr(9, "me");
(p.draft, p.checks) = (true, checks);
facts(leak(p), Some(leak(extra(None, false, "h"))), None)
};
assert!(needs(&draft(Some((4, 0, 0)))).0.contains(&Need::MarkReady));
for checks in [Some((3, 1, 0)), Some((3, 0, 1)), Some((0, 0, 0)), None] {
assert!(!needs(&draft(checks)).0.contains(&Need::MarkReady), "{checks:?}");
}
let mut f = draft(Some((4, 0, 0)));
f.unpushed = (1, 0);
assert!(!needs(&f).0.contains(&Need::MarkReady));
let unsigned = |required| {
let mut e = extra(None, false, "h");
(e.unsigned, e.signatures_required) = (1, required);
facts(leak(pr(10, "amy")), Some(leak(e)), None)
};
assert!(needs(&unsigned(true)).0.contains(&Need::Unsigned(1, false)));
assert!(!needs(&unsigned(false)).0.iter().any(|n| matches!(n, Need::Unsigned(..))));
let mut p = pr(12, "amy");
(p.decision, p.checks) = ("APPROVED".into(), Some((3, 0, 0)));
let mut e = extra(None, false, "h");
(e.unsigned, e.signatures_required) = (1, true);
let n = needs(&facts(leak(p), Some(leak(e)), None)).0;
assert!(n.contains(&Need::Unsigned(1, false)) && !n.contains(&Need::ReadyToMerge), "{n:?}");
assert_eq!(Need::Unsigned(1, false).says(), "1 commit unsigned, and its base requires signed ones: ask the author to sign them");
let ci = |author: &str, draft: bool| {
let mut p = pr(11, author);
(p.checks, p.draft) = (Some((5, 2, 0)), draft);
needs(&facts(leak(p), Some(leak(extra(None, false, "h"))), None)).0
};
assert!(ci("amy", false).contains(&Need::ChecksFailing(2, false)));
assert!(ci("me", true).contains(&Need::ChecksFailing(2, true)));
assert!(!ci("amy", true).iter().any(|n| matches!(n, Need::ChecksFailing(..))));
assert_eq!(Need::ChecksFailing(2, true).says(), "2 checks failing: fix them");
let fix = |author: &str, unpushed| {
let mut p = pr(11, author);
(p.checks, p.draft) = (Some((5, 2, 0)), true);
let mut f = facts(leak(p), Some(leak(extra(None, false, "h"))), None);
f.unpushed = unpushed;
needs(&f).1
};
assert_eq!(fix("me", (0, 0)), Some(Task::FixChecks));
assert_eq!(fix("me", (1, 0)), None);
assert_eq!(fix("amy", (0, 0)), None);
let mut p = pr(11, "me");
p.checks = Some((5, 2, 0));
let note = |head: &str| crate::claude::CiNote { head: head.into(), why: "flaky".into(), task: "pin the toolchain".into(), rerun: false };
let (here, old) = (note("h"), note("old"));
let mut f = facts(leak(p), Some(leak(extra(None, false, "h"))), None);
f.ci_note = Some(&here);
let (n, c) = needs(&f);
assert!(n.contains(&Need::CiNotThisPr("flaky".into(), true)) && c != Some(Task::FixChecks), "{n:?} {c:?}");
assert!(Need::CiNotThisPr("flaky".into(), true).says().ends_with("N N drafts a PR fixing it, a a has it try again"));
f.ci_note = Some(&old);
assert_eq!(needs(&f).1, Some(Task::FixChecks));
let mut e = extra(None, false, "h");
e.awaiting = 2;
assert!(needs(&facts(leak(pr(13, "amy")), Some(leak(e)), None)).0.contains(&Need::ApproveRuns(2)));
assert_eq!(Need::ApproveRuns(1).says(), "1 workflow run awaiting approval: approve it (W W)");
let it = Item { kind: crate::todo::Kind::Pr, number: 1, needs: vec![Need::ApproveRuns(3), Need::ChecksFailing(2, false), Need::NoReview], claude: None, updated: String::new(), reason: Some("bug fix — review".into()), changed: false, deferred: None, busy: None, waits: None };
assert_eq!(it.tags(), ["⏸ 3 runs to approve (W W)", "✗ 2 checks failing"]);
assert_eq!(it.says().split(" · ").count(), 3);
let mut conflicting = extra(None, false, "h");
conflicting.mergeable = "CONFLICTING".into();
assert_eq!(needs(&facts(leak(pr(7, "amy")), Some(leak(conflicting.clone())), None)).0, vec![Need::Conflicts(false), Need::NoReview]);
assert_eq!(needs(&facts(leak(pr(7, "me")), Some(leak(conflicting)), None)).0, vec![Need::Conflicts(true)]);
let mut f = facts(leak(pr(6, "me")), Some(leak(extra(None, false, "h"))), Some(leak(review(vec![], "h"))));
f.review_changed = Some(LOOKED + 1);
assert_eq!(needs(&f).0, vec![Need::ClaudeReviewNew]);
}
#[test]
fn replacements() {
let o = |n| crate::replace::Original { number: n, ..Default::default() };
let r = crate::replace::Record { id: 12, replaces: vec![o(12), o(15)], title: "Fix".into(), ..Default::default() };
assert_eq!(rep_needs(&r), vec![Need::Publish]);
let r = crate::replace::Record { published: Some(20), closed: vec![12], ..r };
assert_eq!(rep_needs(&r), vec![Need::CloseOriginals(1)]);
assert_eq!(rep_needs(&crate::replace::Record { closed: vec![12, 15], ..r }), vec![]);
}
#[test]
fn ordering() {
let it = |n, needs: Vec<Need>, updated: &str| Item { kind: crate::todo::Kind::Pr, number: n, needs, claude: None, updated: updated.into(), reason: None, changed: false, deferred: None, busy: None, waits: None };
let items = vec![it(1, vec![Need::NoReview], "b"), it(2, vec![Need::ReviewRequested], "c"), it(3, vec![Need::NoReview], "a")];
let (o, since) = order(items.clone(), None);
assert_eq!(since, Since::default());
assert!(o.iter().all(|i| !i.changed)); assert_eq!(o.iter().map(|i| i.number).collect::<Vec<_>>(), [2, 3, 1]);
let ranked = Ranked { at: 1, cost: 0.1, fingerprint: fingerprint(&items), waits: Default::default(), closes: Default::default(), items: vec![("pr-1".into(), "small fix — merge".into()), ("pr-3".into(), "x".into())] };
let (o, since) = order(items.clone(), Some(&ranked));
assert_eq!(since, Since::default()); assert_eq!(o.iter().map(|i| i.number).collect::<Vec<_>>(), [2, 1, 3]);
assert_eq!(o[1].reason.as_deref(), Some("small fix — merge"));
assert_eq!(o[0].says(), "your review is requested");
let mut now = vec![it(1, vec![Need::ReviewRequested], "b"), it(3, vec![Need::NoReview], "a"), it(9, vec![Need::NoReview], "d")];
now[0].needs.push(Need::NoReview);
let (o, since) = order(now, Some(&ranked));
assert_eq!(since, Since { new: 1, changed: 1, gone: 1 });
assert_eq!(o.iter().filter(|i| i.changed).map(|i| i.number).collect::<Vec<_>>().len(), 2);
let (p, waits, closes) = parse("Here: [{\"key\": \"pr-2\", \"reason\": \"bug fix\", \"next\": \"review it\"}, {\"key\": \"rep-12\", \"reason\": \"ready\", \"waits_on\": {\"merged\": 2380}}, {\"key\": \"issue-5\", \"reason\": \"dup\", \"close\": \"duplicate of #4\"}]").unwrap();
assert_eq!(closes.get("issue-5").map(String::as_str), Some("duplicate of #4"));
assert_eq!(p, [("pr-2".to_owned(), "bug fix\nreview it".to_owned()), ("rep-12".to_owned(), "ready".to_owned()), ("issue-5".to_owned(), "dup".to_owned())]);
assert_eq!(waits.get("rep-12"), Some(&Until::Merged(2380))); }
fn items_needs() -> String {
Need::ReadyToMerge.weight().to_string()
}
#[test]
fn or_a_time() {
let now = 1000;
assert_eq!(or_time("#2258 gets a push, or 2 weeks", now), ("#2258 gets a push".into(), Some(now + 14 * 86400)));
assert_eq!(or_time("#12 merged or closed", now), ("#12 merged or closed".into(), None)); assert_eq!(Until::parse("#2258 gets a push", now), Some(Until::PushedTo(2258, String::new())));
assert_eq!(Until::parse("#2258 gets a push (past abc1234)", now), Some(Until::PushedTo(2258, "abc1234".into())));
let u = Until::PushedTo(7, "abc1234".into());
assert_eq!(Until::from_json(&u.to_json()), Some(u));
assert_eq!(Until::from_json(&json!({ "pushed": 9 })), Some(Until::PushedTo(9, String::new()))); assert_eq!(Until::from_json(&json!({ "pushed": "#9" })), Some(Until::PushedTo(9, String::new())));
assert_eq!(or_time("İİİİİİİİİİİİİİ or 1h", now).1, Some(now + 3600)); let c = |who: &str, body: &str| (who.to_owned(), body.to_owned(), 0);
assert_eq!(waiting_on(&[c("me", "Waiting on #2258 to be updated")], now), None);
}
#[test]
fn waiting_on_from_comments() {
let c = |who: &str, body: &str| (who.to_owned(), body.to_owned(), 0);
let now = 0;
assert_eq!(waiting_on(&[c("bo", "Looks good.\n\nBlocked on #2380.")], now), Some((Until::Merged(2380), "bo".into(), 0)));
assert_eq!(waiting_on(&[c("me", "Waiting on: CI to pass")], now), Some((Until::Green, "me".into(), 0)));
assert_eq!(waiting_on(&[c("me", "waiting on @amy approves it")], now), Some((Until::Approved(Some("amy".into())), "me".into(), 0)));
assert_eq!(waiting_on(&[c("me", "Depends on #12 merged")], now), Some((Until::Merged(12), "me".into(), 0)));
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("bo", "Blocked on #2")], now).map(|w| w.0), Some(Until::Merged(2)));
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("me", "Unblocked: #1 is in")], now), None);
assert_eq!(waiting_on(&[c("me", "Waiting on @amy approves it"), c("me", "Waiting for reviews")], now).map(|w| w.0), Some(Until::Reviewed(None)));
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("me", "Blocked on the design call")], now), None);
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("bo", "Until then, LGTM")], now).map(|w| w.0), Some(Until::Merged(1)));
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("bo", "Waiting only for CI")], now).map(|w| w.0), Some(Until::Merged(1)));
assert_eq!(waiting_on(&[c("me", "Waiting for the review comments to be fixed")], now), None);
for t in ["Waiting for the review", "Waiting for your review", "Waiting on code review", "Waiting for maintainer review"] {
assert_eq!(waiting_on(&[c("me", t)], now).map(|w| w.0), Some(Until::Reviewed(None)), "{t}");
}
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("bo", "Until it's updated, LGTM")], now).map(|w| w.0), Some(Until::Merged(1)));
for t in ["**Blocked on** #12", "**Blocked on #12**", "Blocked on **#12**"] {
assert_eq!(waiting_on(&[c("me", "Blocked on #1"), c("bo", t)], now).map(|w| w.0), Some(Until::Merged(12)), "{t}");
}
for t in ["Waiting on CI, then review", "Waiting for me to review", "Waiting on @bo's review comments to be addressed"] {
assert!(!matches!(waiting_on(&[c("me", t)], now).map(|w| w.0), Some(Until::Reviewed(_))), "{t}");
}
assert_eq!(waiting_on(&[c("me", "Waiting on @bo's review")], now).map(|w| w.0), Some(Until::Reviewed(Some("bo".into()))));
assert_eq!(waiting_on(&[c("me", "I'm waiting to see what amy thinks")], now), None);
for u in [Until::Merged(2380), Until::Green, Until::Approved(Some("amy".into())), Until::Reviewed(Some("bo".into())), Until::Reviewed(None), Until::Closed(7), Until::Pushed("abc1234".into())] {
assert_eq!(waiting_on(&[c("me", &format!("Waiting on: {}.", u.says()))], now).map(|w| w.0), Some(u));
}
}
#[test]
fn deferral_conditions() {
let now = 1_000_000;
let p = |t: &str| Until::parse(t, now);
assert_eq!(p("#2380 is merged"), Some(Until::Merged(2380)));
assert_eq!(p("until 2380 closed"), Some(Until::Closed(2380)));
assert_eq!(p("CI is green"), Some(Until::Green));
assert_eq!(p("checks pass"), Some(Until::Green));
assert_eq!(p("@amy approves"), Some(Until::Approved(Some("amy".into()))));
assert_eq!(p("it's approved"), Some(Until::Approved(None)));
assert_eq!(p("@bo reviews it"), Some(Until::Reviewed(Some("bo".into()))));
assert_eq!(p("someone else reviews it"), Some(Until::Reviewed(None)));
assert_eq!(p("anyone reviews it"), Some(Until::Reviewed(None)));
assert_eq!(p("it's reviewed by someone."), Some(Until::Reviewed(None)));
assert_eq!(p("@bo reviews it (#12)"), None); assert_eq!(p("Blocked on #12 until someone reviews it"), None); assert_eq!(p("Blocked on #12 until it's approved"), None);
assert_eq!(p("Blocked on #12 until its CI passes"), None);
assert_eq!(p("review it in 2 days"), Some(Until::Time(now + 2 * 86400)));
assert_eq!(p("@bo reviews in 2 days"), Some(Until::Time(now + 2 * 86400))); for t in ["the review comments are fixed", "#12 is reviewed", "review again next week", "@bo reviews #12", "it's reviewed elsewhere"] {
assert_eq!(p(t), None, "{t}"); }
assert_eq!(Until::from_json(&json!({ "reviewed": null })), Some(Until::Reviewed(None)));
assert_eq!(Until::from_json(&json!({ "none": "why" })), None);
assert_eq!(p("3h"), Some(Until::Time(now + 3 * 3600)));
assert_eq!(p("2 days"), Some(Until::Time(now + 2 * 86400)));
assert_eq!(p("a week"), Some(Until::Time(now + 7 * 86400)));
assert_eq!(p("the release is out"), None); assert_eq!(p("Can't do anything until author addresses reviews"), Some(Until::Pushed(String::new())));
assert_eq!(p("the author pushes"), Some(Until::Pushed(String::new())));
assert_eq!(p("the author pushes (past abc1234)"), Some(Until::Pushed("abc1234".into())));
assert_eq!(p("Blocked on #12 until it's merged"), Some(Until::Merged(12)));
assert_eq!(p("#12, until it's merged"), Some(Until::Merged(12)));
assert_eq!(p("Blocked (see #12) until it's merged"), Some(Until::Merged(12)));
assert_eq!(p("Don't approve until the release"), None); assert_eq!(p("won't review until @amy pushes"), None);
assert_eq!(p("the author addresses the defaced docs"), Some(Until::Pushed(String::new())));
assert_eq!(p("@amy reviews the fixes"), Some(Until::Reviewed(Some("amy".into()))));
assert_eq!(p("3 days after the push"), Some(Until::Time(now + 3 * 86400)));
for u in [Until::Merged(1), Until::Green, Until::Approved(None), Until::Reviewed(Some("x".into())), Until::Time(5), Until::Pushed("abc".into())] {
assert_eq!(Until::from_json(&u.to_json()), Some(u));
}
assert_eq!(Until::from_json(&json!({ "at": "2026-10-20T09:00:00Z" })), crate::gh::epoch_secs("2026-10-20T09:00:00Z").map(Until::Time));
}
#[test]
fn deferring() {
let it = |n, needs: Vec<Need>| Item { kind: crate::todo::Kind::Pr, number: n, needs, claude: None, updated: String::new(), reason: None, changed: false, deferred: None, busy: None, waits: None };
let items = vec![it(1, vec![Need::ReadyToMerge]), it(2, vec![Need::NoReview])];
let d: Deferred = [("pr-1".to_owned(), Deferral { until: 200, needs: items[0].needs_key(), cond: None, at: 0 })].into();
let never = |_: &Item, _: &Until, _: i64| false;
let o = defer(items.clone(), &d, 100, never);
assert_eq!((o[0].number, o[1].number, o[1].deferred.as_ref().map(|d| d.until)), (2, 1, Some(200)));
assert_eq!(defer(items.clone(), &d, 300, never)[0].number, 1);
let mut changed = items;
changed[0].needs.push(Need::NewActivity);
assert_eq!(defer(changed, &d, 100, never)[0].deferred, None);
let d: Deferred = [("pr-1".to_owned(), Deferral { until: i64::MAX, needs: items_needs(), cond: Some(Until::Merged(2380)), at: 0 })].into();
let items = vec![it(1, vec![Need::ReadyToMerge]), it(2, vec![Need::NoReview])];
assert_eq!(defer(items.clone(), &d, 100, never)[1].number, 1);
assert_eq!(defer(items, &d, 100, |_, c, _| *c == Until::Merged(2380))[0].number, 1);
}
}