use super::*;
pub(super) const STALE: &str = "showing the PR as dv last saw it; refreshing from GitHub…";
pub(super) const GITHUB_POLL: std::time::Duration = std::time::Duration::from_secs(5);
pub(super) type Fetched = (Result<gh::Review>, Result<Info>);
pub(super) fn fetch_github(n: u64, cache: Option<&Path>) -> Fetched {
let (threads, mut info) = std::thread::scope(|s| {
let info = s.spawn(|| gh::info_json(n));
(gh::threads_json(n), info.join().unwrap_or_else(|p| Err(anyhow!(crate::batch::panicked(&p)))))
});
if let (Some(p), Ok(i)) = (cache, &mut info)
&& let Some(old) = std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())
{
gh::keep_known(i, &old["info"]);
}
if let (Some(p), Ok(t), Ok(i)) = (cache, &threads, &info) {
_ = p.parent().map(std::fs::create_dir_all);
_ = crate::claude::write(p, &json!({ "threads": t, "info": i }));
}
(threads.map(|t| gh::parse_threads(&t)), info.map(|i| parse_info(&i)))
}
pub(crate) fn recent_github(n: u64) -> Option<(gh::Pr, Value)> {
let p = cache_path(n).filter(|p| gh::recent(p))?;
let v: Value = serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()?;
Some((gh::pr_of(&v["info"]).ok()?, v["threads"].clone()))
}
pub(super) fn cache_path(n: u64) -> Option<PathBuf> {
Some(crate::claude::dv_dir().ok()?.join("cache").join(format!("pr-{n}.json")))
}
pub(crate) fn prefetch_github(n: u64) {
if let Some(p) = cache_path(n).filter(|p| !p.exists()) {
_ = fetch_github(n, Some(&p));
}
}
pub(super) fn cached_github(p: &Path) -> Option<Fetched> {
let v: Value = serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()?;
Some((Ok(gh::parse_threads(&v["threads"])), Ok(parse_info(&v["info"]))))
}
pub(super) fn base_note_in(msg: &str, last: &str, note: &str) -> String {
let keep = msg.split(" · ").filter(|p| !p.is_empty() && *p != last && *p != STALE);
keep.chain([note]).collect::<Vec<_>>().join(" · ")
}
impl App {
pub(super) fn load_threads(&mut self) {
self.load_github();
self.load_claude();
}
pub(super) fn load_github(&mut self) {
let Some(n) = self.pr.as_ref().map(|p| p.number) else { return };
self.github_gen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.github_stale = false;
self.apply_github(fetch_github(n, cache_path(n).as_deref()), true);
}
pub(super) fn show_cached(&mut self, n: u64) -> bool {
let Some(f) = cache_path(n).as_deref().and_then(cached_github) else { return false };
let head = self.pr.as_ref().map(|p| p.head_oid.clone()); self.github_gen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.apply_github(f, false);
if let (Some(pr), Some(h)) = (&mut self.pr, head) {
pr.head_oid = h;
}
self.github_stale = true;
self.msg = STALE.into();
true
}
pub(super) fn ensure_fresh(&mut self) {
if self.github_stale {
let keep = self.cursor_key();
self.load_threads(); self.rebuild(keep);
}
}
pub(super) fn start_github_watch(&mut self, n: u64, fetch_now: bool) {
let (tx, rx) = std::sync::mpsc::channel();
let loads = self.github_gen.clone();
let (poke, poked) = std::sync::mpsc::channel();
self.github_poke = Some(poke);
let cache = cache_path(n);
std::thread::spawn(move || {
let mut etag = gh::pr_etag(n, "").ok().flatten().unwrap_or_default();
let at = loads.load(std::sync::atomic::Ordering::SeqCst);
if fetch_now && tx.send((at, fetch_github(n, cache.as_deref()))).is_err() {
return;
}
loop {
match poked.recv_timeout(GITHUB_POLL) {
Ok(()) => {
if let Ok(Some(e)) = gh::pr_etag(n, &etag) {
etag = e;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let Ok(Some(e)) = gh::pr_etag(n, &etag) else { continue };
etag = e;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, }
let at = loads.load(std::sync::atomic::Ordering::SeqCst);
if tx.send((at, fetch_github(n, cache.as_deref()))).is_err() {
return; }
}
});
self.github = Some(rx);
}
pub(super) fn github_changed(&mut self) {
self.github_gen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
pub(super) fn watch_github(&mut self) {
let busy = self.action.is_some() || self.job.is_some() || self.picker.is_some() || self.prompt.is_some();
if busy || self.pending.is_some() || self.select || !self.labels.is_empty() {
return;
}
let Some((at, f)) = self.github.as_ref().and_then(|rx| rx.try_iter().last()) else { return };
if at != self.github_gen.load(std::sync::atomic::Ordering::SeqCst) {
return; }
let count = |a: &App| a.threads.iter().filter(|t| t.claude.is_none()).map(|t| t.comments.len()).sum::<usize>();
let before = count(self);
let had_new = self.new_paths();
let keep = self.cursor_key();
self.apply_github(f, true);
self.shape();
let new = count(self).saturating_sub(before);
self.unfold_new(&had_new);
self.rebuild(keep);
if std::mem::take(&mut self.github_stale) && self.msg == STALE {
self.msg.clear();
}
if new > 0 && self.msg.is_empty() {
self.msg = format!("{new} new comment(s) on GitHub");
}
}
pub(super) fn apply_github(&mut self, (threads, info): Fetched, fresh: bool) {
match threads {
Ok(r) => {
if r.pending.is_none() || !r.pending_body.is_empty() {
self.summary_draft = r.pending_body;
}
let claude: Vec<Thread> = std::mem::take(&mut self.threads).into_iter().filter(|t| t.claude.is_some()).collect();
(self.threads, self.review) = (r.threads.into_iter().chain(claude).collect(), r.pending);
(self.theirs, self.me) = (!r.mine, r.me); self.apply_viewed(r.viewed); if let Some(pr) = &mut self.pr {
pr.node_id = r.pr_id;
}
}
Err(e) => self.msg = format!("loading comments: {e:#}"),
}
match info {
Ok(Info { lines, title, head, url, state, unsigned, awaiting, failing, base, conflicts }) => {
(self.awaiting, self.failing, self.conflicts) = (awaiting, failing, conflicts);
(self.info, self.pr_state) = (lines, Some(state));
if let Some(unsigned) = unsigned {
for c in &mut self.commits {
c.signed = !unsigned.contains(&c.sha);
}
self.unsigned = unsigned;
}
if let Some(pr) = &mut self.pr {
(pr.title, pr.head_oid, pr.url) = (title, head, url);
if fresh && !base.is_empty() {
self.github_base = Some(base);
}
}
self.refresh_sync(); }
Err(e) => self.msg = format!("loading PR info: {e:#}"),
}
self.follow_github_base(true);
}
pub(super) fn follow_github_base(&mut self, say: bool) {
let Some(base) = self.github_base.clone().filter(|b| self.pr.as_ref().is_some_and(|p| p.base != *b)) else { return };
if self.job.is_some() {
return;
}
let follows = self.pr.as_ref().is_some_and(|p| self.follow_base.as_deref() == Some(crate::diff::remote_branch(&p.base).as_str()));
if follows && super::rebase::rebasing(&self.root) {
if say {
self.base_note(&format!("the PR's base is {base} now: the diff follows once the rebase is through"));
}
return;
}
self.base_note(&format!("the PR's base is {base} now: fetching it…"));
self.job = Some(Job::Retarget(base));
}
pub(super) fn base_note(&mut self, note: &str) {
let last = std::mem::replace(&mut self.base_said, note.to_owned());
self.msg = base_note_in(&self.msg, &last, note);
}
pub(super) fn retarget(&mut self, base: String) {
let Some(old) = self.pr.as_ref().map(|p| p.base.clone()) else { return };
let follows = self.follow_base.as_deref() == Some(crate::diff::remote_branch(&old).as_str());
let onto = crate::diff::remote_branch(&base);
if follows {
let fetched = diff::git_in(&self.root, &["fetch", "-q", &crate::diff::remote(), &format!("+refs/heads/{base}:refs/remotes/{onto}")]);
let Ok(mb) = fetched.and_then(|_| diff::git_in(&self.root, &["merge-base", &onto, "HEAD"])) else {
self.github_base = None; let said = format!("the PR's base is {base} now, but {onto} couldn't be fetched: the diff still starts from {old} (tried again on the next refresh)");
return self.base_note(&said);
};
let mb = mb.trim().to_owned();
if !self.uncommitted() {
self.base = mb.clone();
}
(self.full_base, self.follow_base) = (mb, Some(onto));
(self.to, self.commit_shown, self.commit_anchor) = (None, None, None);
}
if let Some(pr) = &mut self.pr {
pr.base = base.clone();
}
self.reload_tree();
let said = match follows {
true => format!("the PR's base is {base} now: the diff starts from there"),
false => format!("the PR's base is {base} now (the diff keeps the base you gave)"),
};
self.base_note(&said);
}
}
#[test]
pub(super) fn notes_on_the_base() {
let a = base_note_in(&format!("rebased · {STALE}"), "", "the base is x now: fetching it…");
assert_eq!(a, "rebased · the base is x now: fetching it…");
assert_eq!(base_note_in(&a, "the base is x now: fetching it…", "the base is x now"), "rebased · the base is x now");
}