1use std::net::Ipv4Addr;
8use std::sync::Arc;
9
10use axum::extract::{Query, Request, State};
11use axum::http::{header, StatusCode, Uri};
12use axum::middleware::{self, Next};
13use axum::response::{IntoResponse, Json, Response};
14use axum::routing::{get, post};
15use axum::Router;
16use forgetop_core::cache::CacheStore;
17use forgetop_core::domain::{Notification, NotificationDetail};
18use forgetop_core::launchpad::{Entry, EntryKind};
19use forgetop_core::provider::{ItemRef, PrDecoration};
20use forgetop_core::secret::SecretStore;
21use forgetop_core::service::{ConfigService, ConnectionHealthService, SectionService};
22use rust_embed::RustEmbed;
23use serde::Deserialize;
24
25use crate::actions::ActionError;
26
27pub mod cached;
28
29mod actions;
30mod connections;
31mod dto;
32
33#[derive(RustEmbed)]
35#[folder = "web/dist"]
36struct Assets;
37
38pub const DEFAULT_PORT: u16 = 8177;
40
41#[derive(Clone)]
44pub struct Deps {
45 pub sections: Arc<SectionService>,
46 pub health: Arc<ConnectionHealthService>,
47 pub config: Arc<ConfigService>,
48 pub secrets: Arc<dyn SecretStore>,
49 pub cache: Arc<CacheStore>,
53}
54
55pub struct Server {
57 pub port: u16,
58 pub token: String,
59 pub url: String,
61}
62
63#[derive(Default)]
68struct DecorationCache {
69 entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
70}
71
72impl DecorationCache {
73 const MAX: usize = 500;
76
77 fn key(conn: &str, item: &ItemRef, updated_at: Option<&str>) -> String {
78 format!("{conn}\u{1}{}\u{1}{}\u{1}{}", item.repo.as_deref().unwrap_or(""), item.id, updated_at.unwrap_or(""))
79 }
80
81 fn get(&self, key: &str) -> Option<PrDecoration> {
82 self.entries.lock().unwrap().get(key).cloned()
83 }
84
85 fn put(&self, key: String, value: PrDecoration) {
86 let mut entries = self.entries.lock().unwrap();
87 if entries.len() >= Self::MAX {
88 entries.clear();
89 }
90 entries.insert(key, value);
91 }
92}
93
94#[derive(Default)]
98struct NotifDetailCache {
99 entries: std::sync::Mutex<std::collections::HashMap<String, NotificationDetail>>,
100}
101
102impl NotifDetailCache {
103 const MAX: usize = 500;
105
106 fn key(conn: &str, n: &Notification) -> String {
107 format!("{conn}\u{1}{}\u{1}{}", n.id, n.detail_ref.as_deref().unwrap_or(""))
108 }
109
110 fn get(&self, key: &str) -> Option<NotificationDetail> {
111 self.entries.lock().unwrap_or_else(|p| p.into_inner()).get(key).cloned()
112 }
113
114 fn put(&self, key: String, value: NotificationDetail) {
115 let mut entries = self.entries.lock().unwrap_or_else(|p| p.into_inner());
116 if entries.len() >= Self::MAX {
117 entries.clear();
118 }
119 entries.insert(key, value);
120 }
121}
122
123#[derive(Clone)]
124struct AppState {
125 deps: Deps,
126 token: Arc<str>,
127 decorations: Arc<DecorationCache>,
128 notif_details: Arc<NotifDetailCache>,
129 inflight: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
132 flusher: Arc<cached::Flusher>,
133}
134
135async fn bind(deps: Deps, port: u16) -> std::io::Result<(tokio::net::TcpListener, Server, AppState)> {
136 let token = uuid::Uuid::new_v4().to_string();
137 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await?;
138 let bound = listener.local_addr()?.port();
139 let url = format!("http://127.0.0.1:{bound}/?t={token}");
140 let flusher = Arc::new(cached::Flusher::new(deps.cache.clone()));
141 let state = AppState {
142 deps,
143 token: Arc::from(token.as_str()),
144 decorations: Arc::new(DecorationCache::default()),
145 notif_details: Arc::new(NotifDetailCache::default()),
146 inflight: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
147 flusher,
148 };
149 Ok((listener, Server { port: bound, token, url }, state))
150}
151
152pub async fn spawn(deps: Deps, port: u16) -> std::io::Result<Server> {
155 let (listener, server, state) = bind(deps, port).await?;
156 tokio::spawn(async move {
157 let _ = axum::serve(listener, router(state)).await;
158 });
159 Ok(server)
160}
161
162pub async fn serve_blocking(deps: Deps, port: u16, on_ready: impl FnOnce(&str)) -> std::io::Result<()> {
165 let (listener, server, state) = bind(deps, port).await?;
166 on_ready(&server.url);
167 axum::serve(listener, router(state)).await
168}
169
170fn router(state: AppState) -> Router {
171 let api = Router::new()
175 .route("/api/health", get(health))
176 .route("/api/pull-requests", get(pull_requests))
177 .route("/api/work-items", get(work_items))
178 .route("/api/pipelines", get(pipelines))
179 .route("/api/notifications", get(notifications))
180 .route("/api/launchpad", get(launchpad))
181 .route("/api/launchpad/dismiss", post(dismiss_launchpad_item))
182 .route("/api/prefs", get(prefs))
183 .route("/api/prefs/hidden-work-item-state", post(set_hidden_work_item_state))
184 .route("/api/pr/detail", get(pr_detail))
185 .route("/api/pr/decoration", get(pr_decoration))
186 .route("/api/pr/commit-changes", get(pr_commit_changes))
187 .route("/api/pr/file-text", get(pr_file_text))
188 .route("/api/highlight", post(highlight))
189 .route("/api/pr/vote", post(pr_vote))
190 .route("/api/pr/merge", post(pr_merge))
191 .route("/api/pr/revert", post(pr_revert))
192 .route("/api/pr/comment", post(pr_comment))
193 .route("/api/pr/reply", post(pr_reply))
194 .route("/api/pr/review", post(pr_review))
195 .route("/api/pr/resolve-thread", post(pr_resolve_thread))
196 .route("/api/pr/draft", post(pr_draft))
197 .route("/api/pr/closed", post(pr_closed))
198 .route("/api/pr/reviewers", get(pr_reviewers))
199 .route("/api/pr/request-reviewer", post(pr_request_reviewer))
200 .route("/api/wi/detail", get(wi_detail))
201 .route("/api/wi/states", get(wi_states))
202 .route("/api/wi/state", post(wi_state))
203 .route("/api/wi/comment", post(wi_comment))
204 .route("/api/wi/assignees", get(wi_assignees))
205 .route("/api/wi/assignee", post(wi_assignee))
206 .route("/api/wi/update", post(wi_update))
207 .route("/api/pipeline/detail", get(pipeline_detail))
208 .route("/api/pipeline/logs", get(pipeline_logs))
209 .route("/api/pipeline/annotations", get(pipeline_annotations))
210 .route("/api/pipeline/artifacts", get(pipeline_artifacts))
211 .route("/api/pipeline/approval", post(pipeline_approval))
212 .route("/api/pipeline/trigger", post(pipeline_trigger))
213 .route("/api/pipeline/cancel", post(pipeline_cancel))
214 .route("/api/pipeline/rerun", post(pipeline_rerun))
215 .route("/api/notification/read", post(notification_read))
216 .route("/api/notification/read-many", post(notification_read_many))
217 .route("/api/notification/done", post(notification_done))
218 .route("/api/notification/detail", post(notification_detail))
219 .route("/api/inbox", get(inbox))
220 .route("/api/providers", get(providers))
221 .route("/api/connections", get(list_connections).post(save_connection))
222 .route("/api/connections/delete", post(delete_connection))
223 .route("/api/connections/test", post(test_connection))
224 .route("/api/connections/repositories", get(connection_repositories))
225 .route("/api/connections/scope", post(set_connection_scope))
226 .route("/api/pipelines/definitions", get(pipeline_definitions))
227 .route("/api/pipelines/selection", post(set_pipeline_selection))
228 .layer(middleware::from_fn_with_state(state.clone(), auth))
229 .with_state(state);
230
231 Router::new().merge(api).fallback(static_asset)
232}
233
234async fn static_asset(uri: Uri) -> Response {
237 let path = uri.path().trim_start_matches('/');
238 let path = if path.is_empty() { "index.html" } else { path };
239 serve(path)
240 .or_else(|| serve("index.html"))
241 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
242}
243
244fn serve(path: &str) -> Option<Response> {
245 let file = Assets::get(path)?;
246 let mime = mime_guess::from_path(path).first_or_octet_stream();
247 Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
248}
249
250async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
254 if token_from(&req).as_deref() == Some(&state.token) {
255 next.run(req).await
256 } else {
257 (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
258 }
259}
260
261fn token_from(req: &Request) -> Option<String> {
262 if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
263 return Some(header.to_string());
264 }
265 req.uri()
266 .query()
267 .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
268}
269
270async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
271 Json(dto::health(&s.deps.health).await)
272}
273pub const FETCHED_AT_HEADER: &str = "x-forgetop-fetched-at";
275
276const STALE_WAIT: std::time::Duration = std::time::Duration::from_millis(1500);
281
282fn stale_wait() -> std::time::Duration {
283 std::env::var("FORGETOP_CACHE_WAIT_MS")
284 .ok()
285 .and_then(|v| v.parse().ok())
286 .map(std::time::Duration::from_millis)
287 .unwrap_or(STALE_WAIT)
288}
289
290async fn serve_cached<T, F, Fut>(s: &AppState, key: &str, cached: Option<cached::Cached<T>>, fetch: F) -> Response
296where
297 T: serde::Serialize + Send + 'static,
298 F: FnOnce(AppState) -> Fut + Send + 'static,
299 Fut: std::future::Future<Output = T> + Send + 'static,
300{
301 let claim = |s: &AppState| s.inflight.lock().unwrap_or_else(|p| p.into_inner()).insert(key.to_string());
302 let from_cache = |hit: cached::Cached<T>| {
303 let mut res = Json(hit.value).into_response();
304 if let Ok(v) = hit.fetched_at.to_rfc3339().parse() {
305 res.headers_mut().insert(FETCHED_AT_HEADER, v);
306 }
307 res
308 };
309 if !claim(s) {
310 return match cached {
312 Some(hit) => from_cache(hit),
313 None => Json(fetch(s.clone()).await).into_response(),
314 };
315 }
316 let live = {
317 let s2 = s.clone();
318 let key = key.to_string();
319 tokio::spawn(async move {
320 let value = fetch(s2.clone()).await;
321 s2.inflight.lock().unwrap_or_else(|p| p.into_inner()).remove(&key);
322 s2.flusher.schedule();
323 value
324 })
325 };
326 let Some(hit) = cached else {
327 return match live.await {
328 Ok(value) => Json(value).into_response(),
329 Err(_) => (StatusCode::BAD_GATEWAY, "the fetch behind this list failed").into_response(),
330 };
331 };
332 match tokio::time::timeout(stale_wait(), live).await {
333 Ok(Ok(value)) => Json(value).into_response(),
334 _ => from_cache(hit),
336 }
337}
338
339async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Response {
340 let view = dto::PrView::parse(q.view.as_deref());
341 let hit = cached::pull_requests(&s.deps.cache, view);
342 serve_cached(&s, &format!("prs.{view:?}"), hit, move |s: AppState| async move { dto::pull_requests(&s.deps.sections, &s.deps.cache, view).await }).await
343}
344async fn work_items(State(s): State<AppState>) -> Response {
345 let hit = cached::work_items(&s.deps.cache);
346 serve_cached(&s, "work_items", hit, |s: AppState| async move { dto::work_items(&s.deps.sections, &s.deps.cache).await }).await
347}
348async fn pipelines(State(s): State<AppState>) -> Response {
349 let hit = cached::pipelines(&s.deps.cache);
352 serve_cached(&s, "pipelines", hit, |s: AppState| async move { dto::pipelines(&s.deps.sections).await }).await
353}
354fn dismissed_notifications(s: &AppState) -> Arc<std::collections::HashSet<String>> {
357 Arc::new(s.deps.config.snapshot().ui.dismissed_notifications.into_iter().collect())
358}
359
360fn visible_notifications(s: &AppState, rows: Vec<dto::NotifRow>, dismissed: &std::collections::HashSet<String>) -> Vec<dto::NotifRow> {
363 rows.into_iter()
364 .filter(|r| !dismissed.contains(&r.dismiss_key))
365 .map(|r| {
366 let n = &r.notification;
367 if n.detail_ref.is_none() || (n.actor.is_some() && n.excerpt.is_some()) {
368 return r;
369 }
370 match s.notif_details.get(&NotifDetailCache::key(&r.connection_id, n)) {
371 Some(d) => r.with_detail(&d),
372 None => r,
373 }
374 })
375 .collect()
376}
377
378const NOTIFICATIONS_FETCH: &str = "notifications";
380
381async fn notifications(State(s): State<AppState>) -> Response {
382 let dismissed = dismissed_notifications(&s);
383 let hit = cached::notifications(&s.deps.cache)
384 .map(|c| cached::Cached { value: visible_notifications(&s, c.value, &dismissed), fetched_at: c.fetched_at });
385 serve_cached(&s, NOTIFICATIONS_FETCH, hit, move |s: AppState| async move {
386 let rows = dto::notifications(&s.deps.sections, &s.deps.cache).await;
387 visible_notifications(&s, rows, &dismissed)
388 })
389 .await
390}
391
392async fn inbox(State(s): State<AppState>) -> Response {
395 let dismissed = dismissed_notifications(&s);
396 let hit = cached::notifications(&s.deps.cache).map(|c| cached::Cached {
397 value: dto::inbox_threads(&visible_notifications(&s, c.value, &dismissed)),
398 fetched_at: c.fetched_at,
399 });
400 serve_cached(&s, NOTIFICATIONS_FETCH, hit, move |s: AppState| async move {
401 let rows = dto::notifications(&s.deps.sections, &s.deps.cache).await;
402 dto::inbox_threads(&visible_notifications(&s, rows, &dismissed))
403 })
404 .await
405}
406async fn launchpad(State(s): State<AppState>) -> Response {
407 let dismissed = Arc::new(s.deps.config.snapshot().ui.dismissed_launchpad_items);
409 let hit = cached::launchpad(&s.deps.cache, &dismissed);
410 serve_cached(&s, "launchpad", hit, move |s: AppState| async move { dto::launchpad(&s.deps.sections, &s.deps.cache, &dismissed).await }).await
411}
412
413#[derive(Deserialize)]
419struct ItemQuery {
420 conn: String,
421 id: String,
422 #[serde(default)]
423 repo: Option<String>,
424}
425
426impl ItemQuery {
427 fn item(self) -> (String, ItemRef) {
428 (self.conn, ItemRef::maybe(self.repo, self.id))
429 }
430}
431
432#[derive(Deserialize)]
434struct CommitQuery {
435 conn: String,
436 id: String,
437 sha: String,
438 #[serde(default)]
439 repo: Option<String>,
440}
441
442#[derive(Deserialize)]
445struct FileTextQuery {
446 conn: String,
447 id: String,
448 path: String,
449 #[serde(default)]
450 sha: Option<String>,
451 #[serde(default)]
452 repo: Option<String>,
453}
454
455#[derive(Deserialize)]
457struct PrListQuery {
458 #[serde(default)]
459 view: Option<String>,
460}
461
462#[derive(Deserialize)]
464struct RunQuery {
465 conn: String,
466 run_id: String,
467 #[serde(default)]
468 repo: Option<String>,
469}
470
471#[derive(Deserialize)]
473struct PipelineLogsQuery {
474 conn: String,
475 run_id: String,
476 #[serde(default)]
477 repo: Option<String>,
478 #[serde(default)]
479 job: Option<String>,
480}
481
482fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
485 action_response_with(operation, result.map(|()| serde_json::json!({ "ok": true })))
486}
487
488fn action_response_with(operation: &'static str, result: Result<serde_json::Value, ActionError>) -> Response {
490 match result {
491 Ok(body) => Json(body).into_response(),
492 Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
493 Err(ActionError::Failed(msg)) => {
494 forgetop_core::diag::log(operation, "provider action failed");
495 (StatusCode::BAD_GATEWAY, msg).into_response()
496 }
497 }
498}
499
500async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
501 match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
502 Some(changes) => Json(changes).into_response(),
503 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
504 }
505}
506
507async fn pr_file_text(State(s): State<AppState>, Query(q): Query<FileTextQuery>) -> Response {
511 match dto::pr_file_text(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.path, q.sha.as_deref()).await {
512 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
513 None => (StatusCode::NOT_FOUND, "file text not available").into_response(),
514 }
515}
516
517#[derive(Deserialize)]
519struct HighlightReq {
520 path: String,
521 lines: Vec<String>,
522}
523
524async fn highlight(Json(req): Json<HighlightReq>) -> Json<dto::Highlight> {
528 Json(dto::highlight(&req.path, &req.lines))
529}
530
531async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
532 let (conn, item) = q.item();
533 match dto::pr_detail(&s.deps.sections, &conn, &item).await {
534 Some(detail) => Json(detail).into_response(),
535 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
536 }
537}
538
539#[derive(Deserialize)]
542struct DecorationQuery {
543 conn: String,
544 id: String,
545 #[serde(default)]
546 repo: Option<String>,
547 #[serde(default)]
548 updated_at: Option<String>,
549}
550
551async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
552 let item = ItemRef::maybe(q.repo, q.id);
553 let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
554 if let Some(hit) = s.decorations.get(&key) {
555 return Json(hit).into_response();
556 }
557 match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
558 Some(decoration) => {
559 s.decorations.put(key, decoration.clone());
560 Json(decoration).into_response()
561 }
562 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
563 }
564}
565
566async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
567 action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
568}
569async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
570 action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
571}
572async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
573 action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
574}
575async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
576 action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
577}
578async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
579 action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
580}
581async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
582 action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
583}
584async fn pr_resolve_thread(State(s): State<AppState>, Json(req): Json<actions::PrResolveThreadReq>) -> Response {
585 action_response("dashboard.pr_resolve_thread", actions::pr_resolve_thread(&s.deps.sections, req).await)
586}
587async fn pr_draft(State(s): State<AppState>, Json(req): Json<actions::PrDraftReq>) -> Response {
588 action_response("dashboard.pr_draft", actions::pr_set_draft(&s.deps.sections, req).await)
589}
590async fn pr_closed(State(s): State<AppState>, Json(req): Json<actions::PrClosedReq>) -> Response {
591 action_response("dashboard.pr_closed", actions::pr_set_closed(&s.deps.sections, req).await)
592}
593async fn pr_reviewers(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
594 let (conn, item) = q.item();
595 match actions::pr_reviewers(&s.deps.sections, &conn, &item).await {
596 Some(users) => Json(users).into_response(),
597 None => (StatusCode::NOT_FOUND, "pull request connection not found").into_response(),
598 }
599}
600async fn pr_request_reviewer(State(s): State<AppState>, Json(req): Json<actions::PrRequestReviewerReq>) -> Response {
601 action_response("dashboard.pr_request_reviewer", actions::pr_request_reviewer(&s.deps.sections, req).await)
602}
603
604async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
605 let (conn, item) = q.item();
606 match dto::wi_detail(&s.deps.sections, &conn, &item).await {
607 Some(detail) => Json(detail).into_response(),
608 None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
609 }
610}
611async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
612 let (conn, item) = q.item();
613 match actions::wi_states(&s.deps.sections, &conn, &item).await {
614 Some(states) => Json(states).into_response(),
615 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
616 }
617}
618async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
619 action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
620}
621async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
622 action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
623}
624async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
625 let (conn, item) = q.item();
626 match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
627 Some(users) => Json(users).into_response(),
628 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
629 }
630}
631async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
632 action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
633}
634async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
635 action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
636}
637
638async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
639 match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
640 Some(detail) => Json(detail).into_response(),
641 None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
642 }
643}
644async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
645 match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
646 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
647 None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
648 }
649}
650
651async fn pipeline_annotations(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
652 match dto::pipeline_annotations(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
653 Some(annotations) => Json(annotations).into_response(),
654 None => (StatusCode::NOT_FOUND, "annotations not available").into_response(),
655 }
656}
657async fn pipeline_artifacts(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
658 match dto::pipeline_artifacts(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
659 Some(artifacts) => Json(artifacts).into_response(),
660 None => (StatusCode::NOT_FOUND, "artifacts not available").into_response(),
661 }
662}
663
664async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
665 action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
666}
667async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
668 action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
669}
670async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
671 action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
672}
673async fn pipeline_rerun(State(s): State<AppState>, Json(req): Json<actions::PipelineRerunReq>) -> Response {
674 let result = actions::pipeline_rerun(&s.deps.sections, req).await;
675 action_response_with("dashboard.pipeline_rerun", result.map(|new_run_id| serde_json::json!({ "ok": true, "new_run_id": new_run_id })))
676}
677
678async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
679 action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
680}
681
682async fn current_notifications(s: &AppState, conn: &str, ids: &[String]) -> Vec<dto::NotifRow> {
686 if let Some(hit) = cached::notifications(&s.deps.cache) {
687 let knows = |id: &String| hit.value.iter().any(|r| r.connection_id == conn && r.notification.id == *id);
688 if ids.iter().all(knows) {
689 return hit.value;
690 }
691 }
692 let rows = dto::notifications(&s.deps.sections, &s.deps.cache).await;
693 s.flusher.schedule();
694 rows
695}
696
697async fn notification_read_many(State(s): State<AppState>, Json(req): Json<actions::NotifIdsReq>) -> Response {
698 let rows = current_notifications(&s, &req.conn, &req.ids).await;
699 action_response("dashboard.notification_read_many", actions::notif_read_many(&s.deps.sections, &rows, &req).await)
700}
701
702async fn notification_done(State(s): State<AppState>, Json(req): Json<actions::NotifIdsReq>) -> Response {
704 let rows = current_notifications(&s, &req.conn, &req.ids).await;
705 action_response("dashboard.notification_done", actions::notif_done(&s.deps.sections, &s.deps.config, &rows, &req).await)
706}
707
708#[derive(Deserialize)]
709struct NotifDetailReq {
710 conn: String,
711 id: String,
712}
713
714async fn notification_detail(State(s): State<AppState>, Json(req): Json<NotifDetailReq>) -> Response {
718 let rows = current_notifications(&s, &req.conn, std::slice::from_ref(&req.id)).await;
719 let Some(row) = rows.into_iter().find(|r| r.connection_id == req.conn && r.notification.id == req.id) else {
720 return (StatusCode::NOT_FOUND, "notification not found").into_response();
721 };
722 let detail = if row.notification.detail_ref.is_none() {
723 NotificationDetail::default()
725 } else {
726 let key = NotifDetailCache::key(&req.conn, &row.notification);
727 match s.notif_details.get(&key) {
728 Some(hit) => hit,
729 None => match actions::notif_detail(&s.deps.sections, &req.conn, &row.notification).await {
730 Ok(d) => {
731 s.notif_details.put(key, d.clone());
732 d
733 }
734 Err(e) => {
735 s.notif_details.put(key, NotificationDetail::default());
738 return action_response_with("dashboard.notification_detail", Err(e));
739 }
740 },
741 }
742 };
743 let target = detail.target.clone();
744 let filled = row.with_detail(&detail);
746 Json(serde_json::json!({
747 "actor": filled.notification.actor,
748 "excerpt": filled.notification.excerpt,
749 "target": target,
750 "url": filled.notification.url,
751 "phrase": filled.phrase,
752 "why": filled.why,
753 }))
754 .into_response()
755}
756
757#[derive(Deserialize)]
760struct IdReq {
761 id: String,
762}
763
764async fn providers() -> Json<Vec<connections::ProviderInfo>> {
765 Json(connections::providers())
766}
767async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
768 Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
769}
770async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
771 match connections::save(&s.deps.config, &s.deps.sections, req).await {
772 Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
773 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
774 }
775}
776async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
777 match connections::remove(&s.deps.config, &req.id).await {
778 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
779 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
780 }
781}
782async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
783 match connections::test(&s.deps.health, &req.id).await {
784 Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
785 None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
786 }
787}
788
789async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
793 match s.deps.sections.discover_repositories(&q.id).await {
794 Ok(page) => Json(page).into_response(),
795 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
796 }
797}
798
799#[derive(Deserialize)]
800struct IdQuery {
801 id: String,
802}
803
804#[derive(Deserialize)]
805struct ScopeReq {
806 id: String,
807 scope: Vec<String>,
810}
811
812async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
813 match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
814 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
815 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
816 }
817}
818
819#[derive(serde::Serialize)]
823struct PipelineDefinitionsResp {
824 definitions: Vec<forgetop_core::domain::PipelineDefinition>,
825 all: bool,
826 selected: Vec<String>,
827}
828
829async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
830 let source = match s.deps.sections.pipeline_source_for(&q.id).await {
831 Ok(Some(source)) => source,
832 Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
833 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
834 };
835 let definitions = match source.discover().await {
836 Ok(defs) => defs,
837 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
838 };
839 let cfg = s.deps.config.snapshot();
840 let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
841 Json(PipelineDefinitionsResp {
842 definitions,
843 all: sub.is_some_and(|s| s.auto_discover_all),
844 selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
845 })
846 .into_response()
847}
848
849#[derive(Deserialize)]
850struct PipelineSelectionReq {
851 id: String,
852 #[serde(default)]
854 all: bool,
855 #[serde(default)]
857 ids: Vec<String>,
858}
859
860async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
861 let saved = if req.all {
862 s.deps.config.set_pipeline_auto_discover(&req.id, true).await
863 } else {
864 s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
865 };
866 match saved {
867 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
868 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
869 }
870}
871
872#[derive(Deserialize)]
873struct DismissLaunchpadReq {
874 conn: String,
875 kind: String,
877 #[serde(default)]
878 repo: Option<String>,
879 id: String,
880}
881
882async fn dismiss_launchpad_item(State(s): State<AppState>, Json(req): Json<DismissLaunchpadReq>) -> Response {
884 let kind = match req.kind.as_str() {
885 "pr" => EntryKind::Pr,
886 "wi" => EntryKind::Wi,
887 "pipe" => EntryKind::Pipe,
888 other => return (StatusCode::BAD_REQUEST, format!("unknown launchpad kind `{other}`")).into_response(),
889 };
890 let key = Entry::key(&req.conn, kind, req.repo.as_deref(), &req.id);
891 match s.deps.config.dismiss_launchpad_item(key).await {
893 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
894 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
895 }
896}
897
898async fn prefs(State(s): State<AppState>) -> Response {
900 let ui = s.deps.config.snapshot().ui;
901 Json(serde_json::json!({ "hidden_work_item_states": ui.hidden_work_item_states })).into_response()
902}
903
904#[derive(Deserialize)]
905struct HiddenWorkItemStateReq {
906 state: String,
907 hidden: bool,
908}
909
910async fn set_hidden_work_item_state(State(s): State<AppState>, Json(req): Json<HiddenWorkItemStateReq>) -> Response {
913 match s.deps.config.set_hidden_work_item_state(&req.state, req.hidden).await {
914 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
915 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
926 use forgetop_core::config::{ConfigStore, ForgetopConfig, InMemoryConfigStore};
927 use forgetop_core::domain::ProviderType;
928 use forgetop_core::provider::{Connection, ProviderRegistry};
929 use forgetop_core::secret::InMemorySecretStore;
930 use forgetop_core::service::ConnectionResolver;
931 use forgetop_providers::demo::demo_factories;
932
933 async fn demo_deps() -> Deps {
935 deps_with(Arc::new(CacheStore::disabled())).await
936 }
937
938 async fn deps_with(cache: Arc<CacheStore>) -> Deps {
939 let registry = Arc::new(ProviderRegistry::new(demo_factories()));
940 let store: Arc<dyn ConfigStore> = Arc::new(InMemoryConfigStore::new(ForgetopConfig::default()));
941 let secrets: Arc<dyn SecretStore> = Arc::new(InMemorySecretStore::default());
942 let config = Arc::new(ConfigService::new(store, secrets.clone(), registry.clone()));
943 config.load().await.unwrap();
944 let conn = Connection {
945 id: "github".into(),
946 provider_type: ProviderType::GitHub,
947 display_name: "github".into(),
948 base_url: None,
949 organization: None,
950 project: None,
951 repository: None,
952 username: None,
953 credential_ref: None,
954 repo_scope: None,
955 };
956 config.add_or_update_connection(conn, None).await.unwrap();
957 config.bind_pull_requests("github").await.unwrap();
958 config.bind_work_items("github").await.unwrap();
959 config.set_pipeline_auto_discover("github", true).await.unwrap();
960 let resolver = Arc::new(ConnectionResolver::new(config.clone(), registry, secrets.clone()));
961 let sections = Arc::new(SectionService::new(config.clone(), resolver.clone()));
962 let health = Arc::new(ConnectionHealthService::new(config.clone(), resolver));
963 Deps { sections, health, config, secrets, cache }
964 }
965
966 struct Api {
967 base: String,
968 token: String,
969 client: reqwest::Client,
970 }
971
972 impl Api {
973 async fn start() -> Api {
974 let server = spawn(demo_deps().await, 0).await.expect("server binds a free port");
975 Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
976 }
977
978 async fn start_with(deps: Deps) -> Api {
979 let server = spawn(deps, 0).await.expect("server binds a free port");
980 Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
981 }
982
983 async fn get_raw(&self, path: &str) -> reqwest::Response {
984 self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.expect("request")
985 }
986 async fn get(&self, path: &str) -> serde_json::Value {
987 let r = self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.unwrap();
988 assert_eq!(r.status(), 200, "GET {path}");
989 r.json().await.unwrap()
990 }
991 async fn post(&self, path: &str, body: serde_json::Value) -> u16 {
992 let r = self.client.post(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).json(&body).send().await.unwrap();
993 r.status().as_u16()
994 }
995 async fn open_prs(&self) -> Vec<(String, String)> {
997 let rows = self.get("/api/pull-requests").await;
998 rows.as_array()
999 .unwrap()
1000 .iter()
1001 .map(|r| (r["pull_request"]["id"].as_str().unwrap().to_string(), r["pull_request"]["status"].as_str().unwrap().to_string()))
1002 .collect()
1003 }
1004 async fn detail(&self, id: &str) -> serde_json::Value {
1005 self.get(&format!("/api/pr/detail?conn=github&id={id}")).await
1006 }
1007 }
1008
1009 static DEMO_PRS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1014
1015 async fn open_pr(api: &Api, nth: usize) -> String {
1016 let open: Vec<_> = api.open_prs().await.into_iter().filter(|(_, s)| s == "Open").collect();
1017 open.get(nth).unwrap_or_else(|| panic!("demo has at least {} open PRs", nth + 1)).0.clone()
1018 }
1019
1020 #[tokio::test]
1021 async fn pr_detail_advertises_the_demo_writes() {
1022 let _one_at_a_time = DEMO_PRS.lock().await;
1023 let api = Api::start().await;
1024 let id = open_pr(&api, 0).await;
1025 let writes = &api.detail(&id).await["writes"];
1026 for k in ["resolve_threads", "draft", "close", "reopen", "request_reviewer"] {
1027 assert_eq!(writes[k], true, "demo supports {k}: {writes}");
1028 }
1029 }
1030
1031 #[tokio::test]
1032 async fn resolving_a_thread_flips_it_on_the_next_detail() {
1033 let _one_at_a_time = DEMO_PRS.lock().await;
1034 let api = Api::start().await;
1035 let id = open_pr(&api, 1).await;
1036 let thread = |d: &serde_json::Value, t: &str| d["threads"].as_array().unwrap().iter().find(|x| x["id"] == t).cloned().unwrap();
1037 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "t1 starts open");
1038
1039 let body = |resolved: bool| serde_json::json!({ "conn": "github", "id": id, "thread_id": "t1", "resolved": resolved });
1040 assert_eq!(api.post("/api/pr/resolve-thread", body(true)).await, 200);
1041 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], true, "resolve persists");
1042 assert_eq!(api.post("/api/pr/resolve-thread", body(false)).await, 200);
1043 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "reopen persists");
1044 }
1045
1046 #[tokio::test]
1047 async fn draft_and_close_change_the_pr_status() {
1048 let _one_at_a_time = DEMO_PRS.lock().await;
1049 let api = Api::start().await;
1050 let id = open_pr(&api, 2).await;
1051 let status = |d: serde_json::Value| d["pull_request"]["status"].as_str().unwrap().to_string();
1052
1053 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": true })).await, 200);
1054 assert_eq!(status(api.detail(&id).await), "Draft", "converted to a draft");
1055 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": false })).await, 200);
1056 assert_eq!(status(api.detail(&id).await), "Open", "ready for review again");
1057
1058 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": true })).await, 200);
1059 assert_eq!(status(api.detail(&id).await), "Closed");
1060 assert!(!api.open_prs().await.iter().any(|(p, _)| *p == id), "a closed PR leaves the open list");
1061 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": false })).await, 200);
1062 assert_eq!(status(api.detail(&id).await), "Open", "reopened");
1063 assert!(api.open_prs().await.iter().any(|(p, _)| *p == id), "and back on the open list");
1064
1065 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "nope", "id": id, "draft": true })).await, 404);
1067 }
1068
1069 #[tokio::test]
1070 async fn requesting_a_reviewer_adds_them_to_the_pr() {
1071 let _one_at_a_time = DEMO_PRS.lock().await;
1072 let api = Api::start().await;
1073 let id = open_pr(&api, 3).await;
1074 let users = api.get(&format!("/api/pr/reviewers?conn=github&id={id}")).await;
1075 let users = users.as_array().expect("reviewers is a JSON array of users");
1076 assert!(!users.is_empty(), "the demo has someone to ask");
1077
1078 let reviewer_ids = |d: &serde_json::Value| -> Vec<String> {
1079 d["pull_request"]["reviewers"].as_array().unwrap().iter().map(|r| r["user"]["id"].as_str().unwrap().to_string()).collect()
1080 };
1081 let before = reviewer_ids(&api.detail(&id).await);
1082 let pick = users.iter().map(|u| u["id"].as_str().unwrap().to_string()).find(|u| !before.contains(u)).expect("someone not yet reviewing");
1083
1084 assert_eq!(api.post("/api/pr/request-reviewer", serde_json::json!({ "conn": "github", "id": id, "user_id": pick })).await, 200);
1085 let after = api.detail(&id).await;
1086 let added = after["pull_request"]["reviewers"].as_array().unwrap().iter().find(|r| r["user"]["id"] == pick.as_str()).cloned();
1087 let added = added.expect("the requested reviewer is on the PR");
1088 assert_eq!(added["vote"], "NoVote", "requested, not yet voted");
1089
1090 let missing = api.client.get(format!("{}/api/pr/reviewers?conn=nope&id={id}", api.base)).header("x-forgetop-token", &api.token).send().await.unwrap();
1091 assert_eq!(missing.status(), 404, "a bad connection is a 404");
1092 }
1093
1094 fn scratch_cache() -> (Arc<CacheStore>, std::path::PathBuf) {
1095 let path = std::env::temp_dir().join(format!("forgetop-api-cache-{}.json", uuid::Uuid::new_v4()));
1096 (Arc::new(CacheStore::new(&path)), path)
1097 }
1098
1099 #[tokio::test]
1102 async fn lists_answer_from_the_shared_cache_then_refresh_behind_it() {
1103 std::env::set_var("FORGETOP_CACHE_WAIT_MS", "0");
1105 let (cache, path) = scratch_cache();
1106 let api = Api::start_with(deps_with(cache.clone()).await).await;
1107
1108 let cold = api.get_raw("/api/work-items").await;
1110 assert!(cold.headers().get(FETCHED_AT_HEADER).is_none(), "a live answer carries no cache date");
1111 let live: Vec<serde_json::Value> = cold.json().await.unwrap();
1112 assert!(!live.is_empty());
1113 assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some(), "the live fetch wrote the TUI's key");
1114
1115 assert!(cache.rewrite::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS, |mut rows| {
1117 rows[0].wi.title = "Left here by the terminal".into();
1118 rows
1119 }));
1120 let warm = api.get_raw("/api/work-items").await;
1121 let fetched_at = warm.headers().get(FETCHED_AT_HEADER).expect("a cached answer says when it was fetched").clone();
1122 assert!(chrono::DateTime::parse_from_rfc3339(fetched_at.to_str().unwrap()).is_ok());
1123 let rows: Vec<serde_json::Value> = warm.json().await.unwrap();
1124 assert_eq!(rows[0]["work_item"]["title"], "Left here by the terminal");
1125
1126 let mut fresh = Vec::new();
1128 for _ in 0..50 {
1129 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1130 fresh = api.get_raw("/api/work-items").await.json::<Vec<serde_json::Value>>().await.unwrap();
1131 if fresh[0]["work_item"]["title"] != "Left here by the terminal" {
1132 break;
1133 }
1134 }
1135 assert_ne!(fresh[0]["work_item"]["title"], "Left here by the terminal", "the live fetch wrote back");
1136 std::env::remove_var("FORGETOP_CACHE_WAIT_MS");
1137 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1139 assert!(path.exists(), "the cache was flushed to {}", path.display());
1140 let _ = std::fs::remove_file(path);
1141 }
1142
1143 #[tokio::test]
1145 async fn the_cached_launchpad_drops_dismissed_entries() {
1146 let (cache, path) = scratch_cache();
1147 let api = Api::start_with(deps_with(cache.clone()).await).await;
1148 let _ = api.get_raw("/api/launchpad").await;
1149 let all = cached::launchpad(&cache, &[]).expect("launchpad from cache").value;
1150 let first = serde_json::to_value(&all.rows[0]).unwrap();
1151 let (conn, id) = (first["connection_id"].as_str().unwrap().to_string(), first[match first["kind"].as_str().unwrap() {
1152 "pr" => "pull_request",
1153 "wi" => "work_item",
1154 _ => "run",
1155 }]["id"]
1156 .as_str()
1157 .unwrap()
1158 .to_string());
1159 let legacy = cached::launchpad(&cache, &[format!("{conn}:{id}")]).expect("launchpad from cache").value;
1160 assert!(legacy.rows.len() < all.rows.len(), "the legacy key hides the entry");
1161 let kind = first["kind"].as_str().unwrap();
1163 let item = &first[match kind {
1164 "pr" => "pull_request",
1165 "wi" => "work_item",
1166 _ => "run",
1167 }];
1168 let repo = item["repository"].as_str().unwrap_or("");
1169 let current = cached::launchpad(&cache, &[format!("{conn}:{kind}:{repo}:{id}")]).expect("launchpad from cache").value;
1170 assert_eq!(current.rows.len(), legacy.rows.len(), "the current key hides the same entry");
1171 let other = cached::launchpad(&cache, &["nope:pr::0".to_string()]).expect("launchpad from cache").value;
1173 assert_eq!(other.rows.len(), all.rows.len());
1174 let _ = std::fs::remove_file(path);
1175 }
1176
1177 #[tokio::test]
1179 async fn live_fetches_write_the_terminal_ui_keys() {
1180 let (cache, path) = scratch_cache();
1181 let api = Api::start_with(deps_with(cache.clone()).await).await;
1182 let _ = api.get_raw("/api/pull-requests?view=all").await;
1183 let _ = api.get_raw("/api/pull-requests?view=review_requested").await;
1184 let _ = api.get_raw("/api/launchpad").await;
1185 let _ = api.get_raw("/api/notifications").await;
1186 let all = cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::All, false)).expect("All view");
1187 assert!(!all.value.is_empty());
1188 assert!(cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::ReviewRequested, false)).is_some());
1189 assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_MINE).is_some());
1190 assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_REVIEW).is_some());
1191 assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some());
1192 assert!(cache.get::<Vec<cached::TuiPipeRow>>(cached::KEY_PIPELINES).is_some());
1193 assert!(cache.get::<Vec<cached::TuiNotifRow>>(cached::KEY_INBOX).is_some());
1194 let live: serde_json::Value = api.get_raw("/api/launchpad").await.json().await.unwrap();
1196 let from_cache = cached::launchpad(&cache, &[]).expect("launchpad from cache");
1197 assert_eq!(serde_json::to_value(&from_cache.value).unwrap(), live);
1198 let _ = std::fs::remove_file(path);
1199 }
1200}