Skip to main content

forgetop_server/
lib.rs

1//! Embedded HTTP server for the forgetop web dashboard.
2//!
3//! It's a thin frontend over the **same** `SectionService` / health services the TUI uses —
4//! no logic fork. Wave 1 is a read-only JSON API + a placeholder page, bound to `127.0.0.1`
5//! and gated by a per-session token so no other local process or web page can reach it.
6
7use 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::launchpad::{Entry, EntryKind};
18use forgetop_core::provider::{ItemRef, PrDecoration};
19use forgetop_core::secret::SecretStore;
20use forgetop_core::service::{ConfigService, ConnectionHealthService, SectionService};
21use rust_embed::RustEmbed;
22use serde::Deserialize;
23
24use crate::actions::ActionError;
25
26pub mod cached;
27
28mod actions;
29mod connections;
30mod dto;
31
32/// The built dashboard SPA, baked into the binary at compile time (see `build.rs`).
33#[derive(RustEmbed)]
34#[folder = "web/dist"]
35struct Assets;
36
37/// Default dashboard port. `0` lets the OS pick a free one.
38pub const DEFAULT_PORT: u16 = 8177;
39
40/// The services the dashboard reads from and writes through — the same ones the TUI is given,
41/// so connections added here show up in the TUI (and vice versa).
42#[derive(Clone)]
43pub struct Deps {
44    pub sections: Arc<SectionService>,
45    pub health: Arc<ConnectionHealthService>,
46    pub config: Arc<ConfigService>,
47    pub secrets: Arc<dyn SecretStore>,
48    /// The on-disk cache the TUI paints from at launch, shared so the dashboard does too: lists
49    /// answer from it first and the live fetch writes back (see `cached`). Pass a disabled store
50    /// to cache nothing (`--demo`).
51    pub cache: Arc<CacheStore>,
52}
53
54/// A bound, running server: where it lives and the token needed to reach it.
55pub struct Server {
56    pub port: u16,
57    pub token: String,
58    /// `http://127.0.0.1:<port>/?t=<token>` — the URL to open in a browser.
59    pub url: String,
60}
61
62/// Decorated PR fields, cached per `(connection, repository, id, updated_at)`.
63///
64/// `updated_at` is part of the key on purpose: it is the provider's own statement that the PR
65/// changed, so a stale entry can never outlive the change that invalidates it.
66#[derive(Default)]
67struct DecorationCache {
68    entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
69}
70
71impl DecorationCache {
72    /// Bounded so a long-running dashboard can't grow it without limit. On overflow the whole
73    /// map is dropped rather than evicted one by one — decoration is cheap to refetch.
74    const MAX: usize = 500;
75
76    fn key(conn: &str, item: &ItemRef, updated_at: Option<&str>) -> String {
77        format!("{conn}\u{1}{}\u{1}{}\u{1}{}", item.repo.as_deref().unwrap_or(""), item.id, updated_at.unwrap_or(""))
78    }
79
80    fn get(&self, key: &str) -> Option<PrDecoration> {
81        self.entries.lock().unwrap().get(key).cloned()
82    }
83
84    fn put(&self, key: String, value: PrDecoration) {
85        let mut entries = self.entries.lock().unwrap();
86        if entries.len() >= Self::MAX {
87            entries.clear();
88        }
89        entries.insert(key, value);
90    }
91}
92
93#[derive(Clone)]
94struct AppState {
95    deps: Deps,
96    token: Arc<str>,
97    decorations: Arc<DecorationCache>,
98    /// Lists with a live fetch out right now, by key: a poll that lands meanwhile answers from
99    /// the cache rather than starting another.
100    inflight: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
101    flusher: Arc<cached::Flusher>,
102}
103
104async fn bind(deps: Deps, port: u16) -> std::io::Result<(tokio::net::TcpListener, Server, AppState)> {
105    let token = uuid::Uuid::new_v4().to_string();
106    let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await?;
107    let bound = listener.local_addr()?.port();
108    let url = format!("http://127.0.0.1:{bound}/?t={token}");
109    let flusher = Arc::new(cached::Flusher::new(deps.cache.clone()));
110    let state = AppState {
111        deps,
112        token: Arc::from(token.as_str()),
113        decorations: Arc::new(DecorationCache::default()),
114        inflight: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
115        flusher,
116    };
117    Ok((listener, Server { port: bound, token, url }, state))
118}
119
120/// Bind + serve in the background, returning the URL (with token) for the caller to open.
121/// **Best-effort:** an `Err` just means "no dashboard" — the TUI should carry on regardless.
122pub async fn spawn(deps: Deps, port: u16) -> std::io::Result<Server> {
123    let (listener, server, state) = bind(deps, port).await?;
124    tokio::spawn(async move {
125        let _ = axum::serve(listener, router(state)).await;
126    });
127    Ok(server)
128}
129
130/// Bind + serve until the process exits (headless `--dashboard`). Calls `on_ready` with the
131/// URL once bound so the caller can open the browser.
132pub async fn serve_blocking(deps: Deps, port: u16, on_ready: impl FnOnce(&str)) -> std::io::Result<()> {
133    let (listener, server, state) = bind(deps, port).await?;
134    on_ready(&server.url);
135    axum::serve(listener, router(state)).await
136}
137
138fn router(state: AppState) -> Router {
139    // The API carries your data and can act on your behalf, so it's token-gated. The static
140    // SPA (HTML/JS/CSS) is just code — not secret — so it's served openly; the browser gets
141    // the token from the `/?t=` URL and replays it on every API call.
142    let api = Router::new()
143        .route("/api/health", get(health))
144        .route("/api/pull-requests", get(pull_requests))
145        .route("/api/work-items", get(work_items))
146        .route("/api/pipelines", get(pipelines))
147        .route("/api/notifications", get(notifications))
148        .route("/api/launchpad", get(launchpad))
149        .route("/api/launchpad/dismiss", post(dismiss_launchpad_item))
150        .route("/api/prefs", get(prefs))
151        .route("/api/prefs/hidden-work-item-state", post(set_hidden_work_item_state))
152        .route("/api/pr/detail", get(pr_detail))
153        .route("/api/pr/decoration", get(pr_decoration))
154        .route("/api/pr/commit-changes", get(pr_commit_changes))
155        .route("/api/pr/file-text", get(pr_file_text))
156        .route("/api/highlight", post(highlight))
157        .route("/api/pr/vote", post(pr_vote))
158        .route("/api/pr/merge", post(pr_merge))
159        .route("/api/pr/revert", post(pr_revert))
160        .route("/api/pr/comment", post(pr_comment))
161        .route("/api/pr/reply", post(pr_reply))
162        .route("/api/pr/review", post(pr_review))
163        .route("/api/pr/resolve-thread", post(pr_resolve_thread))
164        .route("/api/pr/draft", post(pr_draft))
165        .route("/api/pr/closed", post(pr_closed))
166        .route("/api/pr/reviewers", get(pr_reviewers))
167        .route("/api/pr/request-reviewer", post(pr_request_reviewer))
168        .route("/api/wi/detail", get(wi_detail))
169        .route("/api/wi/states", get(wi_states))
170        .route("/api/wi/state", post(wi_state))
171        .route("/api/wi/comment", post(wi_comment))
172        .route("/api/wi/assignees", get(wi_assignees))
173        .route("/api/wi/assignee", post(wi_assignee))
174        .route("/api/wi/update", post(wi_update))
175        .route("/api/pipeline/detail", get(pipeline_detail))
176        .route("/api/pipeline/logs", get(pipeline_logs))
177        .route("/api/pipeline/annotations", get(pipeline_annotations))
178        .route("/api/pipeline/artifacts", get(pipeline_artifacts))
179        .route("/api/pipeline/approval", post(pipeline_approval))
180        .route("/api/pipeline/trigger", post(pipeline_trigger))
181        .route("/api/pipeline/cancel", post(pipeline_cancel))
182        .route("/api/pipeline/rerun", post(pipeline_rerun))
183        .route("/api/notification/read", post(notification_read))
184        .route("/api/providers", get(providers))
185        .route("/api/connections", get(list_connections).post(save_connection))
186        .route("/api/connections/delete", post(delete_connection))
187        .route("/api/connections/test", post(test_connection))
188        .route("/api/connections/repositories", get(connection_repositories))
189        .route("/api/connections/scope", post(set_connection_scope))
190        .route("/api/pipelines/definitions", get(pipeline_definitions))
191        .route("/api/pipelines/selection", post(set_pipeline_selection))
192        .layer(middleware::from_fn_with_state(state.clone(), auth))
193        .with_state(state);
194
195    Router::new().merge(api).fallback(static_asset)
196}
197
198/// Serves an embedded SPA asset by path, falling back to `index.html` for unknown routes so
199/// client-side routing works on refresh/deep-link.
200async fn static_asset(uri: Uri) -> Response {
201    let path = uri.path().trim_start_matches('/');
202    let path = if path.is_empty() { "index.html" } else { path };
203    serve(path)
204        .or_else(|| serve("index.html"))
205        .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
206}
207
208fn serve(path: &str) -> Option<Response> {
209    let file = Assets::get(path)?;
210    let mime = mime_guess::from_path(path).first_or_octet_stream();
211    Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
212}
213
214/// Session-token gate. The browser opens `/?t=<token>`; other calls may send it as the
215/// `x-forgetop-token` header. Combined with localhost-only binding, this keeps the
216/// (action-capable) API off-limits to other local processes and web pages.
217async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
218    if token_from(&req).as_deref() == Some(&state.token) {
219        next.run(req).await
220    } else {
221        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
222    }
223}
224
225fn token_from(req: &Request) -> Option<String> {
226    if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
227        return Some(header.to_string());
228    }
229    req.uri()
230        .query()
231        .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
232}
233
234async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
235    Json(dto::health(&s.deps.health).await)
236}
237/// The response header naming when a cached answer's data was fetched. Absent on a live answer.
238pub const FETCHED_AT_HEADER: &str = "x-forgetop-fetched-at";
239
240/// How long a list request waits for its live fetch before answering from the shared cache
241/// instead (the fetch carries on behind the answer and writes back). Long enough that a quick
242/// forge still answers live on every poll, short enough that a slow one never leaves the page on
243/// skeletons. `FORGETOP_CACHE_WAIT_MS` overrides it; 0 always answers from the cache when it can.
244const STALE_WAIT: std::time::Duration = std::time::Duration::from_millis(1500);
245
246fn stale_wait() -> std::time::Duration {
247    std::env::var("FORGETOP_CACHE_WAIT_MS")
248        .ok()
249        .and_then(|v| v.parse().ok())
250        .map(std::time::Duration::from_millis)
251        .unwrap_or(STALE_WAIT)
252}
253
254/// Answers a list live when the forge is quick, and from the shared cache when it is not: the
255/// live fetch starts at once, the request waits `stale_wait()` for it, and past that the cached
256/// rows go out (dated by the header) while the fetch finishes behind them and writes back. With
257/// nothing cached the request waits for the fetch however long it takes. One live fetch per key
258/// at a time: a poll that lands while one is out answers from the cache.
259async fn serve_cached<T, F, Fut>(s: &AppState, key: &str, cached: Option<cached::Cached<T>>, fetch: F) -> Response
260where
261    T: serde::Serialize + Send + 'static,
262    F: FnOnce(AppState) -> Fut + Send + 'static,
263    Fut: std::future::Future<Output = T> + Send + 'static,
264{
265    let claim = |s: &AppState| s.inflight.lock().unwrap_or_else(|p| p.into_inner()).insert(key.to_string());
266    let from_cache = |hit: cached::Cached<T>| {
267        let mut res = Json(hit.value).into_response();
268        if let Ok(v) = hit.fetched_at.to_rfc3339().parse() {
269            res.headers_mut().insert(FETCHED_AT_HEADER, v);
270        }
271        res
272    };
273    if !claim(s) {
274        // A fetch is already out for this list; it lands shortly and the next poll sees it.
275        return match cached {
276            Some(hit) => from_cache(hit),
277            None => Json(fetch(s.clone()).await).into_response(),
278        };
279    }
280    let live = {
281        let s2 = s.clone();
282        let key = key.to_string();
283        tokio::spawn(async move {
284            let value = fetch(s2.clone()).await;
285            s2.inflight.lock().unwrap_or_else(|p| p.into_inner()).remove(&key);
286            s2.flusher.schedule();
287            value
288        })
289    };
290    let Some(hit) = cached else {
291        return match live.await {
292            Ok(value) => Json(value).into_response(),
293            Err(_) => (StatusCode::BAD_GATEWAY, "the fetch behind this list failed").into_response(),
294        };
295    };
296    match tokio::time::timeout(stale_wait(), live).await {
297        Ok(Ok(value)) => Json(value).into_response(),
298        // Slow forge, or a panicked fetch: the last known rows, dated, while the fetch runs on.
299        _ => from_cache(hit),
300    }
301}
302
303async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Response {
304    let view = dto::PrView::parse(q.view.as_deref());
305    let hit = cached::pull_requests(&s.deps.cache, view);
306    serve_cached(&s, &format!("prs.{view:?}"), hit, move |s: AppState| async move { dto::pull_requests(&s.deps.sections, &s.deps.cache, view).await }).await
307}
308async fn work_items(State(s): State<AppState>) -> Response {
309    let hit = cached::work_items(&s.deps.cache);
310    serve_cached(&s, "work_items", hit, |s: AppState| async move { dto::work_items(&s.deps.sections, &s.deps.cache).await }).await
311}
312async fn pipelines(State(s): State<AppState>) -> Response {
313    // Read-only against the cache: the launchpad fetch writes the pipelines entry, with the
314    // "yours" flag the list alone cannot tell.
315    let hit = cached::pipelines(&s.deps.cache);
316    serve_cached(&s, "pipelines", hit, |s: AppState| async move { dto::pipelines(&s.deps.sections).await }).await
317}
318async fn notifications(State(s): State<AppState>) -> Response {
319    let hit = cached::notifications(&s.deps.cache);
320    serve_cached(&s, "notifications", hit, |s: AppState| async move { dto::notifications(&s.deps.sections, &s.deps.cache).await }).await
321}
322async fn launchpad(State(s): State<AppState>) -> Response {
323    // Read once, so the cached answer and the live fetch hide the same entries.
324    let dismissed = Arc::new(s.deps.config.snapshot().ui.dismissed_launchpad_items);
325    let hit = cached::launchpad(&s.deps.cache, &dismissed);
326    serve_cached(&s, "launchpad", hit, move |s: AppState| async move { dto::launchpad(&s.deps.sections, &s.deps.cache, &dismissed).await }).await
327}
328
329/// Query params identifying an item within a connection (`?conn=…&id=…&repo=…`).
330///
331/// `repo` is a **query parameter**, never a path segment: an `owner/repo` always contains a
332/// slash. It is optional, so every link written before connections spanned an account still
333/// resolves (a single-repository connection needs no address).
334#[derive(Deserialize)]
335struct ItemQuery {
336    conn: String,
337    id: String,
338    #[serde(default)]
339    repo: Option<String>,
340}
341
342impl ItemQuery {
343    fn item(self) -> (String, ItemRef) {
344        (self.conn, ItemRef::maybe(self.repo, self.id))
345    }
346}
347
348/// Query params identifying a single commit's diff within a PR (`?conn=…&id=…&sha=…`).
349#[derive(Deserialize)]
350struct CommitQuery {
351    conn: String,
352    id: String,
353    sha: String,
354    #[serde(default)]
355    repo: Option<String>,
356}
357
358/// Query params naming one file of a PR (`?conn=…&id=…&path=…`), as of a commit (`&sha=…`) or,
359/// without one, the PR's head.
360#[derive(Deserialize)]
361struct FileTextQuery {
362    conn: String,
363    id: String,
364    path: String,
365    #[serde(default)]
366    sha: Option<String>,
367    #[serde(default)]
368    repo: Option<String>,
369}
370
371/// Query params for the PR list: which view to show (`?view=all|merged|review_requested`).
372#[derive(Deserialize)]
373struct PrListQuery {
374    #[serde(default)]
375    view: Option<String>,
376}
377
378/// Query params identifying a pipeline run within a connection (`?conn=…&run_id=…`).
379#[derive(Deserialize)]
380struct RunQuery {
381    conn: String,
382    run_id: String,
383    #[serde(default)]
384    repo: Option<String>,
385}
386
387/// Query params for pipeline logs: a run, optionally scoped to a single job (`&job=…`).
388#[derive(Deserialize)]
389struct PipelineLogsQuery {
390    conn: String,
391    run_id: String,
392    #[serde(default)]
393    repo: Option<String>,
394    #[serde(default)]
395    job: Option<String>,
396}
397
398/// Turns an action outcome into a response: `{ok:true}`, 404 (no such connection/capability),
399/// or 502 (the provider call failed).
400fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
401    action_response_with(operation, result.map(|()| serde_json::json!({ "ok": true })))
402}
403
404/// Like [`action_response`], for an action that reports something back: `Ok` carries the JSON body.
405fn action_response_with(operation: &'static str, result: Result<serde_json::Value, ActionError>) -> Response {
406    match result {
407        Ok(body) => Json(body).into_response(),
408        Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
409        Err(ActionError::Failed(msg)) => {
410            forgetop_core::diag::log(operation, "provider action failed");
411            (StatusCode::BAD_GATEWAY, msg).into_response()
412        }
413    }
414}
415
416async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
417    match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
418        Some(changes) => Json(changes).into_response(),
419        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
420    }
421}
422
423/// One file's full text, so a diff can expand the unchanged lines between its hunks — plain text,
424/// or a 404 when there's no such connection, or the forge has no text for it (binary, oversized,
425/// missing at that commit, or a provider without a content API).
426async fn pr_file_text(State(s): State<AppState>, Query(q): Query<FileTextQuery>) -> Response {
427    match dto::pr_file_text(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.path, q.sha.as_deref()).await {
428        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
429        None => (StatusCode::NOT_FOUND, "file text not available").into_response(),
430    }
431}
432
433/// Source lines to syntax-highlight, and the path whose extension picks the language.
434#[derive(Deserialize)]
435struct HighlightReq {
436    path: String,
437    lines: Vec<String>,
438}
439
440/// `POST /api/highlight`: the dashboard sends the lines of a diff (or of a file whose gaps it
441/// is expanding) and gets back per-line `[len, kind]` spans — see [`dto::Highlight`]. Pure
442/// CPU over the request body, no provider involved; the same grammars the TUI uses.
443async fn highlight(Json(req): Json<HighlightReq>) -> Json<dto::Highlight> {
444    Json(dto::highlight(&req.path, &req.lines))
445}
446
447async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
448    let (conn, item) = q.item();
449    match dto::pr_detail(&s.deps.sections, &conn, &item).await {
450        Some(detail) => Json(detail).into_response(),
451        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
452    }
453}
454
455/// Query params for one PR's decoration. `updated_at` is the provider's own last-changed stamp
456/// from the list row, and is what makes the cache entry safe to reuse.
457#[derive(Deserialize)]
458struct DecorationQuery {
459    conn: String,
460    id: String,
461    #[serde(default)]
462    repo: Option<String>,
463    #[serde(default)]
464    updated_at: Option<String>,
465}
466
467async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
468    let item = ItemRef::maybe(q.repo, q.id);
469    let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
470    if let Some(hit) = s.decorations.get(&key) {
471        return Json(hit).into_response();
472    }
473    match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
474        Some(decoration) => {
475            s.decorations.put(key, decoration.clone());
476            Json(decoration).into_response()
477        }
478        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
479    }
480}
481
482async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
483    action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
484}
485async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
486    action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
487}
488async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
489    action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
490}
491async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
492    action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
493}
494async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
495    action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
496}
497async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
498    action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
499}
500async fn pr_resolve_thread(State(s): State<AppState>, Json(req): Json<actions::PrResolveThreadReq>) -> Response {
501    action_response("dashboard.pr_resolve_thread", actions::pr_resolve_thread(&s.deps.sections, req).await)
502}
503async fn pr_draft(State(s): State<AppState>, Json(req): Json<actions::PrDraftReq>) -> Response {
504    action_response("dashboard.pr_draft", actions::pr_set_draft(&s.deps.sections, req).await)
505}
506async fn pr_closed(State(s): State<AppState>, Json(req): Json<actions::PrClosedReq>) -> Response {
507    action_response("dashboard.pr_closed", actions::pr_set_closed(&s.deps.sections, req).await)
508}
509async fn pr_reviewers(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
510    let (conn, item) = q.item();
511    match actions::pr_reviewers(&s.deps.sections, &conn, &item).await {
512        Some(users) => Json(users).into_response(),
513        None => (StatusCode::NOT_FOUND, "pull request connection not found").into_response(),
514    }
515}
516async fn pr_request_reviewer(State(s): State<AppState>, Json(req): Json<actions::PrRequestReviewerReq>) -> Response {
517    action_response("dashboard.pr_request_reviewer", actions::pr_request_reviewer(&s.deps.sections, req).await)
518}
519
520async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
521    let (conn, item) = q.item();
522    match dto::wi_detail(&s.deps.sections, &conn, &item).await {
523        Some(detail) => Json(detail).into_response(),
524        None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
525    }
526}
527async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
528    let (conn, item) = q.item();
529    match actions::wi_states(&s.deps.sections, &conn, &item).await {
530        Some(states) => Json(states).into_response(),
531        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
532    }
533}
534async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
535    action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
536}
537async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
538    action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
539}
540async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
541    let (conn, item) = q.item();
542    match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
543        Some(users) => Json(users).into_response(),
544        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
545    }
546}
547async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
548    action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
549}
550async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
551    action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
552}
553
554async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
555    match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
556        Some(detail) => Json(detail).into_response(),
557        None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
558    }
559}
560async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
561    match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
562        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
563        None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
564    }
565}
566
567async fn pipeline_annotations(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
568    match dto::pipeline_annotations(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
569        Some(annotations) => Json(annotations).into_response(),
570        None => (StatusCode::NOT_FOUND, "annotations not available").into_response(),
571    }
572}
573async fn pipeline_artifacts(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
574    match dto::pipeline_artifacts(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
575        Some(artifacts) => Json(artifacts).into_response(),
576        None => (StatusCode::NOT_FOUND, "artifacts not available").into_response(),
577    }
578}
579
580async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
581    action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
582}
583async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
584    action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
585}
586async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
587    action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
588}
589async fn pipeline_rerun(State(s): State<AppState>, Json(req): Json<actions::PipelineRerunReq>) -> Response {
590    let result = actions::pipeline_rerun(&s.deps.sections, req).await;
591    action_response_with("dashboard.pipeline_rerun", result.map(|new_run_id| serde_json::json!({ "ok": true, "new_run_id": new_run_id })))
592}
593
594async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
595    action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
596}
597
598// ---- connection management ----
599
600#[derive(Deserialize)]
601struct IdReq {
602    id: String,
603}
604
605async fn providers() -> Json<Vec<connections::ProviderInfo>> {
606    Json(connections::providers())
607}
608async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
609    Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
610}
611async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
612    match connections::save(&s.deps.config, &s.deps.sections, req).await {
613        Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
614        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
615    }
616}
617async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
618    match connections::remove(&s.deps.config, &req.id).await {
619        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
620        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
621    }
622}
623async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
624    match connections::test(&s.deps.health, &req.id).await {
625        Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
626        None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
627    }
628}
629
630/// The repositories a connection could fetch from — the scope picker's candidate list. Only
631/// this endpoint calls provider discovery, so discovery being wrong shows an empty picker; it
632/// cannot stop an already-scoped connection fetching.
633async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
634    match s.deps.sections.discover_repositories(&q.id).await {
635        Ok(page) => Json(page).into_response(),
636        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
637    }
638}
639
640#[derive(Deserialize)]
641struct IdQuery {
642    id: String,
643}
644
645#[derive(Deserialize)]
646struct ScopeReq {
647    id: String,
648    /// The chosen repositories. An empty list is a real choice — fetch nothing — and is stored
649    /// as such; it is not the same as never having chosen.
650    scope: Vec<String>,
651}
652
653async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
654    match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
655        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
656        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
657    }
658}
659
660/// A pipeline connection's discovered definitions and which of them are fetched — the picker's
661/// candidates. Pipelines are opt-in: nothing selected (`all` false, `selected` empty) fetches
662/// nothing.
663#[derive(serde::Serialize)]
664struct PipelineDefinitionsResp {
665    definitions: Vec<forgetop_core::domain::PipelineDefinition>,
666    all: bool,
667    selected: Vec<String>,
668}
669
670async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
671    let source = match s.deps.sections.pipeline_source_for(&q.id).await {
672        Ok(Some(source)) => source,
673        Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
674        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
675    };
676    let definitions = match source.discover().await {
677        Ok(defs) => defs,
678        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
679    };
680    let cfg = s.deps.config.snapshot();
681    let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
682    Json(PipelineDefinitionsResp {
683        definitions,
684        all: sub.is_some_and(|s| s.auto_discover_all),
685        selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
686    })
687    .into_response()
688}
689
690#[derive(Deserialize)]
691struct PipelineSelectionReq {
692    id: String,
693    /// Every pipeline, including ones created later. Wins over `ids`.
694    #[serde(default)]
695    all: bool,
696    /// The chosen definitions. Empty is a real choice — fetch nothing.
697    #[serde(default)]
698    ids: Vec<String>,
699}
700
701async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
702    let saved = if req.all {
703        s.deps.config.set_pipeline_auto_discover(&req.id, true).await
704    } else {
705        s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
706    };
707    match saved {
708        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
709        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
710    }
711}
712
713#[derive(Deserialize)]
714struct DismissLaunchpadReq {
715    conn: String,
716    /// `"pr"`, `"wi"` or `"pipe"` — the tag of a `launchpad::EntryKind`.
717    kind: String,
718    #[serde(default)]
719    repo: Option<String>,
720    id: String,
721}
722
723/// Permanently dismisses one Command Center item, under the same key the TUI's `D` writes.
724async fn dismiss_launchpad_item(State(s): State<AppState>, Json(req): Json<DismissLaunchpadReq>) -> Response {
725    let kind = match req.kind.as_str() {
726        "pr" => EntryKind::Pr,
727        "wi" => EntryKind::Wi,
728        "pipe" => EntryKind::Pipe,
729        other => return (StatusCode::BAD_REQUEST, format!("unknown launchpad kind `{other}`")).into_response(),
730    };
731    let key = Entry::key(&req.conn, kind, req.repo.as_deref(), &req.id);
732    // Merged into the saved list, not written over it: the TUI adds to the same list.
733    match s.deps.config.dismiss_launchpad_item(key).await {
734        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
735        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
736    }
737}
738
739/// View preferences the dashboard shares with the TUI.
740async fn prefs(State(s): State<AppState>) -> Response {
741    let ui = s.deps.config.snapshot().ui;
742    Json(serde_json::json!({ "hidden_work_item_states": ui.hidden_work_item_states })).into_response()
743}
744
745#[derive(Deserialize)]
746struct HiddenWorkItemStateReq {
747    state: String,
748    hidden: bool,
749}
750
751/// Hides or shows one work-item state. One state at a time, merged into the saved list, so the
752/// dashboard never writes over what the TUI's "Show states" picker has set meanwhile.
753async fn set_hidden_work_item_state(State(s): State<AppState>, Json(req): Json<HiddenWorkItemStateReq>) -> Response {
754    match s.deps.config.set_hidden_work_item_state(&req.state, req.hidden).await {
755        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
756        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    //! The optional pull-request writes (resolve a thread, draft, close/reopen, request a
763    //! reviewer) end to end through the HTTP API, against the in-memory demo provider — the same
764    //! harness `tests/api.rs` uses for the other routes.
765
766    use super::*;
767    use forgetop_core::config::{ConfigStore, ForgetopConfig, InMemoryConfigStore};
768    use forgetop_core::domain::ProviderType;
769    use forgetop_core::provider::{Connection, ProviderRegistry};
770    use forgetop_core::secret::InMemorySecretStore;
771    use forgetop_core::service::ConnectionResolver;
772    use forgetop_providers::demo::demo_factories;
773
774    /// One demo GitHub connection bound to the PR section — the shape `forgetop --demo` wires.
775    async fn demo_deps() -> Deps {
776        deps_with(Arc::new(CacheStore::disabled())).await
777    }
778
779    async fn deps_with(cache: Arc<CacheStore>) -> Deps {
780        let registry = Arc::new(ProviderRegistry::new(demo_factories()));
781        let store: Arc<dyn ConfigStore> = Arc::new(InMemoryConfigStore::new(ForgetopConfig::default()));
782        let secrets: Arc<dyn SecretStore> = Arc::new(InMemorySecretStore::default());
783        let config = Arc::new(ConfigService::new(store, secrets.clone(), registry.clone()));
784        config.load().await.unwrap();
785        let conn = Connection {
786            id: "github".into(),
787            provider_type: ProviderType::GitHub,
788            display_name: "github".into(),
789            base_url: None,
790            organization: None,
791            project: None,
792            repository: None,
793            username: None,
794            credential_ref: None,
795            repo_scope: None,
796        };
797        config.add_or_update_connection(conn, None).await.unwrap();
798        config.bind_pull_requests("github").await.unwrap();
799        config.bind_work_items("github").await.unwrap();
800        config.set_pipeline_auto_discover("github", true).await.unwrap();
801        let resolver = Arc::new(ConnectionResolver::new(config.clone(), registry, secrets.clone()));
802        let sections = Arc::new(SectionService::new(config.clone(), resolver.clone()));
803        let health = Arc::new(ConnectionHealthService::new(config.clone(), resolver));
804        Deps { sections, health, config, secrets, cache }
805    }
806
807    struct Api {
808        base: String,
809        token: String,
810        client: reqwest::Client,
811    }
812
813    impl Api {
814        async fn start() -> Api {
815            let server = spawn(demo_deps().await, 0).await.expect("server binds a free port");
816            Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
817        }
818
819        async fn start_with(deps: Deps) -> Api {
820            let server = spawn(deps, 0).await.expect("server binds a free port");
821            Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
822        }
823
824        async fn get_raw(&self, path: &str) -> reqwest::Response {
825            self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.expect("request")
826        }
827        async fn get(&self, path: &str) -> serde_json::Value {
828            let r = self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.unwrap();
829            assert_eq!(r.status(), 200, "GET {path}");
830            r.json().await.unwrap()
831        }
832        async fn post(&self, path: &str, body: serde_json::Value) -> u16 {
833            let r = self.client.post(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).json(&body).send().await.unwrap();
834            r.status().as_u16()
835        }
836        /// The open PRs the list shows, as `(id, status)`.
837        async fn open_prs(&self) -> Vec<(String, String)> {
838            let rows = self.get("/api/pull-requests").await;
839            rows.as_array()
840                .unwrap()
841                .iter()
842                .map(|r| (r["pull_request"]["id"].as_str().unwrap().to_string(), r["pull_request"]["status"].as_str().unwrap().to_string()))
843                .collect()
844        }
845        async fn detail(&self, id: &str) -> serde_json::Value {
846            self.get(&format!("/api/pr/detail?conn=github&id={id}")).await
847        }
848    }
849
850    /// The demo PRs are global to the process, so each test picks its own open PR to write to.
851    /// The GitHub connection has exactly four open ones, and `draft_and_close` takes one out of
852    /// the open list for a moment, so the PR tests run one at a time: a listing must never land
853    /// inside that window.
854    static DEMO_PRS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
855
856    async fn open_pr(api: &Api, nth: usize) -> String {
857        let open: Vec<_> = api.open_prs().await.into_iter().filter(|(_, s)| s == "Open").collect();
858        open.get(nth).unwrap_or_else(|| panic!("demo has at least {} open PRs", nth + 1)).0.clone()
859    }
860
861    #[tokio::test]
862    async fn pr_detail_advertises_the_demo_writes() {
863        let _one_at_a_time = DEMO_PRS.lock().await;
864        let api = Api::start().await;
865        let id = open_pr(&api, 0).await;
866        let writes = &api.detail(&id).await["writes"];
867        for k in ["resolve_threads", "draft", "close", "reopen", "request_reviewer"] {
868            assert_eq!(writes[k], true, "demo supports {k}: {writes}");
869        }
870    }
871
872    #[tokio::test]
873    async fn resolving_a_thread_flips_it_on_the_next_detail() {
874        let _one_at_a_time = DEMO_PRS.lock().await;
875        let api = Api::start().await;
876        let id = open_pr(&api, 1).await;
877        let thread = |d: &serde_json::Value, t: &str| d["threads"].as_array().unwrap().iter().find(|x| x["id"] == t).cloned().unwrap();
878        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "t1 starts open");
879
880        let body = |resolved: bool| serde_json::json!({ "conn": "github", "id": id, "thread_id": "t1", "resolved": resolved });
881        assert_eq!(api.post("/api/pr/resolve-thread", body(true)).await, 200);
882        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], true, "resolve persists");
883        assert_eq!(api.post("/api/pr/resolve-thread", body(false)).await, 200);
884        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "reopen persists");
885    }
886
887    #[tokio::test]
888    async fn draft_and_close_change_the_pr_status() {
889        let _one_at_a_time = DEMO_PRS.lock().await;
890        let api = Api::start().await;
891        let id = open_pr(&api, 2).await;
892        let status = |d: serde_json::Value| d["pull_request"]["status"].as_str().unwrap().to_string();
893
894        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": true })).await, 200);
895        assert_eq!(status(api.detail(&id).await), "Draft", "converted to a draft");
896        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": false })).await, 200);
897        assert_eq!(status(api.detail(&id).await), "Open", "ready for review again");
898
899        assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": true })).await, 200);
900        assert_eq!(status(api.detail(&id).await), "Closed");
901        assert!(!api.open_prs().await.iter().any(|(p, _)| *p == id), "a closed PR leaves the open list");
902        assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": false })).await, 200);
903        assert_eq!(status(api.detail(&id).await), "Open", "reopened");
904        assert!(api.open_prs().await.iter().any(|(p, _)| *p == id), "and back on the open list");
905
906        // A missing connection is a 404, as for every other action.
907        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "nope", "id": id, "draft": true })).await, 404);
908    }
909
910    #[tokio::test]
911    async fn requesting_a_reviewer_adds_them_to_the_pr() {
912        let _one_at_a_time = DEMO_PRS.lock().await;
913        let api = Api::start().await;
914        let id = open_pr(&api, 3).await;
915        let users = api.get(&format!("/api/pr/reviewers?conn=github&id={id}")).await;
916        let users = users.as_array().expect("reviewers is a JSON array of users");
917        assert!(!users.is_empty(), "the demo has someone to ask");
918
919        let reviewer_ids = |d: &serde_json::Value| -> Vec<String> {
920            d["pull_request"]["reviewers"].as_array().unwrap().iter().map(|r| r["user"]["id"].as_str().unwrap().to_string()).collect()
921        };
922        let before = reviewer_ids(&api.detail(&id).await);
923        let pick = users.iter().map(|u| u["id"].as_str().unwrap().to_string()).find(|u| !before.contains(u)).expect("someone not yet reviewing");
924
925        assert_eq!(api.post("/api/pr/request-reviewer", serde_json::json!({ "conn": "github", "id": id, "user_id": pick })).await, 200);
926        let after = api.detail(&id).await;
927        let added = after["pull_request"]["reviewers"].as_array().unwrap().iter().find(|r| r["user"]["id"] == pick.as_str()).cloned();
928        let added = added.expect("the requested reviewer is on the PR");
929        assert_eq!(added["vote"], "NoVote", "requested, not yet voted");
930
931        let missing = api.client.get(format!("{}/api/pr/reviewers?conn=nope&id={id}", api.base)).header("x-forgetop-token", &api.token).send().await.unwrap();
932        assert_eq!(missing.status(), 404, "a bad connection is a 404");
933    }
934
935    fn scratch_cache() -> (Arc<CacheStore>, std::path::PathBuf) {
936        let path = std::env::temp_dir().join(format!("forgetop-api-cache-{}.json", uuid::Uuid::new_v4()));
937        (Arc::new(CacheStore::new(&path)), path)
938    }
939
940    /// A list answers from the shared cache at once, says so in a header, and the live fetch
941    /// behind it replaces the entry.
942    #[tokio::test]
943    async fn lists_answer_from_the_shared_cache_then_refresh_behind_it() {
944        // Demo data answers in microseconds, so to see the cached path the wait is turned off.
945        std::env::set_var("FORGETOP_CACHE_WAIT_MS", "0");
946        let (cache, path) = scratch_cache();
947        let api = Api::start_with(deps_with(cache.clone()).await).await;
948
949        // Cold: the live fetch answers and writes the TUI's entry.
950        let cold = api.get_raw("/api/work-items").await;
951        assert!(cold.headers().get(FETCHED_AT_HEADER).is_none(), "a live answer carries no cache date");
952        let live: Vec<serde_json::Value> = cold.json().await.unwrap();
953        assert!(!live.is_empty());
954        assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some(), "the live fetch wrote the TUI's key");
955
956        // Make the cached entry recognisably stale, as a TUI that ran earlier would have left it.
957        assert!(cache.rewrite::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS, |mut rows| {
958            rows[0].wi.title = "Left here by the terminal".into();
959            rows
960        }));
961        let warm = api.get_raw("/api/work-items").await;
962        let fetched_at = warm.headers().get(FETCHED_AT_HEADER).expect("a cached answer says when it was fetched").clone();
963        assert!(chrono::DateTime::parse_from_rfc3339(fetched_at.to_str().unwrap()).is_ok());
964        let rows: Vec<serde_json::Value> = warm.json().await.unwrap();
965        assert_eq!(rows[0]["work_item"]["title"], "Left here by the terminal");
966
967        // The fetch that ran behind that answer has replaced the entry by now (demo data is instant).
968        let mut fresh = Vec::new();
969        for _ in 0..50 {
970            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
971            fresh = api.get_raw("/api/work-items").await.json::<Vec<serde_json::Value>>().await.unwrap();
972            if fresh[0]["work_item"]["title"] != "Left here by the terminal" {
973                break;
974            }
975        }
976        assert_ne!(fresh[0]["work_item"]["title"], "Left here by the terminal", "the live fetch wrote back");
977        std::env::remove_var("FORGETOP_CACHE_WAIT_MS");
978        // And the flusher put it on disk for the next process.
979        tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
980        assert!(path.exists(), "the cache was flushed to {}", path.display());
981        let _ = std::fs::remove_file(path);
982    }
983
984    /// A dismissal hides the entry on the cached path too, by the current key or the legacy one.
985    #[tokio::test]
986    async fn the_cached_launchpad_drops_dismissed_entries() {
987        let (cache, path) = scratch_cache();
988        let api = Api::start_with(deps_with(cache.clone()).await).await;
989        let _ = api.get_raw("/api/launchpad").await;
990        let all = cached::launchpad(&cache, &[]).expect("launchpad from cache").value;
991        let first = serde_json::to_value(&all.rows[0]).unwrap();
992        let (conn, id) = (first["connection_id"].as_str().unwrap().to_string(), first[match first["kind"].as_str().unwrap() {
993            "pr" => "pull_request",
994            "wi" => "work_item",
995            _ => "run",
996        }]["id"]
997            .as_str()
998            .unwrap()
999            .to_string());
1000        let legacy = cached::launchpad(&cache, &[format!("{conn}:{id}")]).expect("launchpad from cache").value;
1001        assert!(legacy.rows.len() < all.rows.len(), "the legacy key hides the entry");
1002        // The current key (kind and repository included) hides it too.
1003        let kind = first["kind"].as_str().unwrap();
1004        let item = &first[match kind {
1005            "pr" => "pull_request",
1006            "wi" => "work_item",
1007            _ => "run",
1008        }];
1009        let repo = item["repository"].as_str().unwrap_or("");
1010        let current = cached::launchpad(&cache, &[format!("{conn}:{kind}:{repo}:{id}")]).expect("launchpad from cache").value;
1011        assert_eq!(current.rows.len(), legacy.rows.len(), "the current key hides the same entry");
1012        // A key that matches nothing hides nothing.
1013        let other = cached::launchpad(&cache, &["nope:pr::0".to_string()]).expect("launchpad from cache").value;
1014        assert_eq!(other.rows.len(), all.rows.len());
1015        let _ = std::fs::remove_file(path);
1016    }
1017
1018    /// Every list the dashboard fetches lands in the cache under the key the TUI reads at launch.
1019    #[tokio::test]
1020    async fn live_fetches_write_the_terminal_ui_keys() {
1021        let (cache, path) = scratch_cache();
1022        let api = Api::start_with(deps_with(cache.clone()).await).await;
1023        let _ = api.get_raw("/api/pull-requests?view=all").await;
1024        let _ = api.get_raw("/api/pull-requests?view=review_requested").await;
1025        let _ = api.get_raw("/api/launchpad").await;
1026        let _ = api.get_raw("/api/notifications").await;
1027        let all = cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::All, false)).expect("All view");
1028        assert!(!all.value.is_empty());
1029        assert!(cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::ReviewRequested, false)).is_some());
1030        assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_MINE).is_some());
1031        assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_REVIEW).is_some());
1032        assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some());
1033        assert!(cache.get::<Vec<cached::TuiPipeRow>>(cached::KEY_PIPELINES).is_some());
1034        assert!(cache.get::<Vec<dto::NotifRow>>(cached::KEY_INBOX).is_some());
1035        // The Command Center rebuilt from those entries is the one the live build produced.
1036        let live: serde_json::Value = api.get_raw("/api/launchpad").await.json().await.unwrap();
1037        let from_cache = cached::launchpad(&cache, &[]).expect("launchpad from cache");
1038        assert_eq!(serde_json::to_value(&from_cache.value).unwrap(), live);
1039        let _ = std::fs::remove_file(path);
1040    }
1041}