Skip to main content

forgetop_server/
cached.rs

1//! The lists the dashboard shares with the terminal UI through the on-disk cache
2//! (`forgetop_core::cache::CacheStore`, one `cache.json` per install).
3//!
4//! Both frontends read and write the same entries under the same keys, so whichever ran last
5//! leaves the next one a warm start: a list request answers from here at once, and the live
6//! fetch that follows it writes back. Last writer wins — the file is rewritten atomically and
7//! every entry carries when it was fetched, so the worst a race can do is leave slightly older
8//! rows for the next cold start to paint.
9//!
10//! The entries hold the TUI's row shapes (its `PrRow` has `pr`, the dashboard's has
11//! `pull_request`; its `PipeRow` records gates by name, the dashboard's carries approvals), so
12//! this module is the one place that translates. The keys and shapes mirror
13//! `crates/forgetop-tui/src/app.rs`; change them together.
14
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::Arc;
18use std::time::Duration;
19
20use chrono::{DateTime, Utc};
21use forgetop_core::cache::CacheStore;
22use forgetop_core::domain::{PipelineApproval, PipelineRun, ProviderType, PullRequest, WorkItem};
23use forgetop_core::filter::pull_request_matches;
24use forgetop_core::launchpad::{PipeInput, PrInput, WiInput};
25use forgetop_core::provider::PullRequestFilter;
26use serde::{Deserialize, Serialize};
27
28use crate::dto::{self, NotifRow, PipeRow, PrRow, PrView, WiRow};
29
30pub const KEY_WORK_ITEMS: &str = "list.work_items";
31pub const KEY_PIPELINES: &str = "list.pipelines";
32pub const KEY_INBOX: &str = "list.inbox";
33pub const KEY_PR_POOL: &str = "list.prs.pool";
34pub const KEY_LAUNCHPAD_MINE: &str = "list.launchpad.mine";
35pub const KEY_LAUNCHPAD_REVIEW: &str = "list.launchpad.review";
36
37/// The TUI's key for one PR view's last rows (`list.prs.Mine.false`).
38pub fn prs_key(filter: PullRequestFilter, completed: bool) -> String {
39    format!("list.prs.{filter:?}.{completed}")
40}
41
42/// How many rows a PR view derived from the pool keeps — the TUI's `PR_VIEW_CAP`, which is the
43/// `limit` every per-view fetch passes.
44const PR_VIEW_CAP: usize = 50;
45
46/// A pull request as the TUI caches it.
47#[derive(Serialize, Deserialize)]
48pub struct TuiPrRow {
49    pub connection_id: String,
50    pub connection: String,
51    pub provider: ProviderType,
52    pub pr: PullRequest,
53}
54
55/// A work item as the TUI caches it.
56#[derive(Serialize, Deserialize)]
57pub struct TuiWiRow {
58    pub connection_id: String,
59    pub connection: String,
60    pub provider: ProviderType,
61    pub wi: WorkItem,
62}
63
64/// A pipeline run as the TUI caches it: the run exactly as the provider reported it (the
65/// Waiting status is derived at display time from `gates`), the gates holding it by name, and
66/// whether one of them is this user's to answer.
67#[derive(Serialize, Deserialize)]
68pub struct TuiPipeRow {
69    pub connection_id: String,
70    pub connection: String,
71    pub provider: ProviderType,
72    pub run: PipelineRun,
73    pub definition_name: Option<String>,
74    pub awaiting_approval: bool,
75    #[serde(default)]
76    pub triggered_by_me: bool,
77    #[serde(default)]
78    pub gates: Vec<String>,
79}
80
81/// The TUI's unfiltered pull-request pool, from which it derives every PR view. Read only: the
82/// dashboard never has the whole account's worth of rows to write it, so it writes the per-view
83/// keys instead. Unknown fields are ignored, defaults cover a pool written by an older build.
84#[derive(Deserialize)]
85struct TuiPrPool {
86    #[serde(default)]
87    open: Vec<TuiPrRow>,
88    #[serde(default)]
89    completed: Vec<TuiPrRow>,
90    /// connection_id → the signed-in user's handle there; absent means "could not tell", and
91    /// `pull_request_matches` then passes every row through rather than hiding them.
92    #[serde(default)]
93    me: HashMap<String, Option<String>>,
94    #[serde(default)]
95    needs_decoration: HashMap<String, bool>,
96}
97
98/// A value read from the cache, with when it was fetched so the response can say so.
99pub struct Cached<T> {
100    pub value: T,
101    pub fetched_at: DateTime<Utc>,
102}
103
104// ---- reading ----
105
106fn pr_row(r: TuiPrRow, needs_decoration: &HashMap<String, bool>) -> PrRow {
107    // The pool records which connections list without decoration; a per-view entry does not,
108    // and GitHub is the one provider whose list endpoint omits those fields.
109    let needs = needs_decoration
110        .get(&r.connection_id)
111        .copied()
112        .unwrap_or(matches!(r.provider, ProviderType::GitHub));
113    PrRow { connection_id: r.connection_id, connection: r.connection, provider: r.provider, pull_request: r.pr, needs_decoration: needs }
114}
115
116fn pr_input(r: TuiPrRow) -> PrInput {
117    PrInput { connection_id: r.connection_id, connection: r.connection, provider: r.provider, pr: r.pr }
118}
119
120/// The TUI's own derivation of a view from the pool: the completed or open rows, the filter
121/// applied with that connection's identity, capped like a fetched view.
122fn derive_from_pool(pool: TuiPrPool, filter: PullRequestFilter, completed: bool) -> (Vec<TuiPrRow>, HashMap<String, bool>) {
123    let TuiPrPool { open, completed: done, me, needs_decoration } = pool;
124    let rows = if completed { done } else { open };
125    let rows = rows
126        .into_iter()
127        .filter(|r| pull_request_matches(&r.pr, filter, me.get(&r.connection_id).and_then(|m| m.as_deref())))
128        .take(PR_VIEW_CAP)
129        .collect();
130    (rows, needs_decoration)
131}
132
133/// One PR view's rows, as last known. The view's own entry wins (it is what the TUI paints at
134/// launch); otherwise the view is derived from the pool.
135pub fn pull_requests(cache: &CacheStore, view: PrView) -> Option<Cached<Vec<PrRow>>> {
136    let (filter, completed) = view.parts();
137    if let Some(entry) = cache.get::<Vec<TuiPrRow>>(&prs_key(filter, completed)) {
138        let none = HashMap::new();
139        let value = entry.value.into_iter().filter(|r| view.keep(&r.pr)).map(|r| pr_row(r, &none)).collect();
140        return Some(Cached { value, fetched_at: entry.fetched_at });
141    }
142    let entry = cache.get::<TuiPrPool>(KEY_PR_POOL)?;
143    let (rows, needs_decoration) = derive_from_pool(entry.value, filter, completed);
144    let value = rows.into_iter().filter(|r| view.keep(&r.pr)).map(|r| pr_row(r, &needs_decoration)).collect();
145    Some(Cached { value, fetched_at: entry.fetched_at })
146}
147
148pub fn work_items(cache: &CacheStore) -> Option<Cached<Vec<WiRow>>> {
149    let entry = cache.get::<Vec<TuiWiRow>>(KEY_WORK_ITEMS)?;
150    let value = entry
151        .value
152        .into_iter()
153        .map(|r| WiRow { connection_id: r.connection_id, connection: r.connection, provider: r.provider, work_item: r.wi })
154        .collect();
155    Some(Cached { value, fetched_at: entry.fetched_at })
156}
157
158/// A cached run as the dashboard lists it: the status the gates imply, and each gate as an
159/// approval the user can answer only if the TUI recorded that one of them was theirs.
160fn pipe_row(r: TuiPipeRow) -> PipeRow {
161    let TuiPipeRow { connection_id, connection, provider, mut run, definition_name, awaiting_approval, gates, .. } = r;
162    run.status = run.shown_status(!gates.is_empty());
163    let approvals = gates
164        .into_iter()
165        .map(|name| PipelineApproval { id: name.clone(), name, can_respond: awaiting_approval, blocks_run: true })
166        .collect();
167    PipeRow { connection_id, connection, provider, run, definition_name, approvals }
168}
169
170/// A cached run as a Command Center input — the same derivation the live `pipe_inputs` makes.
171pub fn pipe_input(r: TuiPipeRow) -> PipeInput {
172    let TuiPipeRow { connection_id, connection, provider, mut run, definition_name, awaiting_approval, triggered_by_me, gates } = r;
173    run.status = run.shown_status(!gates.is_empty());
174    PipeInput { connection_id, connection, provider, run, definition_name, awaiting_approval, triggered_by_me }
175}
176
177pub fn pipelines(cache: &CacheStore) -> Option<Cached<Vec<PipeRow>>> {
178    let entry = cache.get::<Vec<TuiPipeRow>>(KEY_PIPELINES)?;
179    Some(Cached { value: entry.value.into_iter().map(pipe_row).collect(), fetched_at: entry.fetched_at })
180}
181
182pub fn notifications(cache: &CacheStore) -> Option<Cached<Vec<NotifRow>>> {
183    // Same shape on both sides.
184    let entry = cache.get::<Vec<NotifRow>>(KEY_INBOX)?;
185    Some(Cached { value: entry.value, fetched_at: entry.fetched_at })
186}
187
188/// The Command Center from the cached lists. Needs both PR slices (the TUI's launchpad keys, or
189/// the pool to derive them from); work items and pipelines fall back to empty, and the response
190/// is dated by its oldest part so the page knows how old what it shows is.
191pub fn launchpad(cache: &CacheStore) -> Option<Cached<dto::LaunchpadResponse>> {
192    let slice = |key: &str, filter: PullRequestFilter, completed: bool| -> Option<(Vec<PrInput>, DateTime<Utc>)> {
193        if let Some(entry) = cache.get::<Vec<TuiPrRow>>(key) {
194            return Some((entry.value.into_iter().map(pr_input).collect(), entry.fetched_at));
195        }
196        let entry = cache.get::<TuiPrPool>(KEY_PR_POOL)?;
197        let (rows, _) = derive_from_pool(entry.value, filter, completed);
198        Some((rows.into_iter().map(pr_input).collect(), entry.fetched_at))
199    };
200    let (review, review_at) = slice(KEY_LAUNCHPAD_REVIEW, PullRequestFilter::ReviewRequested, false)?;
201    let (mine, mine_at) = slice(KEY_LAUNCHPAD_MINE, PullRequestFilter::Mine, true)?;
202    let mut fetched_at = review_at.min(mine_at);
203    let wis = match cache.get::<Vec<TuiWiRow>>(KEY_WORK_ITEMS) {
204        Some(entry) => {
205            fetched_at = fetched_at.min(entry.fetched_at);
206            entry
207                .value
208                .into_iter()
209                .map(|r| WiInput { connection_id: r.connection_id, connection: r.connection, provider: r.provider, wi: r.wi })
210                .collect()
211        }
212        None => Vec::new(),
213    };
214    let pipes = match cache.get::<Vec<TuiPipeRow>>(KEY_PIPELINES) {
215        Some(entry) => {
216            fetched_at = fetched_at.min(entry.fetched_at);
217            entry.value.into_iter().map(pipe_input).collect()
218        }
219        None => Vec::new(),
220    };
221    Some(Cached { value: dto::build_launchpad(&review, &mine, &wis, &pipes), fetched_at })
222}
223
224// ---- writing ----
225
226pub fn store_pull_requests(cache: &CacheStore, view: PrView, rows: &[PrRow], at: DateTime<Utc>) {
227    let (filter, completed) = view.parts();
228    let tui: Vec<TuiPrRow> = rows
229        .iter()
230        .map(|r| TuiPrRow { connection_id: r.connection_id.clone(), connection: r.connection.clone(), provider: r.provider, pr: r.pull_request.clone() })
231        .collect();
232    cache.put(&prs_key(filter, completed), &tui, at);
233}
234
235pub fn store_launchpad_prs(cache: &CacheStore, key: &str, inputs: &[PrInput], at: DateTime<Utc>) {
236    let tui: Vec<TuiPrRow> = inputs
237        .iter()
238        .map(|i| TuiPrRow { connection_id: i.connection_id.clone(), connection: i.connection.clone(), provider: i.provider, pr: i.pr.clone() })
239        .collect();
240    cache.put(key, &tui, at);
241}
242
243pub fn store_work_items(cache: &CacheStore, inputs: &[WiInput], at: DateTime<Utc>) {
244    let tui: Vec<TuiWiRow> = inputs
245        .iter()
246        .map(|i| TuiWiRow { connection_id: i.connection_id.clone(), connection: i.connection.clone(), provider: i.provider, wi: i.wi.clone() })
247        .collect();
248    cache.put(KEY_WORK_ITEMS, &tui, at);
249}
250
251pub fn store_pipelines(cache: &CacheStore, rows: &[TuiPipeRow], at: DateTime<Utc>) {
252    cache.put(KEY_PIPELINES, &rows, at);
253}
254
255pub fn store_notifications(cache: &CacheStore, rows: &[NotifRow], at: DateTime<Utc>) {
256    cache.put(KEY_INBOX, &rows, at);
257}
258
259// ---- flushing ----
260
261/// Writes the cache to disk shortly after something changed it, once per burst: every list the
262/// dashboard polls lands within a second or two of the others, and one flush covers them all.
263pub struct Flusher {
264    cache: Arc<CacheStore>,
265    pending: AtomicBool,
266}
267
268impl Flusher {
269    pub fn new(cache: Arc<CacheStore>) -> Self {
270        Self { cache, pending: AtomicBool::new(false) }
271    }
272
273    pub fn schedule(self: &Arc<Self>) {
274        if self.pending.swap(true, Ordering::AcqRel) {
275            return;
276        }
277        let me = Arc::clone(self);
278        tokio::spawn(async move {
279            tokio::time::sleep(Duration::from_secs(1)).await;
280            me.pending.store(false, Ordering::Release);
281            me.cache.flush().await;
282        });
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use serde_json::json;
290
291    fn user(name: &str) -> forgetop_core::domain::User {
292        forgetop_core::domain::User { id: name.into(), display_name: name.into(), handle: Some(name.into()), avatar_url: None }
293    }
294
295    fn pr(id: &str, author: &str, reviewer: Option<&str>) -> TuiPrRow {
296        let reviewers = reviewer
297            .map(|r| vec![forgetop_core::domain::Reviewer { user: user(r), vote: forgetop_core::domain::ReviewVote::NoVote, is_required: false }])
298            .unwrap_or_default();
299        TuiPrRow {
300            connection_id: "gh".into(),
301            connection: "GitHub".into(),
302            provider: ProviderType::GitHub,
303            pr: PullRequest {
304                id: id.into(),
305                repository: Some("acme/pay".into()),
306                number: None,
307                title: format!("PR {id}"),
308                description: None,
309                author: user(author),
310                status: forgetop_core::domain::PullRequestStatus::Open,
311                is_draft: false,
312                source_ref: Some("x".into()),
313                target_ref: Some("main".into()),
314                reviewers,
315                labels: Vec::new(),
316                checks: forgetop_core::domain::CheckStatus::Passed,
317                check_summary: None,
318                mergeable: forgetop_core::domain::MergeableState::Unknown,
319                changed_files: 0,
320                additions: 0,
321                deletions: 0,
322                created_at: None,
323                updated_at: None,
324                url: None,
325            },
326        }
327    }
328
329    #[tokio::test]
330    async fn views_derive_from_the_pool_with_the_tui_identity() {
331        // A disabled store never stores, so build one on a scratch path.
332        let path = std::env::temp_dir().join(format!("forgetop-cached-{}.json", uuid::Uuid::new_v4()));
333        let cache = CacheStore::new(&path);
334        let pool = json!({
335            "open": [pr("1", "sam", Some("priya")), pr("2", "priya", Some("sam")), pr("3", "tom", None)],
336            "completed": Vec::<TuiPrRow>::new(),
337            "me": { "gh": "sam" },
338            "needs_decoration": { "gh": true }
339        });
340        cache.put(KEY_PR_POOL, &pool, Utc::now());
341
342        let all = pull_requests(&cache, PrView::All).expect("pool answers All");
343        assert_eq!(all.value.len(), 3);
344        assert!(all.value.iter().all(|r| r.needs_decoration), "the pool says GitHub lists undecorated");
345        let yours = pull_requests(&cache, PrView::Yours).expect("pool answers Yours");
346        assert_eq!(yours.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["1"]);
347        let review = pull_requests(&cache, PrView::ReviewRequested).expect("pool answers Review requested");
348        assert_eq!(review.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["2"]);
349        assert!(pull_requests(&cache, PrView::MergedByYou).expect("pool answers Merged").value.is_empty());
350
351        // A view's own entry, as the TUI leaves one, wins over the pool.
352        cache.put(&prs_key(PullRequestFilter::All, false), &json!([pr("9", "sam", None)]), Utc::now() + chrono::Duration::seconds(1));
353        let all = pull_requests(&cache, PrView::All).expect("view entry");
354        assert_eq!(all.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["9"]);
355        let _ = std::fs::remove_file(path);
356    }
357}