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::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/// The built dashboard SPA, baked into the binary at compile time (see `build.rs`).
34#[derive(RustEmbed)]
35#[folder = "web/dist"]
36struct Assets;
37
38/// Default dashboard port. `0` lets the OS pick a free one.
39pub const DEFAULT_PORT: u16 = 8177;
40
41/// The services the dashboard reads from and writes through — the same ones the TUI is given,
42/// so connections added here show up in the TUI (and vice versa).
43#[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    /// The on-disk cache the TUI paints from at launch, shared so the dashboard does too: lists
50    /// answer from it first and the live fetch writes back (see `cached`). Pass a disabled store
51    /// to cache nothing (`--demo`).
52    pub cache: Arc<CacheStore>,
53}
54
55/// A bound, running server: where it lives and the token needed to reach it.
56pub struct Server {
57    pub port: u16,
58    pub token: String,
59    /// `http://127.0.0.1:<port>/?t=<token>` — the URL to open in a browser.
60    pub url: String,
61}
62
63/// Decorated PR fields, cached per `(connection, repository, id, updated_at)`.
64///
65/// `updated_at` is part of the key on purpose: it is the provider's own statement that the PR
66/// changed, so a stale entry can never outlive the change that invalidates it.
67#[derive(Default)]
68struct DecorationCache {
69    entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
70}
71
72impl DecorationCache {
73    /// Bounded so a long-running dashboard can't grow it without limit. On overflow the whole
74    /// map is dropped rather than evicted one by one — decoration is cheap to refetch.
75    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/// Notification details (actor, excerpt) fetched on demand, per `(connection, id, detail_ref)` —
95/// so reopening a notification costs no call (the GitHub budget), while new activity on the same
96/// thread (a new latest comment, so a new `detail_ref`) is fetched afresh.
97#[derive(Default)]
98struct NotifDetailCache {
99    entries: std::sync::Mutex<std::collections::HashMap<String, NotificationDetail>>,
100}
101
102impl NotifDetailCache {
103    /// Bounded like [`DecorationCache`], and dropped whole on overflow for the same reason.
104    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    /// Lists with a live fetch out right now, by key: a poll that lands meanwhile answers from
130    /// the cache rather than starting another.
131    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
152/// Bind + serve in the background, returning the URL (with token) for the caller to open.
153/// **Best-effort:** an `Err` just means "no dashboard" — the TUI should carry on regardless.
154pub 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
162/// Bind + serve until the process exits (headless `--dashboard`). Calls `on_ready` with the
163/// URL once bound so the caller can open the browser.
164pub 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    // The API carries your data and can act on your behalf, so it's token-gated. The static
172    // SPA (HTML/JS/CSS) is just code — not secret — so it's served openly; the browser gets
173    // the token from the `/?t=` URL and replays it on every API call.
174    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
234/// Serves an embedded SPA asset by path, falling back to `index.html` for unknown routes so
235/// client-side routing works on refresh/deep-link.
236async 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
250/// Session-token gate. The browser opens `/?t=<token>`; other calls may send it as the
251/// `x-forgetop-token` header. Combined with localhost-only binding, this keeps the
252/// (action-capable) API off-limits to other local processes and web pages.
253async 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}
273/// The response header naming when a cached answer's data was fetched. Absent on a live answer.
274pub const FETCHED_AT_HEADER: &str = "x-forgetop-fetched-at";
275
276/// How long a list request waits for its live fetch before answering from the shared cache
277/// instead (the fetch carries on behind the answer and writes back). Long enough that a quick
278/// forge still answers live on every poll, short enough that a slow one never leaves the page on
279/// skeletons. `FORGETOP_CACHE_WAIT_MS` overrides it; 0 always answers from the cache when it can.
280const 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
290/// Answers a list live when the forge is quick, and from the shared cache when it is not: the
291/// live fetch starts at once, the request waits `stale_wait()` for it, and past that the cached
292/// rows go out (dated by the header) while the fetch finishes behind them and writes back. With
293/// nothing cached the request waits for the fetch however long it takes. One live fetch per key
294/// at a time: a poll that lands while one is out answers from the cache.
295async 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        // A fetch is already out for this list; it lands shortly and the next poll sees it.
311        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        // Slow forge, or a panicked fetch: the last known rows, dated, while the fetch runs on.
335        _ => 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    // Read-only against the cache: the launchpad fetch writes the pipelines entry, with the
350    // "yours" flag the list alone cannot tell.
351    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}
354/// The notifications either frontend marked done (`ui.dismissed_notifications`), read once per
355/// request so the cached answer and the live fetch hide the same rows.
356fn dismissed_notifications(s: &AppState) -> Arc<std::collections::HashSet<String>> {
357    Arc::new(s.deps.config.snapshot().ui.dismissed_notifications.into_iter().collect())
358}
359
360/// What the inbox shows of `rows`: dismissed ones dropped, and any detail already fetched for a
361/// row (`/api/notification/detail`) filled in, so its phrase names the actor from then on.
362fn 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
378/// Both inbox endpoints share one live fetch: while one is out, the other answers from the cache.
379const 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
392/// The notifications folded into one row per item (`forgetop_core::inbox::threads`), over the
393/// same rows `/api/notifications` serves.
394async 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    // Read once, so the cached answer and the live fetch hide the same entries.
408    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/// Query params identifying an item within a connection (`?conn=…&id=…&repo=…`).
414///
415/// `repo` is a **query parameter**, never a path segment: an `owner/repo` always contains a
416/// slash. It is optional, so every link written before connections spanned an account still
417/// resolves (a single-repository connection needs no address).
418#[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/// Query params identifying a single commit's diff within a PR (`?conn=…&id=…&sha=…`).
433#[derive(Deserialize)]
434struct CommitQuery {
435    conn: String,
436    id: String,
437    sha: String,
438    #[serde(default)]
439    repo: Option<String>,
440}
441
442/// Query params naming one file of a PR (`?conn=…&id=…&path=…`), as of a commit (`&sha=…`) or,
443/// without one, the PR's head.
444#[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/// Query params for the PR list: which view to show (`?view=all|merged|review_requested`).
456#[derive(Deserialize)]
457struct PrListQuery {
458    #[serde(default)]
459    view: Option<String>,
460}
461
462/// Query params identifying a pipeline run within a connection (`?conn=…&run_id=…`).
463#[derive(Deserialize)]
464struct RunQuery {
465    conn: String,
466    run_id: String,
467    #[serde(default)]
468    repo: Option<String>,
469}
470
471/// Query params for pipeline logs: a run, optionally scoped to a single job (`&job=…`).
472#[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
482/// Turns an action outcome into a response: `{ok:true}`, 404 (no such connection/capability),
483/// or 502 (the provider call failed).
484fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
485    action_response_with(operation, result.map(|()| serde_json::json!({ "ok": true })))
486}
487
488/// Like [`action_response`], for an action that reports something back: `Ok` carries the JSON body.
489fn 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
507/// One file's full text, so a diff can expand the unchanged lines between its hunks — plain text,
508/// or a 404 when there's no such connection, or the forge has no text for it (binary, oversized,
509/// missing at that commit, or a provider without a content API).
510async 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/// Source lines to syntax-highlight, and the path whose extension picks the language.
518#[derive(Deserialize)]
519struct HighlightReq {
520    path: String,
521    lines: Vec<String>,
522}
523
524/// `POST /api/highlight`: the dashboard sends the lines of a diff (or of a file whose gaps it
525/// is expanding) and gets back per-line `[len, kind]` spans — see [`dto::Highlight`]. Pure
526/// CPU over the request body, no provider involved; the same grammars the TUI uses.
527async 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/// Query params for one PR's decoration. `updated_at` is the provider's own last-changed stamp
540/// from the list row, and is what makes the cache entry safe to reuse.
541#[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
682/// The notifications an action resolves `ids` against — every row, dismissed ones included. The
683/// shared cache answers when it knows every id (the inbox was just polled); otherwise one live
684/// fetch, which writes the cache back like any other.
685async 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
702/// "Done" on an inbox thread: read at the forge, then dismissed under the TUI's key.
703async 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
714/// The actor and excerpt of one notification, fetched once per `(connection, id, detail_ref)` and
715/// kept for the session. A notification whose feed entry already carries them answers with those,
716/// without a call.
717async 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        // Nothing to fetch: the feed already said all it can.
724        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                    // Remembered as "nothing more", so a deleted comment isn't asked for again on
736                    // every open: each ask is a call against the hourly budget.
737                    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    // What the feed carried wins over a fetched guess; the phrase and why follow the filled row.
745    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// ---- connection management ----
758
759#[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
789/// The repositories a connection could fetch from — the scope picker's candidate list. Only
790/// this endpoint calls provider discovery, so discovery being wrong shows an empty picker; it
791/// cannot stop an already-scoped connection fetching.
792async 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    /// The chosen repositories. An empty list is a real choice — fetch nothing — and is stored
808    /// as such; it is not the same as never having chosen.
809    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/// A pipeline connection's discovered definitions and which of them are fetched — the picker's
820/// candidates. Pipelines are opt-in: nothing selected (`all` false, `selected` empty) fetches
821/// nothing.
822#[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    /// Every pipeline, including ones created later. Wins over `ids`.
853    #[serde(default)]
854    all: bool,
855    /// The chosen definitions. Empty is a real choice — fetch nothing.
856    #[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    /// `"pr"`, `"wi"` or `"pipe"` — the tag of a `launchpad::EntryKind`.
876    kind: String,
877    #[serde(default)]
878    repo: Option<String>,
879    id: String,
880}
881
882/// Permanently dismisses one Command Center item, under the same key the TUI's `D` writes.
883async 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    // Merged into the saved list, not written over it: the TUI adds to the same list.
892    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
898/// View preferences the dashboard shares with the TUI.
899async 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
910/// Hides or shows one work-item state. One state at a time, merged into the saved list, so the
911/// dashboard never writes over what the TUI's "Show states" picker has set meanwhile.
912async 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    //! The optional pull-request writes (resolve a thread, draft, close/reopen, request a
922    //! reviewer) end to end through the HTTP API, against the in-memory demo provider — the same
923    //! harness `tests/api.rs` uses for the other routes.
924
925    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    /// One demo GitHub connection bound to the PR section — the shape `forgetop --demo` wires.
934    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        /// The open PRs the list shows, as `(id, status)`.
996        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    /// The demo PRs are global to the process, so each test picks its own open PR to write to.
1010    /// The GitHub connection has exactly four open ones, and `draft_and_close` takes one out of
1011    /// the open list for a moment, so the PR tests run one at a time: a listing must never land
1012    /// inside that window.
1013    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        // A missing connection is a 404, as for every other action.
1066        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    /// A list answers from the shared cache at once, says so in a header, and the live fetch
1100    /// behind it replaces the entry.
1101    #[tokio::test]
1102    async fn lists_answer_from_the_shared_cache_then_refresh_behind_it() {
1103        // Demo data answers in microseconds, so to see the cached path the wait is turned off.
1104        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        // Cold: the live fetch answers and writes the TUI's entry.
1109        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        // Make the cached entry recognisably stale, as a TUI that ran earlier would have left it.
1116        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        // The fetch that ran behind that answer has replaced the entry by now (demo data is instant).
1127        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        // And the flusher put it on disk for the next process.
1138        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    /// A dismissal hides the entry on the cached path too, by the current key or the legacy one.
1144    #[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        // The current key (kind and repository included) hides it too.
1162        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        // A key that matches nothing hides nothing.
1172        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    /// Every list the dashboard fetches lands in the cache under the key the TUI reads at launch.
1178    #[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        // The Command Center rebuilt from those entries is the one the live build produced.
1195        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}