1use 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
37pub fn prs_key(filter: PullRequestFilter, completed: bool) -> String {
39 format!("list.prs.{filter:?}.{completed}")
40}
41
42const PR_VIEW_CAP: usize = 50;
45
46#[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#[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#[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#[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#[derive(Deserialize)]
95struct TuiPrPool {
96 #[serde(default)]
97 open: Vec<TuiPrRow>,
98 #[serde(default)]
99 completed: Vec<TuiPrRow>,
100 #[serde(default)]
103 me: HashMap<String, Option<String>>,
104 #[serde(default)]
105 needs_decoration: HashMap<String, bool>,
106}
107
108pub struct Cached<T> {
110 pub value: T,
111 pub fetched_at: DateTime<Utc>,
112}
113
114fn pr_row(r: TuiPrRow, needs_decoration: &HashMap<String, bool>) -> PrRow {
117 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
130fn 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
143pub 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
168fn 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
180pub 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
192pub 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
204pub 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
240pub 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
284pub 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 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 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}