use super::*;
use crate::todo::{self, Facts, Item};
impl List {
pub(super) fn find_todo(&mut self) {
let dir = self.reviews_dir.clone();
let seen = dir.as_ref().and_then(|d| d.parent()).map(|d| d.join("seen"));
let ci = dir.as_ref().and_then(|d| d.parent()).map(|d| d.join("ci"));
let secs = |p: std::path::PathBuf| -> Option<i64> {
let t = std::fs::metadata(p).ok()?.modified().ok()?;
Some(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64)
};
let mut items = vec![];
let mut closable = HashMap::new();
let mut replaceable = HashMap::new();
let mut claude_open = HashMap::new();
for p in self.prs.iter().filter(|p| p.open()) {
let file = dir.as_ref().map(|d| d.join(format!("pr-{}.json", p.number)));
let (review, unreadable) = match file.as_ref().map(|f| crate::claude::load(f)) {
Some(Ok(r)) => (r, false),
Some(Err(_)) => (None, true),
None => (None, false),
};
if let Some(r) = review.as_ref().filter(|r| !r.close.trim().is_empty()) {
let head = self.extra.get(&p.number).map_or("", |e| e.head_oid.as_str());
let older = !r.head.is_empty() && !head.is_empty() && r.head != head;
closable.insert(p.number, format!("{}{}", r.close.trim(), if older { " (reviewed at an older head)" } else { "" }));
}
match &review {
Some(r) => {
let head = self.extra.get(&p.number).map_or("", |e| e.head_oid.as_str());
let older = !r.head.is_empty() && !head.is_empty() && r.head != head;
claude_open.insert(p.number, Some((r.threads.iter().filter(|t| t.by_claude() && !t.resolved).count(), older)));
}
None if unreadable => _ = claude_open.insert(p.number, None),
None => {}
}
if let Some(r) = review.as_ref().filter(|r| !r.replace.trim().is_empty() && r.close.trim().is_empty()) {
replaceable.insert(p.number, r.replace.trim().to_owned());
}
let looked = seen.as_ref().and_then(|s| std::fs::read_to_string(s.join(format!("pr-{}.looked", p.number))).ok()?.trim().parse().ok());
let (f, c, head_at) = self.unpushed.get(&p.number).copied().unwrap_or((0, 0, None));
let ci_note = ci.as_ref().and_then(|d| crate::claude::ci_note_at(&d.join(format!("pr-{}.json", p.number))));
let facts = Facts { pr: p, extra: self.extra.get(&p.number), me: &self.me, review: review.as_ref(), looked, review_changed: file.and_then(secs), unpushed: (f, c), head_at, ci_note: ci_note.as_ref() };
let (needs, claude) = todo::needs(&facts);
if !needs.is_empty() {
items.push(Item { kind: crate::todo::Kind::Pr, number: p.number, needs, claude, updated: p.updated.clone(), reason: None, changed: false, deferred: None, busy: None, waits: None });
}
}
self.closable = closable;
self.replaceable = replaceable;
self.claude_open = claude_open;
for r in self.rep_of.values() {
let needs = todo::rep_needs(r);
let updated = self.reps.iter().find(|c| c.number == r.id).map_or(String::new(), |c| c.updated.clone());
if !needs.is_empty() {
items.push(Item { kind: crate::todo::Kind::Rep, number: r.id, needs, claude: None, updated, reason: None, changed: false, deferred: None, busy: None, waits: None });
}
}
let (items, since) = todo::order(items, self.ranked.as_ref());
let now = crate::claude::now_secs();
let met: Vec<(String, String)> = items
.iter()
.filter_map(|it| Some((it, self.deferred.get(&it.key())?)))
.filter_map(|(it, d)| Some((it, d.cond.as_ref()?, d.at)))
.filter(|(it, c, at)| self.happened(it, c, *at))
.map(|(it, c, _)| (it, c))
.map(|(it, c)| (it.key(), format!("#{} is back: {}", it.number, c.says())))
.collect();
for (key, said) in &met {
self.deferred.remove(key);
self.msg = said.clone();
}
if !met.is_empty() {
_ = todo::save_deferred(self.deferred_file.as_deref(), &self.deferred);
}
let mut items = todo::defer(items, &self.deferred, now, |it, c, at| self.happened(it, c, at));
for it in items.iter_mut().filter(|it| !it.is_rep() && it.deferred.is_none()) {
let said = self.extra.get(&it.number).map(|e| e.said.as_slice()).unwrap_or(&[]);
let claude = self.ranked.as_ref().and_then(|r| r.waits.get(&it.key())).map(|c| (c.clone(), String::new()));
let claude = claude.filter(|(c, _)| !c.unwatchable());
let (waits, since) = match todo::waiting_on(said, now) {
Some((c, who, at)) => (Some((c, who)), at),
None => (claude, self.ranked.as_ref().map_or(0, |r| r.at)),
};
it.waits = waits.filter(|(c, _)| !self.happened(it, c, since));
}
let (mut waiting, mut rest): (Vec<Item>, Vec<Item>) = items.into_iter().partition(|it| it.waits.is_some());
rest.append(&mut waiting);
let items = rest;
let doing = |it: &Item| match it.is_rep() {
true => self.drafting_replacement(it.number).then(|| "drafting it".to_owned()).or_else(|| self.running_reps.get(&it.number).cloned()),
false => self.claude_doing(it.number).or_else(|| self.drafting_replacement(it.number).then(|| "drafting a replacement".to_owned())),
};
let (mut busy, todo): (Vec<Item>, Vec<Item>) = items.into_iter().partition(|it| doing(it).is_some());
busy.iter_mut().for_each(|it| it.busy = doing(it));
let (mut ready, todo): (Vec<Item>, Vec<Item>) = todo.into_iter().partition(|it| it.is_rep() && it.needs.contains(&todo::Need::Publish));
let ci_running = |it: &Item| {
let n = self.pr_of(it);
n.and_then(|n| self.prs.iter().find(|p| p.number == n)).and_then(|p| p.checks).is_some_and(|c| c.2 > 0 && c.1 == 0)
};
let (mut later, todo): (Vec<Item>, Vec<Item>) = todo.into_iter().partition(|it| it.deferred.is_some() || it.waits.is_some());
let (mut ci, mut todo): (Vec<Item>, Vec<Item>) = todo.into_iter().partition(ci_running);
ready.append(&mut todo);
ready.append(&mut ci);
ready.append(&mut later);
ready.append(&mut busy);
(self.todo, self.since) = (ready, since);
self.auto_run();
}
fn auto_run(&mut self) {
if !self.auto {
return;
}
let todo: Vec<Item> = self.todo.iter().filter(|it| it.busy.is_none() && it.deferred.is_none() && it.waits.is_none()).cloned().collect();
let mut close = vec![];
for it in todo.iter().filter(|it| it.is_rep() && it.needs.iter().any(|n| matches!(n, todo::Need::CloseOriginals(_)))) {
let Some(r) = self.rep_of.get(&it.number) else { continue };
let Some(p) = r.published.and_then(|n| self.prs.iter().find(|p| p.number == n)) else { continue };
if !p.checks_head.is_empty() && todo::green(p.checks) && self.auto_done.insert((it.number, p.checks_head.clone(), "close")) {
close.push(r.clone());
}
}
let closing: HashSet<u64> = close.iter().flat_map(|r| r.replaces.iter().map(|o| o.number)).collect();
let (mut run, mut ready) = (vec![], vec![]);
let batch_busy = self.batch.values().any(batch::Status::running);
for it in todo.iter().filter(|it| !it.is_rep() && !closing.contains(&it.number)) {
let Some(p) = self.prs.iter().find(|p| p.number == it.number) else { continue };
let Some(head) = self.extra.get(&it.number).map(|e| e.head_oid.clone()).filter(|h| !h.is_empty()) else { continue };
let ci_here = head == p.checks_head;
match it.claude.clone() {
Some(t) if !batch_busy && run.len() < batch::AT_ONCE && (t != batch::Task::FixChecks || ci_here) => {
let at = match t {
batch::Task::Answer { .. } => format!("{head}@{}", self.review_mtime(it.number)),
_ => head,
};
if self.auto_done.insert((it.number, at, t.doing())) {
run.push((it.number, t));
}
}
_ if ci_here && it.needs.contains(&todo::Need::MarkReady) && self.auto_done.insert((it.number, head, "ready")) => ready.push(it.number),
_ => {}
}
}
let skip: HashSet<u64> = self
.todo
.iter()
.filter(|it| it.busy.is_some() || it.deferred.is_some() || it.waits.is_some())
.map(|it| it.number)
.chain(run.iter().map(|r| r.0))
.chain(closing.iter().copied())
.chain(self.prs.iter().filter(|p| p.author == self.me).map(|p| p.number))
.collect();
for &n in self.closable.keys().filter(|&&n| !skip.contains(&n)) {
if batch_busy || run.len() >= batch::AT_ONCE {
break;
}
let Some(head) = self.extra.get(&n).map(|e| e.head_oid.clone()).filter(|h| !h.is_empty()) else { continue };
let file = self.reviews_dir.as_ref().map(|d| d.join(format!("pr-{n}.json")));
let drafted = file.and_then(|f| crate::claude::load(&f).ok().flatten()).is_some_and(|r| !r.closing.trim().is_empty());
if !drafted && self.auto_done.insert((n, head, "close-draft")) {
run.push((n, batch::Task::Close));
}
}
let file = crate::claude::dv_dir().ok().map(|d| d.join("described.json")).filter(|_| !cfg!(test));
if self.described.is_empty() {
self.described = file.as_ref().and_then(|f| serde_json::from_str(&std::fs::read_to_string(f).ok()?).ok()).unwrap_or_default();
}
let mut noted = false;
let busy = |n: u64| self.todo.iter().any(|it| it.number == n && (it.busy.is_some() || it.deferred.is_some()));
for p in self.prs.iter().filter(|p| p.open() && p.author == self.me) {
let Some(head) = self.extra.get(&p.number).map(|e| e.head_oid.clone()).filter(|h| !h.is_empty()) else { continue };
match self.described.get(&p.number).cloned() {
None => {
self.described.insert(p.number, head);
noted = true;
}
Some(old) if old != head && !p.draft && !batch_busy && run.len() < batch::AT_ONCE && !busy(p.number) && !run.iter().any(|r| r.0 == p.number) => {
let f = self.reviews_dir.as_ref().map(|d| d.join(format!("pr-{}.json", p.number)));
let review = f.and_then(|f| crate::claude::load(&f).ok().flatten());
if review.as_ref().is_some_and(|r| r.description.trim().is_empty()) {
run.push((p.number, batch::Task::Describe(old)));
self.described.insert(p.number, head);
noted = true;
}
}
_ => {}
}
}
if let (true, Some(f)) = (noted, &file) {
_ = std::fs::write(f, serde_json::to_string(&self.described).unwrap_or_default());
}
let drafting = self.drafting.values().any(batch::Status::running) || !self.drafting_elsewhere.is_empty();
let replaced = |n: u64| self.rep_of.values().any(|r| r.replaces.iter().any(|o| o.number == n));
let next = self.replaceable.iter().find_map(|(&n, why)| {
let head = self.extra.get(&n).map(|e| e.head_oid.clone()).filter(|h| !h.is_empty())?;
(!replaced(n) && !skip.contains(&n) && !self.auto_done.contains(&(n, head.clone(), "replace"))).then(|| (n, head, why.clone()))
});
let mut replacing = None;
if let (false, Some((n, head, why))) = (drafting, next) {
self.auto_done.insert((n, head, "replace"));
let saved = crate::claude::instructions("replace");
let extra = format!("{}Claude's review of #{n} found it worth replacing: {why}", if saved.trim().is_empty() { String::new() } else { format!("{}\n\n", saved.trim()) });
self.draft_under_way(n, batch::replace(vec![n], self.root.clone(), extra));
replacing = Some(n);
}
let mut said = vec![];
if let Some(n) = replacing {
said.push(format!("Claude is drafting a replacement of #{n} (its review: low effort, worth keeping; the drafts tab has it)"));
}
if !run.is_empty() {
said.push(format!("Claude is {}", self.start_batch(run, String::new())));
}
for n in ready {
said.push(match crate::gh::mark_ready(n) {
Ok(()) => format!("#{n} marked ready for review (CI passed)"),
Err(e) => format!("#{n} not marked ready: {e:#}"),
});
}
for mut r in close {
let n = r.published.unwrap_or(0);
let note = crate::replace::note(&r);
let res = self.rep_pushed(&r).and_then(|()| crate::replace::close_originals(&mut r, ¬e));
said.push(match res {
Ok(done) => format!("closed {} (replaced by #{n}, its CI passed)", done.iter().map(|o| format!("#{o}")).collect::<Vec<_>>().join(", ")),
Err(e) => format!("not closing what #{n} replaces: {e:#}"),
});
self.load_reps();
}
let due = self.auto_ranked.is_none_or(|t| t.elapsed() > std::time::Duration::from_secs(15 * 60));
let changed = self.ranked.is_none() || self.since.new + self.since.changed > 0;
if due && changed && self.rank_rx.is_none() && !self.want_rank && !self.todo.is_empty() {
self.auto_ranked = Some(std::time::Instant::now());
self.rank_now();
}
if !said.is_empty() {
self.msg = format!("auto: {}", said.join("; "));
}
}
fn review_mtime(&self, n: u64) -> u64 {
let f = self.reviews_dir.as_ref().map(|d| d.join(format!("pr-{n}.json")));
let t = f.and_then(|f| std::fs::metadata(f).ok()?.modified().ok());
t.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs())
}
fn rep_pushed(&self, r: &crate::replace::Record) -> anyhow::Result<()> {
let dir = crate::worktree_dir_named(&self.root, &crate::replace::name(r.id))?;
let d = dir.to_string_lossy().into_owned();
let git = |args: &[&str]| crate::diff::git(&[&["-C", d.as_str()], args].concat()).unwrap_or_default();
anyhow::ensure!(git(&["status", "--porcelain"]).trim().is_empty(), "its worktree has uncommitted changes");
anyhow::ensure!(git(&["rev-list", "--count", "@{upstream}..HEAD"]).trim() == "0", "its worktree has commits not pushed");
let n = r.published.unwrap_or(0);
anyhow::ensure!(todo::green(Some(crate::gh::checks(n)?)), "#{n}'s CI isn't all green on GitHub now");
Ok(())
}
pub(super) fn todo_shown(&self) -> Vec<(&Listed, &Item)> {
self.todo
.iter()
.filter_map(|it| {
let cards = if it.is_rep() { &self.reps } else { &self.prs };
Some((cards.iter().find(|c| c.number == it.number)?, it))
})
.collect()
}
pub(super) fn rank_now(&mut self) {
if self.rank_rx.is_some() || self.todo.is_empty() {
return;
}
if self.extra_rx.is_some() || self.checks.is_some() {
self.msg = "Claude ranks what needs you once GitHub's part is in…".into();
return self.want_rank = true;
}
self.want_rank = false;
let lines: Vec<String> = self
.todo_shown()
.into_iter()
.filter(|(_, it)| it.deferred.is_none()) .map(|(card, it)| {
let summary = match it.is_rep() {
true => self.rep_of.get(&it.number).map(|r| r.body.clone()).unwrap_or_default(),
false => self.reviews_dir.as_ref().and_then(|d| crate::claude::load(&d.join(format!("pr-{}.json", it.number))).ok().flatten()).map(|r| r.summary).unwrap_or_default(),
};
todo::about(it, card, &summary)
})
.collect();
self.rank_rx = Some(todo::rank_of(lines, todo::fingerprint(&self.todo), self.root.clone(), todo::Of::Prs));
self.msg = format!("Claude is ranking the {} PR(s) that need you…", self.todo.len());
}
pub(super) fn rank_offer(&self) -> String {
let last = match &self.ranked {
Some(r) => format!("the last, {} ago, {}", crate::gh::ago_secs(crate::claude::now_secs() - r.at), cost_text(r.cost)),
None => "never ranked before".into(),
};
let since = self.since_text();
format!("r again: Claude ranks the {} PR(s) that need you, one run ({last}{since}); nothing else starts", self.todo.len())
}
pub(super) fn since_text(&self) -> String {
let s = self.since;
let parts: Vec<String> = [(s.new, "new"), (s.changed, "changed"), (s.gone, "seen to")].iter().filter(|p| p.0 > 0).map(|(n, w)| format!("{n} {w}")).collect();
match (self.ranked.is_some(), parts.is_empty()) {
(false, _) => String::new(),
(true, true) => ", nothing's changed since".into(),
(true, false) => format!(", since: {}", parts.join(", ")),
}
}
pub(super) fn todo_news(&mut self) {
if let Some(Ok(res)) = self.extra_rx.as_ref().map(Receiver::try_recv) {
self.extra_rx = None;
match res {
Ok((me, extra)) => {
(self.me, self.extra) = (me, extra);
self.scan_unpushed(); }
Err(e) => self.msg = format!("what needs you: {e:#}"),
}
self.find_todo();
}
if self.want_rank {
self.rank_now();
}
let Some(Ok(res)) = self.rank_rx.as_ref().map(Receiver::try_recv) else { return };
self.rank_rx = None;
match res {
Ok(r) => {
self.ranked = Some(r);
self.find_todo();
self.msg = "Claude ranked what needs you: a a on one has it do its part there".into();
}
Err(e) => self.msg = format!("ranking: {e:#} (r r tries again)"),
}
}
pub(super) fn scan_unpushed(&mut self) {
if self.unpushed_rx.is_some() {
self.rescan = true; return;
}
let local: Vec<(u64, String)> = self.local.iter().map(|(&n, (dir, _))| (n, dir.to_string_lossy().into_owned())).collect();
let heads: HashMap<u64, String> = self.extra.iter().map(|(&n, e)| (n, e.head_oid.clone())).collect();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let found = local
.into_iter()
.filter_map(|(n, d)| {
let git = |args: &[&str]| crate::diff::git(&[&["-C", d.as_str()], args].concat()).ok();
let files = git(&["status", "--porcelain"])?.lines().count();
let head = heads.get(&n).cloned().filter(|h| git(&["cat-file", "-e", &format!("{h}^{{commit}}")]).is_some());
let theirs = head.unwrap_or_else(|| "@{upstream}".into());
let here = git(&["rev-parse", "HEAD"]).unwrap_or_default();
let stale = crate::last_checkout(n).is_some_and(|c| c == here.trim());
let commits = match stale {
true => 0,
false => git(&["rev-list", "--count", &format!("{theirs}..HEAD")]).and_then(|c| c.trim().parse().ok()).unwrap_or(0),
};
let at = git(&["log", "-1", "--format=%ct", &theirs]).and_then(|t| t.trim().parse().ok());
(files + commits > 0).then_some((n, (files, commits, at)))
})
.collect();
_ = tx.send(found);
});
self.unpushed_rx = Some(rx);
}
pub(super) fn gone_news(&mut self) {
let mut found = vec![];
self.gone_rx.retain(|rx| match rx.try_recv() {
Ok(f) => {
found.extend(f);
false
}
Err(mpsc::TryRecvError::Empty) => true,
Err(mpsc::TryRecvError::Disconnected) => false,
});
if self.gone_rx.is_empty() {
self.gone_pending.clear(); }
if found.is_empty() {
return;
}
for (n, p) in found {
self.gone_pending.remove(&n);
let Some(o) = self.prs.iter_mut().find(|o| o.number == n) else { continue };
match p {
Some(p) => {
self.gone.insert(n, p.clone());
let (checks, head) = (o.checks, std::mem::take(&mut o.checks_head));
*o = Listed { checks, checks_head: head, ..p };
}
None => o.title = "(couldn't load this PR)".into(),
}
}
self.prune_marks();
self.find_todo();
}
pub(super) fn unpushed_news(&mut self) {
let found = match self.unpushed_rx.as_ref().map(Receiver::try_recv) {
Some(Ok(found)) => found,
Some(Err(mpsc::TryRecvError::Disconnected)) => return self.unpushed_rx = None, _ => return,
};
(self.unpushed, self.unpushed_rx) = (found, None);
self.find_todo();
if std::mem::take(&mut self.rescan) {
self.scan_unpushed();
}
}
pub(super) fn happened(&self, it: &Item, c: &todo::Until, since: i64) -> bool {
let n = self.pr_of(it);
let pr = self.prs.iter().find(|p| Some(p.number) == n);
let state = |n: u64| self.prs.iter().find(|p| p.number == n).map(|p| p.state.clone()).or_else(|| self.others.get(&n).cloned());
let extra = n.and_then(|n| self.extra.get(&n));
let reviews: Vec<&(String, String, i64)> = extra.into_iter().flat_map(|e| &e.reviews).filter(|r| r.2 > since).collect();
match c {
todo::Until::Time(t) => crate::claude::now_secs() >= *t,
todo::Until::Merged(n) => state(*n).is_some_and(|s| s == "MERGED"),
todo::Until::Closed(n) => state(*n).is_some_and(|s| s == "MERGED" || s == "CLOSED"),
todo::Until::Green => todo::green(pr.and_then(|p| p.checks)),
todo::Until::Approved(None) => since == 0 && pr.is_some_and(|p| p.decision == "APPROVED") || reviews.iter().any(|r| r.1 == "APPROVED"),
todo::Until::Approved(Some(who)) => reviews.iter().any(|r| &r.0 == who && r.1 == "APPROVED"),
todo::Until::Reviewed(Some(who)) => reviews.iter().any(|r| &r.0 == who),
todo::Until::Reviewed(None) => reviews.iter().any(|r| r.0 != self.me && pr.is_some_and(|p| r.0 != p.author)),
todo::Until::PushedTo(n, head) => {
self.extra.get(n).is_some_and(|e| todo::Until::moved_past(head, &e.head_oid)) || state(*n).is_some_and(|s| s != "OPEN")
}
todo::Until::Pushed(head) => extra.is_some_and(|e| todo::Until::moved_past(head, &e.head_oid)),
}
}
pub(super) fn defer_until(&mut self, key: String, text: &str) {
let (text, or) = todo::or_time(text, crate::claude::now_secs());
match todo::Until::parse(&text, crate::claude::now_secs()) {
Some(c) => self.defer_on(&key, c, or),
None => {
self.msg = "Claude is reading when you mean…".into();
self.until_rx = Some((key, or, todo::interpret(text, "a pull request".into(), self.root.clone())));
}
}
}
pub(super) fn pr_of(&self, it: &Item) -> Option<u64> {
match it.is_rep() {
true => self.rep_of.get(&it.number).and_then(|r| r.published),
false => Some(it.number),
}
}
fn defer_on(&mut self, key: &str, c: todo::Until, or: Option<i64>) {
let Some(it) = self.todo.iter().find(|it| it.key() == key).cloned() else { return };
let n = self.pr_of(&it);
let extra = n.and_then(|n| self.extra.get(&n)).cloned();
let c = match c {
todo::Until::Green | todo::Until::Approved(_) | todo::Until::Reviewed(_) | todo::Until::Pushed(_) if n.is_none() => {
return self.msg = "not deferred: it isn't published yet, so there's no PR to watch".into();
}
todo::Until::Pushed(_) => match extra.as_ref().map(|e| e.head_oid.clone()).filter(|h| !h.is_empty()) {
Some(h) => todo::Until::Pushed(h),
None => return self.msg = "not deferred: its head on GitHub isn't known yet, to see a push by (try again in a moment)".into(),
},
todo::Until::PushedTo(n, _) => match self.extra.get(&n).map(|e| e.head_oid.clone()).filter(|h| !h.is_empty()) {
Some(h) => todo::Until::PushedTo(n, h),
None => return self.msg = format!("not deferred: #{n}'s head isn't known (an open PR of this repo's?), to see a push by"),
},
todo::Until::Approved(_) | todo::Until::Reviewed(_) if extra.is_none() => {
return self.msg = "not deferred: its reviews on GitHub aren't known yet, to tell a new one by (try again in a moment)".into();
}
c => c,
};
let (until, cond, says) = todo::until_or(c, or);
let at = extra.and_then(|e| e.reviews.iter().map(|r| r.2).max()).unwrap_or(0);
self.deferred.insert(key.to_owned(), todo::Deferral { until, needs: it.needs_key(), cond, at });
self.msg = format!("deferred until {says} (s on it brings it back)");
self.find_others();
self.save_defer();
}
pub(super) fn until_news(&mut self) -> bool {
let Some(Ok(res)) = self.until_rx.as_ref().map(|(_, _, rx)| rx.try_recv()) else { return false };
let Some((key, or, _)) = self.until_rx.take() else { return false };
match res {
Ok(c) => {
let on_it = self.selected_item().is_some_and(|it| it.key() == key);
self.defer_on(&key, c, or);
on_it
}
Err(e) => {
self.msg = format!("not deferred: {e:#}");
false
}
}
}
pub(super) fn find_others(&mut self) {
let waited: Vec<u64> = self
.deferred
.values()
.filter_map(|d| match d.cond {
Some(todo::Until::Merged(n) | todo::Until::Closed(n) | todo::Until::PushedTo(n, _)) => Some(n),
_ => None,
})
.filter(|n| !self.prs.iter().any(|p| p.number == *n) && !self.others_pending.contains(n))
.collect();
if waited.is_empty() {
return;
}
self.others_pending.extend(&waited);
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || _ = tx.send(waited.into_iter().filter_map(|n| Some((n, gh::pr_state(n).ok()??))).collect()));
self.others_rx.push(rx);
}
pub(super) fn others_news(&mut self) {
let mut found = vec![];
self.others_rx.retain(|rx| match rx.try_recv() {
Ok(f) => {
found.extend(f);
false
}
Err(mpsc::TryRecvError::Empty) => true,
Err(mpsc::TryRecvError::Disconnected) => false,
});
if self.others_rx.is_empty() {
self.others_pending.clear(); }
if found.is_empty() {
return;
}
self.others.extend(found);
self.find_todo();
}
pub(super) fn todo_now(&self) -> usize {
self.todo.iter().filter(|it| it.deferred.is_none() && it.busy.is_none() && it.waits.is_none()).count()
}
pub(super) fn rep_busy(&self, id: u64) -> bool {
self.drafting.get(&id).is_some_and(batch::Status::running) || self.drafting_elsewhere.contains(&id) || self.running_reps.contains_key(&id)
}
pub(super) fn drafting_replacement(&self, n: u64) -> bool {
self.rep_of.values().filter(|r| r.id == n || r.replaces.iter().any(|o| o.number == n)).any(|r| {
self.drafting.get(&r.id).is_some_and(batch::Status::running) || self.drafting_elsewhere.contains(&r.id)
})
}
pub(super) fn claude_doing(&self, n: u64) -> Option<String> {
match self.batch.get(&n) {
Some(s) if s.running() => Some(self.batch_task.get(&n).map_or("reviewing", batch::Task::doing).to_owned()),
_ => self.running.get(&n).cloned(),
}
}
pub(super) fn watch_running(&mut self) -> bool {
if self.running_checked.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2)) {
return false;
}
self.running_checked = Some(std::time::Instant::now());
let Some(dir) = self.reviews_dir.as_ref().and_then(|d| d.parent()).map(|d| d.join("running")) else { return false };
let panes = std::cell::OnceCell::new(); let alive = |pid: Option<u32>, session: Option<&str>| match (pid, session) {
(Some(pid), _) => std::process::Command::new("kill").args(["-0", &pid.to_string()]).stderr(std::process::Stdio::null()).status().is_ok_and(|s| s.success()),
(None, Some(id)) => panes.get_or_init(open_panes).iter().find(|p: &&Pane| p.1.contains(id)).is_some_and(|p| {
let screen = crate::pane::screen(p.0);
crate::pane::working(&screen) || crate::pane::asking(&screen)
}),
_ => false,
};
let mut now = crate::claude::running(&dir, alive);
let mut reps = HashMap::new();
let rep_dirs: Vec<(u64, std::path::PathBuf)> =
self.rep_of.keys().filter_map(|&id| Some((id, crate::worktree_dir_named(&self.root, &crate::replace::name(id)).ok()?))).collect();
if crate::view::in_zellij() && (!self.local.is_empty() || !rep_dirs.is_empty()) {
for (id, _, cwd) in panes.get_or_init(open_panes) {
let pr = self.local.iter().find(|(_, (d, _))| cwd.starts_with(d)).map(|(n, _)| *n).filter(|n| !now.contains_key(n));
let rep = rep_dirs.iter().find(|(_, d)| cwd.starts_with(d)).map(|(r, _)| *r);
if pr.is_none() && rep.is_none() {
continue;
}
let screen = crate::pane::screen(*id);
let doing = match (crate::pane::asking(&screen), crate::pane::working(&screen)) {
(true, _) => "asking you (in its pane)",
(_, true) => "working (in its pane)",
_ => continue,
};
match (pr, rep) {
(Some(n), _) => now.insert(n, doing.into()),
(_, Some(r)) => reps.insert(r, doing.into()),
_ => None,
};
}
}
for (n, session, since) in crate::claude::pane_runs(&dir).into_iter().filter(|r| !now.contains_key(&r.0)) {
if let Some(p) = panes.get_or_init(open_panes).iter().find(|p| p.1.contains(&session))
&& crate::pane::close_if_done(p.0, &session, since)
{
crate::claude::clear_running(n);
}
}
let changed = now != self.running || reps != self.running_reps;
(self.running, self.running_reps) = (now, reps);
changed
}
pub(super) fn defer_key(&mut self) {
let Some(it) = self.selected_item() else { return };
let what = if it.is_rep() { crate::replace::label(it.number) } else { format!("#{}", it.number) };
if it.deferred.is_some() {
self.deferred.remove(&it.key());
self.msg = format!("{what} is back");
return self.save_defer();
}
self.msg = format!("defer {what} for: 1 an hour, 4 four hours, d a day, w a week, or u until something happens (any other key: not); it's back sooner if what it needs changes");
self.deferring = Some(it.key());
}
pub(super) fn defer_for(&mut self, key: &str, c: char) {
let Some(it) = self.todo.iter().find(|it| it.key() == key).cloned() else { return };
let (secs, how) = todo::defer_secs(c);
self.deferred.insert(it.key(), todo::Deferral { until: crate::claude::now_secs() + secs, needs: it.needs_key(), cond: None, at: 0 });
self.msg = format!("deferred for {how} (s on it brings it back)");
self.save_defer();
}
pub(super) fn save_defer(&mut self) {
if let Err(e) = todo::save_deferred(self.deferred_file.as_deref(), &self.deferred) {
self.msg = format!("couldn't save it: {e:#}");
}
self.find_todo();
}
pub(super) fn todo_task(&self, n: u64) -> batch::Task {
let it = self.todo.iter().find(|it| !it.is_rep() && it.number == n);
let retry = it.is_some_and(|it| {
it.needs.iter().any(|n| matches!(n, todo::Need::CiNotThisPr(..)))
&& !it.needs.iter().any(|n| matches!(n, todo::Need::Unpushed(..) | todo::Need::ClaudeChanged(..)))
&& !matches!(it.claude, Some(batch::Task::Answer { .. } | batch::Task::Update(_)))
});
if retry {
return batch::Task::FixChecks;
}
it.and_then(|it| it.claude.clone()).unwrap_or(batch::Task::Review)
}
}
pub(super) fn cost_text(c: f64) -> String {
if c > 0.0 { format!("cost ${c:.2}") } else { "cost not recorded".into() }
}
type Pane = (u64, String, std::path::PathBuf);
fn open_panes() -> Vec<Pane> {
if !crate::view::in_zellij() {
return vec![];
}
crate::pane::list(&["-c"])
.unwrap_or_default()
.iter()
.filter(|p| p["exited"] != true && p["is_plugin"] != true)
.filter_map(|p| Some((p["id"].as_u64()?, p["pane_command"].as_str()?.to_owned(), std::path::PathBuf::from(p["pane_cwd"].as_str().unwrap_or("")))))
.collect()
}