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(Deserialize)]
85struct TuiPrPool {
86 #[serde(default)]
87 open: Vec<TuiPrRow>,
88 #[serde(default)]
89 completed: Vec<TuiPrRow>,
90 #[serde(default)]
93 me: HashMap<String, Option<String>>,
94 #[serde(default)]
95 needs_decoration: HashMap<String, bool>,
96}
97
98pub struct Cached<T> {
100 pub value: T,
101 pub fetched_at: DateTime<Utc>,
102}
103
104fn pr_row(r: TuiPrRow, needs_decoration: &HashMap<String, bool>) -> PrRow {
107 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
120fn 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
133pub 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
158fn 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
170pub 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 let entry = cache.get::<Vec<NotifRow>>(KEY_INBOX)?;
185 Some(Cached { value: entry.value, fetched_at: entry.fetched_at })
186}
187
188pub 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
224pub 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
259pub 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 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 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}