Skip to main content

gwm/tui/state/
github_fetch.rs

1//! GitHub fetch state for the TUI (issue #128, part 6/6 of the
2//! `tui::app::App` decomposition #102; threading migrated onto the
3//! shared async-task spine in #255).
4//!
5//! Owns the slice of `App` state that tracks issue / PR linking + the
6//! cached results of the `gh issue view` / `gh pr view` shell-outs:
7//!
8//! - `link` — the [`BranchLink`] resolved for the currently-selected
9//!   worktree's branch (the `(issue, pr)` tuple plus their provenance
10//!   `LinkSource` markers).
11//! - `link_slug` — the `owner/repo` slug parsed from the `origin`
12//!   remote, `None` when there is no GitHub remote.
13//! - `issue_cache` / `pr_cache` — per-(target, number) caches keyed
14//!   by issue / PR number. Each entry is a [`GitHubFetchState`]: cold
15//!   entries are simply absent from the map (treated as `Idle` by
16//!   the accessors), `Loading` while a shell-out is in flight,
17//!   `Loaded(T)` on success, `Error(msg)` on failure. Per-key identity
18//!   matters: pre-#138 the cache was a single per-target slot, so
19//!   completing `Issue(42)` falsely "warmed" `Issue(43)` (the cache
20//!   identity ignored the number).
21//!
22//! **What this module is, post-#255:** a *result cache* + link state.
23//! It deliberately no longer owns the off-thread coalescing / dedupe /
24//! late-result-drop — that machinery is now the generic
25//! [`super::async_task::TaskRunner`] spine, shared with the worktree
26//! refresh, so there is one off-thread mechanism instead of two. Pre-
27//! #255 this module also held an `inflight: HashSet<FetchKey>` that
28//! deduped concurrent fetches and gated the #138 late-drop, but it had
29//! **no per-fetch generation**: two workers for the same key (the
30//! request → invalidate → request retry path) were indistinguishable,
31//! so a stale worker that reported first could win the slot and a fresh
32//! result be dropped (Codex adversarial-review finding on PR #260). The
33//! spine's per-key generation counter fixes that race.
34//!
35//! The orchestrator pattern (post-#255): `App` checks
36//! [`GitHubFetch::is_cached`] for a terminal hit; on a miss it claims a
37//! generation from the spine ([`TaskRunner::request`]), marks the cache
38//! [`GitHubFetchState::Loading`] via [`GitHubFetch::mark_loading`], and
39//! spawns the `gh` worker tagged with that generation. The worker posts
40//! a `TaskMsg::Github{Issue,Pr}` back; the event loop applies it only
41//! when [`TaskRunner::complete`] confirms the generation is still
42//! authoritative, then stamps the terminal result here via
43//! [`GitHubFetch::complete_issue`] / [`GitHubFetch::complete_pr`] (pure
44//! cache writes — the drop decision lives on the spine now).
45//!
46//! The explicit user-initiated refresh (`F` key →
47//! `App::refresh_github_status`) flushes the cache via
48//! [`GitHubFetch::invalidate`] before re-requesting — the user just
49//! asked for fresh data, so a `HitCache` short-circuit there would be a
50//! bug — and the `App` pairs that flush with a spine
51//! `invalidate_matching(is_github)` so any in-flight worker's late
52//! result is dropped (the navigation invariant: cache clear and spine
53//! generation-bump always move together).
54
55use crate::github::{self, BranchLink, IssueStatus, PrStatus};
56use git2::Repository;
57use std::collections::HashMap;
58
59/// State of a background GitHub fetch (issue or PR). Generic over `T`
60/// so the same enum drives both `IssueStatus` and `PrStatus`. The
61/// `Idle` variant is the cold-cache identity; `Loading` flags an
62/// inflight `gh` shell-out so the UI can paint a "…loading" badge;
63/// `Loaded(T)` and `Error(String)` are the two terminal outcomes.
64///
65/// Moved out of `tui::app` per #128 — this module owns the type now
66/// because it owns the state machine that drives transitions between
67/// the variants. Re-exported from `tui::mod` (and from `tui::app` for
68/// callers that already imported it from its historical path) so the
69/// public surface stays at the same path.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum GitHubFetchState<T> {
72  Idle,
73  Loading,
74  Loaded(T),
75  Error(String),
76}
77
78/// Static `Idle` constant for `IssueStatus` so the keyed accessor can
79/// hand back a reference for absent keys without allocating per call.
80/// Lives at module scope so it has `'static` lifetime — required for
81/// the borrow returned by `issue_fetch_state(number)` when the map
82/// has no entry.
83const IDLE_ISSUE: GitHubFetchState<IssueStatus> = GitHubFetchState::Idle;
84
85/// PR-side counterpart to [`IDLE_ISSUE`].
86const IDLE_PR: GitHubFetchState<PrStatus> = GitHubFetchState::Idle;
87
88/// Identity of a GitHub fetch. The `(target, number)` tuple is the
89/// dedupe key: `Issue(42)` and `Pr(42)` never collide (they hit
90/// different `gh` subcommands), and `Issue(42)` vs `Issue(43)` are
91/// independent fetches against different REST endpoints.
92///
93/// Carried through [`FetchAction::Spawn`] so the orchestrator knows
94/// which side to dispatch without re-encoding the discriminant.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum FetchKey {
97  Issue(u64),
98  Pr(u64),
99}
100
101/// GitHub fetch state slice of the TUI `App` (issue #128; threading on
102/// the async-task spine since #255).
103///
104/// See the module docs for the full contract; the short version is:
105/// `App` checks [`Self::is_cached`], marks [`Self::mark_loading`] before
106/// spawning the `gh` worker on the [`super::async_task::TaskRunner`]
107/// spine, and stamps the terminal result back via
108/// [`Self::complete_issue`] / [`Self::complete_pr`] once the spine
109/// confirms the worker's generation is still authoritative.
110pub struct GitHubFetch {
111  pub link: BranchLink,
112  pub link_slug: Option<String>,
113  /// Per-issue-number cache. Absent keys are `Idle`. Closed over by
114  /// the keyed accessor `issue_fetch_state(number)` (#138 fix: the
115  /// cache is keyed by number, not a single per-target slot).
116  issue_cache: HashMap<u64, GitHubFetchState<IssueStatus>>,
117  /// PR-side counterpart to [`Self::issue_cache`].
118  pr_cache: HashMap<u64, GitHubFetchState<PrStatus>>,
119}
120
121impl Default for GitHubFetch {
122  fn default() -> Self {
123    Self::new()
124  }
125}
126
127impl GitHubFetch {
128  /// Construct an empty `GitHubFetch` with no link, no slug, and empty
129  /// per-key caches. The `App` constructor calls this once and then
130  /// immediately runs [`Self::refresh_link`] against the repo so the
131  /// cold state lasts only as long as the constructor itself.
132  pub fn new() -> Self {
133    Self {
134      link: BranchLink::empty(),
135      link_slug: None,
136      issue_cache: HashMap::new(),
137      pr_cache: HashMap::new(),
138    }
139  }
140
141  /// Re-read the link for `branch` against `repo`, re-resolve the
142  /// repo slug from the `origin` remote, and reset every cached
143  /// fetch state. Called by `App::refresh_link` after the user
144  /// navigates to a different worktree — the cached state refers to a
145  /// different `(issue, pr)` tuple and would be misleading if reused.
146  /// (`App::refresh_link` separately drops any in-flight GitHub worker
147  /// on the spine — see [`Self::invalidate`] for the pairing.)
148  pub fn refresh_link(&mut self, repo: &Repository, branch: Option<&str>) {
149    self.link = branch
150      .and_then(|b| github::read_link(repo, b).ok())
151      .unwrap_or_else(BranchLink::empty);
152    self.link_slug = github::repo_slug(repo).ok();
153    self.invalidate();
154  }
155
156  /// Flush every cached fetch state. Equivalent to "the cached
157  /// `(issue, pr)` tuples are no longer authoritative". Called by
158  /// [`Self::refresh_link`]; exposed standalone for callers (e.g. an
159  /// explicit "force refresh" key like `F`) that want to wipe the cache
160  /// without re-reading the link.
161  ///
162  /// Post-#255 this clears only the result cache. Dropping any *in-
163  /// flight* worker's late result is the spine's job: the `App` pairs
164  /// every `invalidate()` with a
165  /// [`TaskRunner::invalidate_matching(is_github)`](super::async_task::TaskRunner::invalidate_matching)
166  /// so the stale generation is bumped and its result discarded by
167  /// [`TaskRunner::complete`](super::async_task::TaskRunner::complete).
168  /// That pairing is the navigation invariant — cache clear and spine
169  /// generation-bump always move together.
170  pub fn invalidate(&mut self) {
171    self.issue_cache.clear();
172    self.pr_cache.clear();
173  }
174
175  /// Stamp an auto-detected PR onto the resolved `link` when none is
176  /// already linked (issue #181). Pure: the `App` orchestrator owns the
177  /// `gh pr list --head <branch>` shell-out and feeds the detected number
178  /// here so the sidebar's `pr_fetch_state()` can resolve it. Delegates
179  /// to [`github::apply_detected_pr`], so an explicit `gwm link --pr`
180  /// (already on `link.pr`) always wins and the result is marked
181  /// `LinkSource::Detected`. This only mutates in-memory state; the `App`
182  /// separately persists the detection via
183  /// [`github::persist_detected_pr`] (issue #283) so the table read path
184  /// picks it up.
185  pub fn apply_detected_pr(&mut self, detected: Option<u64>) {
186    github::apply_detected_pr(&mut self.link, detected);
187  }
188
189  /// Drop a previously auto-detected PR so the next refresh re-resolves
190  /// it from GitHub (issue #181 — the detected link is "resolved live",
191  /// so a PR that was opened/closed/replaced while sitting on the same
192  /// worktree must not stick across `F` presses). A no-op for an
193  /// explicit (`gwm link --pr`) or branch-name link — those stay pinned.
194  pub fn clear_detected_pr(&mut self) {
195    if self.link.pr_source == github::LinkSource::Detected {
196      self.link.pr = None;
197      self.link.pr_source = github::LinkSource::None;
198    }
199  }
200
201  /// Flip the per-key cache entry for `key` to
202  /// [`GitHubFetchState::Loading`] (issue #255). Called by the `App`
203  /// after it has claimed a generation from the spine and is about to
204  /// spawn the `gh` worker, so the sidebar paints a "…loading" badge and
205  /// [`App::is_github_loading`](crate::tui::App) reads `true` until the
206  /// terminal result lands. Pure: coalescing (skip the spawn if a worker
207  /// is already running) is the spine's call, not this module's.
208  pub fn mark_loading(&mut self, key: FetchKey) {
209    match key {
210      FetchKey::Issue(n) => {
211        self.issue_cache.insert(n, GitHubFetchState::Loading);
212      }
213      FetchKey::Pr(n) => {
214        self.pr_cache.insert(n, GitHubFetchState::Loading);
215      }
216    }
217  }
218
219  /// `true` when the per-key cache already carries a terminal `Loaded`
220  /// or `Error` for `key` (issue #255). The `App` consults this before
221  /// claiming a spine generation so an explicit-but-already-warm key
222  /// skips a redundant `gh` spawn. The `(target, number)` identity is
223  /// the cache key: `Issue(42)` / `Pr(42)` / `Issue(43)` are all
224  /// independent (post-#138).
225  pub fn is_cached(&self, key: FetchKey) -> bool {
226    self.has_terminal(key)
227  }
228
229  /// Stamp the terminal outcome of an issue fetch into the per-key cache
230  /// (issue #255). Pure cache write — `Ok` → `Loaded`, `Err` → `Error`,
231  /// keyed by `number`. After this call,
232  /// [`Self::is_cached(Issue(number))`](Self::is_cached) returns `true`.
233  ///
234  /// Post-#255 there is no late-result guard here: the drop decision for
235  /// a superseded worker lives on the spine
236  /// ([`TaskRunner::complete`](super::async_task::TaskRunner::complete)),
237  /// which the `App` checks *before* calling this. So by the time we
238  /// stamp, the result is already known authoritative.
239  pub fn complete_issue(&mut self, number: u64, result: std::result::Result<IssueStatus, String>) {
240    self.issue_cache.insert(number, into_state(result));
241  }
242
243  /// PR-side counterpart to [`Self::complete_issue`] — pure cache write,
244  /// no late-result guard (the spine owns that since #255).
245  pub fn complete_pr(&mut self, number: u64, result: std::result::Result<PrStatus, String>) {
246    self.pr_cache.insert(number, into_state(result));
247  }
248
249  /// Stamp the issue fetch state from a fetch result. `Ok(s)` →
250  /// `Loaded(s)` (keyed by `s.number`), `Err(msg)` → `Error(msg)`
251  /// (keyed by the current `link.issue` if any). Test-friendly
252  /// wrapper used by `App::apply_issue_fetch_result`; the helper is for
253  /// tests that stamp state directly without going through the spine's
254  /// `request → complete` generation flow.
255  ///
256  /// If `Err` is given and no link issue is set, the helper is a
257  /// no-op (there's no number to key by). Tests that exercise the
258  /// error path should set up a branch link first via
259  /// `make_app_on_branch("feat/#<n>-…")`.
260  pub fn apply_issue_result(&mut self, r: std::result::Result<IssueStatus, String>) {
261    let (number, state) = match r {
262      Ok(s) => (s.number, GitHubFetchState::Loaded(s)),
263      Err(e) => {
264        let Some(n) = self.link.issue else {
265          return;
266        };
267        (n, GitHubFetchState::Error(e))
268      }
269    };
270    self.issue_cache.insert(number, state);
271  }
272
273  /// PR-side counterpart to [`Self::apply_issue_result`]. Same
274  /// no-op-on-Err-without-link contract.
275  pub fn apply_pr_result(&mut self, r: std::result::Result<PrStatus, String>) {
276    let (number, state) = match r {
277      Ok(s) => (s.number, GitHubFetchState::Loaded(s)),
278      Err(e) => {
279        let Some(n) = self.link.pr else {
280          return;
281        };
282        (n, GitHubFetchState::Error(e))
283      }
284    };
285    self.pr_cache.insert(number, state);
286  }
287
288  /// Read the cached fetch state for `Issue(number)`. Returns
289  /// `&GitHubFetchState::Idle` for absent keys via a `'static`
290  /// constant so the borrow is cheap and lifetime-free. Used by the
291  /// renderer (`src/tui/ui.rs`) and the `App`-level wrapper
292  /// `App::issue_fetch_state` to read the cache without leaking the
293  /// per-key map shape.
294  pub fn issue_fetch_state(&self, number: u64) -> &GitHubFetchState<IssueStatus> {
295    self.issue_cache.get(&number).unwrap_or(&IDLE_ISSUE)
296  }
297
298  /// PR-side counterpart to [`Self::issue_fetch_state`].
299  pub fn pr_fetch_state(&self, number: u64) -> &GitHubFetchState<PrStatus> {
300    self.pr_cache.get(&number).unwrap_or(&IDLE_PR)
301  }
302
303  /// `true` when the per-key cache carries a terminal variant
304  /// (`Loaded` or `Error`) for `key`. Backs the public
305  /// [`Self::is_cached`]. Post-#138 the cache is keyed by number, so
306  /// `has_terminal(Issue(43))` after a `complete_issue(42, …)` correctly
307  /// returns `false`.
308  fn has_terminal(&self, key: FetchKey) -> bool {
309    match key {
310      FetchKey::Issue(n) => matches!(
311        self.issue_cache.get(&n),
312        Some(GitHubFetchState::Loaded(_)) | Some(GitHubFetchState::Error(_))
313      ),
314      FetchKey::Pr(n) => matches!(
315        self.pr_cache.get(&n),
316        Some(GitHubFetchState::Loaded(_)) | Some(GitHubFetchState::Error(_))
317      ),
318    }
319  }
320}
321
322/// Translate a fetch `Result` into the corresponding terminal
323/// [`GitHubFetchState`] variant. Pulled out as a free function so
324/// both `complete_issue` and `complete_pr` can call it without
325/// having to repeat the `match` — same body, two type parameters.
326fn into_state<T>(r: std::result::Result<T, String>) -> GitHubFetchState<T> {
327  match r {
328    Ok(s) => GitHubFetchState::Loaded(s),
329    Err(e) => GitHubFetchState::Error(e),
330  }
331}