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/// A notification as the TUI caches it. The dashboard's row adds what the inbox computes from
82/// it (phrase, tier, dismissal key); those are derived on the way out, never stored.
83#[derive(Serialize, Deserialize)]
84pub struct TuiNotifRow {
85    pub connection_id: String,
86    pub connection: String,
87    pub provider: ProviderType,
88    pub notification: forgetop_core::domain::Notification,
89}
90
91/// The TUI's unfiltered pull-request pool, from which it derives every PR view. Read only: the
92/// dashboard never has the whole account's worth of rows to write it, so it writes the per-view
93/// keys instead. Unknown fields are ignored, defaults cover a pool written by an older build.
94#[derive(Deserialize)]
95struct TuiPrPool {
96    #[serde(default)]
97    open: Vec<TuiPrRow>,
98    #[serde(default)]
99    completed: Vec<TuiPrRow>,
100    /// connection_id → the signed-in user's handle there; absent means "could not tell", and
101    /// `pull_request_matches` then passes every row through rather than hiding them.
102    #[serde(default)]
103    me: HashMap<String, Option<String>>,
104    #[serde(default)]
105    needs_decoration: HashMap<String, bool>,
106}
107
108/// A value read from the cache, with when it was fetched so the response can say so.
109pub struct Cached<T> {
110    pub value: T,
111    pub fetched_at: DateTime<Utc>,
112}
113
114// ---- reading ----
115
116fn pr_row(r: TuiPrRow, needs_decoration: &HashMap<String, bool>) -> PrRow {
117    // The pool records which connections list without decoration; a per-view entry does not,
118    // and GitHub is the one provider whose list endpoint omits those fields.
119    let needs = needs_decoration
120        .get(&r.connection_id)
121        .copied()
122        .unwrap_or(matches!(r.provider, ProviderType::GitHub));
123    PrRow { connection_id: r.connection_id, connection: r.connection, provider: r.provider, pull_request: r.pr, needs_decoration: needs }
124}
125
126fn pr_input(r: TuiPrRow) -> PrInput {
127    PrInput { connection_id: r.connection_id, connection: r.connection, provider: r.provider, pr: r.pr }
128}
129
130/// The TUI's own derivation of a view from the pool: the completed or open rows, the filter
131/// applied with that connection's identity, capped like a fetched view.
132fn derive_from_pool(pool: TuiPrPool, filter: PullRequestFilter, completed: bool) -> (Vec<TuiPrRow>, HashMap<String, bool>) {
133    let TuiPrPool { open, completed: done, me, needs_decoration } = pool;
134    let rows = if completed { done } else { open };
135    let rows = rows
136        .into_iter()
137        .filter(|r| pull_request_matches(&r.pr, filter, me.get(&r.connection_id).and_then(|m| m.as_deref())))
138        .take(PR_VIEW_CAP)
139        .collect();
140    (rows, needs_decoration)
141}
142
143/// One PR view's rows, as last known. The view's own entry wins (it is what the TUI paints at
144/// launch); otherwise the view is derived from the pool.
145pub fn pull_requests(cache: &CacheStore, view: PrView) -> Option<Cached<Vec<PrRow>>> {
146    let (filter, completed) = view.parts();
147    if let Some(entry) = cache.get::<Vec<TuiPrRow>>(&prs_key(filter, completed)) {
148        let none = HashMap::new();
149        let value = entry.value.into_iter().filter(|r| view.keep(&r.pr)).map(|r| pr_row(r, &none)).collect();
150        return Some(Cached { value, fetched_at: entry.fetched_at });
151    }
152    let entry = cache.get::<TuiPrPool>(KEY_PR_POOL)?;
153    let (rows, needs_decoration) = derive_from_pool(entry.value, filter, completed);
154    let value = rows.into_iter().filter(|r| view.keep(&r.pr)).map(|r| pr_row(r, &needs_decoration)).collect();
155    Some(Cached { value, fetched_at: entry.fetched_at })
156}
157
158pub fn work_items(cache: &CacheStore) -> Option<Cached<Vec<WiRow>>> {
159    let entry = cache.get::<Vec<TuiWiRow>>(KEY_WORK_ITEMS)?;
160    let value = entry
161        .value
162        .into_iter()
163        .map(|r| WiRow { connection_id: r.connection_id, connection: r.connection, provider: r.provider, work_item: r.wi })
164        .collect();
165    Some(Cached { value, fetched_at: entry.fetched_at })
166}
167
168/// A cached run as the dashboard lists it: the status the gates imply, and each gate as an
169/// approval the user can answer only if the TUI recorded that one of them was theirs.
170fn pipe_row(r: TuiPipeRow) -> PipeRow {
171    let TuiPipeRow { connection_id, connection, provider, mut run, definition_name, awaiting_approval, gates, .. } = r;
172    run.status = run.shown_status(!gates.is_empty());
173    let approvals = gates
174        .into_iter()
175        .map(|name| PipelineApproval { id: name.clone(), name, can_respond: awaiting_approval, blocks_run: true })
176        .collect();
177    PipeRow { connection_id, connection, provider, run, definition_name, approvals }
178}
179
180/// A cached run as a Command Center input — the same derivation the live `pipe_inputs` makes.
181pub fn pipe_input(r: TuiPipeRow) -> PipeInput {
182    let TuiPipeRow { connection_id, connection, provider, mut run, definition_name, awaiting_approval, triggered_by_me, gates } = r;
183    run.status = run.shown_status(!gates.is_empty());
184    PipeInput { connection_id, connection, provider, run, definition_name, awaiting_approval, triggered_by_me }
185}
186
187pub fn pipelines(cache: &CacheStore) -> Option<Cached<Vec<PipeRow>>> {
188    let entry = cache.get::<Vec<TuiPipeRow>>(KEY_PIPELINES)?;
189    Some(Cached { value: entry.value.into_iter().map(pipe_row).collect(), fetched_at: entry.fetched_at })
190}
191
192/// Every cached notification as the dashboard lists it — dismissed ones included; the caller
193/// filters (see `lib.rs`), so "done" can still resolve a dismissed row's key.
194pub fn notifications(cache: &CacheStore) -> Option<Cached<Vec<NotifRow>>> {
195    let entry = cache.get::<Vec<TuiNotifRow>>(KEY_INBOX)?;
196    let value = entry
197        .value
198        .into_iter()
199        .map(|r| NotifRow::new(r.connection_id, r.connection, r.provider, r.notification))
200        .collect();
201    Some(Cached { value, fetched_at: entry.fetched_at })
202}
203
204/// The Command Center from the cached lists. Needs both PR slices (the TUI's launchpad keys, or
205/// the pool to derive them from); work items and pipelines fall back to empty, and the response
206/// is dated by its oldest part so the page knows how old what it shows is.
207pub fn launchpad(cache: &CacheStore, dismissed: &[String]) -> Option<Cached<dto::LaunchpadResponse>> {
208    let slice = |key: &str, filter: PullRequestFilter, completed: bool| -> Option<(Vec<PrInput>, DateTime<Utc>)> {
209        if let Some(entry) = cache.get::<Vec<TuiPrRow>>(key) {
210            return Some((entry.value.into_iter().map(pr_input).collect(), entry.fetched_at));
211        }
212        let entry = cache.get::<TuiPrPool>(KEY_PR_POOL)?;
213        let (rows, _) = derive_from_pool(entry.value, filter, completed);
214        Some((rows.into_iter().map(pr_input).collect(), entry.fetched_at))
215    };
216    let (review, review_at) = slice(KEY_LAUNCHPAD_REVIEW, PullRequestFilter::ReviewRequested, false)?;
217    let (mine, mine_at) = slice(KEY_LAUNCHPAD_MINE, PullRequestFilter::Mine, true)?;
218    let mut fetched_at = review_at.min(mine_at);
219    let wis = match cache.get::<Vec<TuiWiRow>>(KEY_WORK_ITEMS) {
220        Some(entry) => {
221            fetched_at = fetched_at.min(entry.fetched_at);
222            entry
223                .value
224                .into_iter()
225                .map(|r| WiInput { connection_id: r.connection_id, connection: r.connection, provider: r.provider, wi: r.wi })
226                .collect()
227        }
228        None => Vec::new(),
229    };
230    let pipes = match cache.get::<Vec<TuiPipeRow>>(KEY_PIPELINES) {
231        Some(entry) => {
232            fetched_at = fetched_at.min(entry.fetched_at);
233            entry.value.into_iter().map(pipe_input).collect()
234        }
235        None => Vec::new(),
236    };
237    Some(Cached { value: dto::build_launchpad(&review, &mine, &wis, &pipes, dismissed), fetched_at })
238}
239
240// ---- writing ----
241
242pub fn store_pull_requests(cache: &CacheStore, view: PrView, rows: &[PrRow], at: DateTime<Utc>) {
243    let (filter, completed) = view.parts();
244    let tui: Vec<TuiPrRow> = rows
245        .iter()
246        .map(|r| TuiPrRow { connection_id: r.connection_id.clone(), connection: r.connection.clone(), provider: r.provider, pr: r.pull_request.clone() })
247        .collect();
248    cache.put(&prs_key(filter, completed), &tui, at);
249}
250
251pub fn store_launchpad_prs(cache: &CacheStore, key: &str, inputs: &[PrInput], at: DateTime<Utc>) {
252    let tui: Vec<TuiPrRow> = inputs
253        .iter()
254        .map(|i| TuiPrRow { connection_id: i.connection_id.clone(), connection: i.connection.clone(), provider: i.provider, pr: i.pr.clone() })
255        .collect();
256    cache.put(key, &tui, at);
257}
258
259pub fn store_work_items(cache: &CacheStore, inputs: &[WiInput], at: DateTime<Utc>) {
260    let tui: Vec<TuiWiRow> = inputs
261        .iter()
262        .map(|i| TuiWiRow { connection_id: i.connection_id.clone(), connection: i.connection.clone(), provider: i.provider, wi: i.wi.clone() })
263        .collect();
264    cache.put(KEY_WORK_ITEMS, &tui, at);
265}
266
267pub fn store_pipelines(cache: &CacheStore, rows: &[TuiPipeRow], at: DateTime<Utc>) {
268    cache.put(KEY_PIPELINES, &rows, at);
269}
270
271pub fn store_notifications(cache: &CacheStore, rows: &[NotifRow], at: DateTime<Utc>) {
272    let tui: Vec<TuiNotifRow> = rows
273        .iter()
274        .map(|r| TuiNotifRow {
275            connection_id: r.connection_id.clone(),
276            connection: r.connection.clone(),
277            provider: r.provider,
278            notification: r.notification.clone(),
279        })
280        .collect();
281    cache.put(KEY_INBOX, &tui, at);
282}
283
284// ---- flushing ----
285
286/// Writes the cache to disk shortly after something changed it, once per burst: every list the
287/// dashboard polls lands within a second or two of the others, and one flush covers them all.
288pub struct Flusher {
289    cache: Arc<CacheStore>,
290    pending: AtomicBool,
291}
292
293impl Flusher {
294    pub fn new(cache: Arc<CacheStore>) -> Self {
295        Self { cache, pending: AtomicBool::new(false) }
296    }
297
298    pub fn schedule(self: &Arc<Self>) {
299        if self.pending.swap(true, Ordering::AcqRel) {
300            return;
301        }
302        let me = Arc::clone(self);
303        tokio::spawn(async move {
304            tokio::time::sleep(Duration::from_secs(1)).await;
305            me.pending.store(false, Ordering::Release);
306            me.cache.flush().await;
307        });
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use serde_json::json;
315
316    fn user(name: &str) -> forgetop_core::domain::User {
317        forgetop_core::domain::User { id: name.into(), display_name: name.into(), handle: Some(name.into()), avatar_url: None }
318    }
319
320    fn pr(id: &str, author: &str, reviewer: Option<&str>) -> TuiPrRow {
321        let reviewers = reviewer
322            .map(|r| vec![forgetop_core::domain::Reviewer { user: user(r), vote: forgetop_core::domain::ReviewVote::NoVote, is_required: false }])
323            .unwrap_or_default();
324        TuiPrRow {
325            connection_id: "gh".into(),
326            connection: "GitHub".into(),
327            provider: ProviderType::GitHub,
328            pr: PullRequest {
329                id: id.into(),
330                repository: Some("acme/pay".into()),
331                number: None,
332                title: format!("PR {id}"),
333                description: None,
334                author: user(author),
335                status: forgetop_core::domain::PullRequestStatus::Open,
336                is_draft: false,
337                source_ref: Some("x".into()),
338                target_ref: Some("main".into()),
339                reviewers,
340                labels: Vec::new(),
341                checks: forgetop_core::domain::CheckStatus::Passed,
342                check_summary: None,
343                mergeable: forgetop_core::domain::MergeableState::Unknown,
344                changed_files: 0,
345                additions: 0,
346                deletions: 0,
347                created_at: None,
348                updated_at: None,
349                url: None,
350            },
351        }
352    }
353
354    #[tokio::test]
355    async fn views_derive_from_the_pool_with_the_tui_identity() {
356        // A disabled store never stores, so build one on a scratch path.
357        let path = std::env::temp_dir().join(format!("forgetop-cached-{}.json", uuid::Uuid::new_v4()));
358        let cache = CacheStore::new(&path);
359        let pool = json!({
360            "open": [pr("1", "sam", Some("priya")), pr("2", "priya", Some("sam")), pr("3", "tom", None)],
361            "completed": Vec::<TuiPrRow>::new(),
362            "me": { "gh": "sam" },
363            "needs_decoration": { "gh": true }
364        });
365        cache.put(KEY_PR_POOL, &pool, Utc::now());
366
367        let all = pull_requests(&cache, PrView::All).expect("pool answers All");
368        assert_eq!(all.value.len(), 3);
369        assert!(all.value.iter().all(|r| r.needs_decoration), "the pool says GitHub lists undecorated");
370        let yours = pull_requests(&cache, PrView::Yours).expect("pool answers Yours");
371        assert_eq!(yours.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["1"]);
372        let review = pull_requests(&cache, PrView::ReviewRequested).expect("pool answers Review requested");
373        assert_eq!(review.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["2"]);
374        assert!(pull_requests(&cache, PrView::MergedByYou).expect("pool answers Merged").value.is_empty());
375
376        // A view's own entry, as the TUI leaves one, wins over the pool.
377        cache.put(&prs_key(PullRequestFilter::All, false), &json!([pr("9", "sam", None)]), Utc::now() + chrono::Duration::seconds(1));
378        let all = pull_requests(&cache, PrView::All).expect("view entry");
379        assert_eq!(all.value.iter().map(|r| r.pull_request.id.as_str()).collect::<Vec<_>>(), ["9"]);
380        let _ = std::fs::remove_file(path);
381    }
382}