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}