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::provider::{ItemRef, PrDecoration};
17use forgetop_core::secret::SecretStore;
18use forgetop_core::service::{ConfigService, ConnectionHealthService, SectionService};
19use rust_embed::RustEmbed;
20use serde::Deserialize;
21
22use crate::actions::ActionError;
23
24mod actions;
25mod connections;
26mod dto;
27
28/// The built dashboard SPA, baked into the binary at compile time (see `build.rs`).
29#[derive(RustEmbed)]
30#[folder = "web/dist"]
31struct Assets;
32
33/// Default dashboard port. `0` lets the OS pick a free one.
34pub const DEFAULT_PORT: u16 = 8177;
35
36/// The services the dashboard reads from and writes through — the same ones the TUI is given,
37/// so connections added here show up in the TUI (and vice versa).
38#[derive(Clone)]
39pub struct Deps {
40    pub sections: Arc<SectionService>,
41    pub health: Arc<ConnectionHealthService>,
42    pub config: Arc<ConfigService>,
43    pub secrets: Arc<dyn SecretStore>,
44}
45
46/// A bound, running server: where it lives and the token needed to reach it.
47pub struct Server {
48    pub port: u16,
49    pub token: String,
50    /// `http://127.0.0.1:<port>/?t=<token>` — the URL to open in a browser.
51    pub url: String,
52}
53
54/// Decorated PR fields, cached per `(connection, repository, id, updated_at)`.
55///
56/// `updated_at` is part of the key on purpose: it is the provider's own statement that the PR
57/// changed, so a stale entry can never outlive the change that invalidates it.
58#[derive(Default)]
59struct DecorationCache {
60    entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
61}
62
63impl DecorationCache {
64    /// Bounded so a long-running dashboard can't grow it without limit. On overflow the whole
65    /// map is dropped rather than evicted one by one — decoration is cheap to refetch.
66    const MAX: usize = 500;
67
68    fn key(conn: &str, item: &ItemRef, updated_at: Option<&str>) -> String {
69        format!("{conn}\u{1}{}\u{1}{}\u{1}{}", item.repo.as_deref().unwrap_or(""), item.id, updated_at.unwrap_or(""))
70    }
71
72    fn get(&self, key: &str) -> Option<PrDecoration> {
73        self.entries.lock().unwrap().get(key).cloned()
74    }
75
76    fn put(&self, key: String, value: PrDecoration) {
77        let mut entries = self.entries.lock().unwrap();
78        if entries.len() >= Self::MAX {
79            entries.clear();
80        }
81        entries.insert(key, value);
82    }
83}
84
85#[derive(Clone)]
86struct AppState {
87    deps: Deps,
88    token: Arc<str>,
89    decorations: Arc<DecorationCache>,
90}
91
92async fn bind(deps: Deps, port: u16) -> std::io::Result<(tokio::net::TcpListener, Server, AppState)> {
93    let token = uuid::Uuid::new_v4().to_string();
94    let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await?;
95    let bound = listener.local_addr()?.port();
96    let url = format!("http://127.0.0.1:{bound}/?t={token}");
97    let state = AppState { deps, token: Arc::from(token.as_str()), decorations: Arc::new(DecorationCache::default()) };
98    Ok((listener, Server { port: bound, token, url }, state))
99}
100
101/// Bind + serve in the background, returning the URL (with token) for the caller to open.
102/// **Best-effort:** an `Err` just means "no dashboard" — the TUI should carry on regardless.
103pub async fn spawn(deps: Deps, port: u16) -> std::io::Result<Server> {
104    let (listener, server, state) = bind(deps, port).await?;
105    tokio::spawn(async move {
106        let _ = axum::serve(listener, router(state)).await;
107    });
108    Ok(server)
109}
110
111/// Bind + serve until the process exits (headless `--dashboard`). Calls `on_ready` with the
112/// URL once bound so the caller can open the browser.
113pub async fn serve_blocking(deps: Deps, port: u16, on_ready: impl FnOnce(&str)) -> std::io::Result<()> {
114    let (listener, server, state) = bind(deps, port).await?;
115    on_ready(&server.url);
116    axum::serve(listener, router(state)).await
117}
118
119fn router(state: AppState) -> Router {
120    // The API carries your data and can act on your behalf, so it's token-gated. The static
121    // SPA (HTML/JS/CSS) is just code — not secret — so it's served openly; the browser gets
122    // the token from the `/?t=` URL and replays it on every API call.
123    let api = Router::new()
124        .route("/api/health", get(health))
125        .route("/api/pull-requests", get(pull_requests))
126        .route("/api/work-items", get(work_items))
127        .route("/api/pipelines", get(pipelines))
128        .route("/api/notifications", get(notifications))
129        .route("/api/launchpad", get(launchpad))
130        .route("/api/pr/detail", get(pr_detail))
131        .route("/api/pr/decoration", get(pr_decoration))
132        .route("/api/pr/commit-changes", get(pr_commit_changes))
133        .route("/api/pr/vote", post(pr_vote))
134        .route("/api/pr/merge", post(pr_merge))
135        .route("/api/pr/revert", post(pr_revert))
136        .route("/api/pr/comment", post(pr_comment))
137        .route("/api/pr/reply", post(pr_reply))
138        .route("/api/pr/review", post(pr_review))
139        .route("/api/wi/detail", get(wi_detail))
140        .route("/api/wi/states", get(wi_states))
141        .route("/api/wi/state", post(wi_state))
142        .route("/api/wi/comment", post(wi_comment))
143        .route("/api/wi/assignees", get(wi_assignees))
144        .route("/api/wi/assignee", post(wi_assignee))
145        .route("/api/wi/update", post(wi_update))
146        .route("/api/pipeline/detail", get(pipeline_detail))
147        .route("/api/pipeline/logs", get(pipeline_logs))
148        .route("/api/pipeline/approval", post(pipeline_approval))
149        .route("/api/pipeline/trigger", post(pipeline_trigger))
150        .route("/api/pipeline/cancel", post(pipeline_cancel))
151        .route("/api/notification/read", post(notification_read))
152        .route("/api/providers", get(providers))
153        .route("/api/connections", get(list_connections).post(save_connection))
154        .route("/api/connections/delete", post(delete_connection))
155        .route("/api/connections/test", post(test_connection))
156        .route("/api/connections/repositories", get(connection_repositories))
157        .route("/api/connections/scope", post(set_connection_scope))
158        .route("/api/pipelines/definitions", get(pipeline_definitions))
159        .route("/api/pipelines/selection", post(set_pipeline_selection))
160        .layer(middleware::from_fn_with_state(state.clone(), auth))
161        .with_state(state);
162
163    Router::new().merge(api).fallback(static_asset)
164}
165
166/// Serves an embedded SPA asset by path, falling back to `index.html` for unknown routes so
167/// client-side routing works on refresh/deep-link.
168async fn static_asset(uri: Uri) -> Response {
169    let path = uri.path().trim_start_matches('/');
170    let path = if path.is_empty() { "index.html" } else { path };
171    serve(path)
172        .or_else(|| serve("index.html"))
173        .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
174}
175
176fn serve(path: &str) -> Option<Response> {
177    let file = Assets::get(path)?;
178    let mime = mime_guess::from_path(path).first_or_octet_stream();
179    Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
180}
181
182/// Session-token gate. The browser opens `/?t=<token>`; other calls may send it as the
183/// `x-forgetop-token` header. Combined with localhost-only binding, this keeps the
184/// (action-capable) API off-limits to other local processes and web pages.
185async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
186    if token_from(&req).as_deref() == Some(&state.token) {
187        next.run(req).await
188    } else {
189        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
190    }
191}
192
193fn token_from(req: &Request) -> Option<String> {
194    if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
195        return Some(header.to_string());
196    }
197    req.uri()
198        .query()
199        .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
200}
201
202async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
203    Json(dto::health(&s.deps.health).await)
204}
205async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
206    Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
207}
208async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
209    Json(dto::work_items(&s.deps.sections).await)
210}
211async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
212    Json(dto::pipelines(&s.deps.sections).await)
213}
214async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
215    Json(dto::notifications(&s.deps.sections).await)
216}
217async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
218    Json(dto::launchpad(&s.deps.sections).await)
219}
220
221/// Query params identifying an item within a connection (`?conn=…&id=…&repo=…`).
222///
223/// `repo` is a **query parameter**, never a path segment: an `owner/repo` always contains a
224/// slash. It is optional, so every link written before connections spanned an account still
225/// resolves (a single-repository connection needs no address).
226#[derive(Deserialize)]
227struct ItemQuery {
228    conn: String,
229    id: String,
230    #[serde(default)]
231    repo: Option<String>,
232}
233
234impl ItemQuery {
235    fn item(self) -> (String, ItemRef) {
236        (self.conn, ItemRef::maybe(self.repo, self.id))
237    }
238}
239
240/// Query params identifying a single commit's diff within a PR (`?conn=…&id=…&sha=…`).
241#[derive(Deserialize)]
242struct CommitQuery {
243    conn: String,
244    id: String,
245    sha: String,
246    #[serde(default)]
247    repo: Option<String>,
248}
249
250/// Query params for the PR list: which view to show (`?view=all|merged|review_requested`).
251#[derive(Deserialize)]
252struct PrListQuery {
253    #[serde(default)]
254    view: Option<String>,
255}
256
257/// Query params identifying a pipeline run within a connection (`?conn=…&run_id=…`).
258#[derive(Deserialize)]
259struct RunQuery {
260    conn: String,
261    run_id: String,
262    #[serde(default)]
263    repo: Option<String>,
264}
265
266/// Query params for pipeline logs: a run, optionally scoped to a single job (`&job=…`).
267#[derive(Deserialize)]
268struct PipelineLogsQuery {
269    conn: String,
270    run_id: String,
271    #[serde(default)]
272    repo: Option<String>,
273    #[serde(default)]
274    job: Option<String>,
275}
276
277/// Turns an action outcome into a response: `{ok:true}`, 404 (no such connection/capability),
278/// or 502 (the provider call failed).
279fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
280    match result {
281        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
282        Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
283        Err(ActionError::Failed(msg)) => {
284            forgetop_core::diag::log(operation, "provider action failed");
285            (StatusCode::BAD_GATEWAY, msg).into_response()
286        }
287    }
288}
289
290async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
291    match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
292        Some(changes) => Json(changes).into_response(),
293        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
294    }
295}
296
297async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
298    let (conn, item) = q.item();
299    match dto::pr_detail(&s.deps.sections, &conn, &item).await {
300        Some(detail) => Json(detail).into_response(),
301        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
302    }
303}
304
305/// Query params for one PR's decoration. `updated_at` is the provider's own last-changed stamp
306/// from the list row, and is what makes the cache entry safe to reuse.
307#[derive(Deserialize)]
308struct DecorationQuery {
309    conn: String,
310    id: String,
311    #[serde(default)]
312    repo: Option<String>,
313    #[serde(default)]
314    updated_at: Option<String>,
315}
316
317async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
318    let item = ItemRef::maybe(q.repo, q.id);
319    let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
320    if let Some(hit) = s.decorations.get(&key) {
321        return Json(hit).into_response();
322    }
323    match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
324        Some(decoration) => {
325            s.decorations.put(key, decoration.clone());
326            Json(decoration).into_response()
327        }
328        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
329    }
330}
331
332async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
333    action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
334}
335async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
336    action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
337}
338async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
339    action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
340}
341async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
342    action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
343}
344async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
345    action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
346}
347async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
348    action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
349}
350
351async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
352    let (conn, item) = q.item();
353    match dto::wi_detail(&s.deps.sections, &conn, &item).await {
354        Some(detail) => Json(detail).into_response(),
355        None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
356    }
357}
358async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
359    let (conn, item) = q.item();
360    match actions::wi_states(&s.deps.sections, &conn, &item).await {
361        Some(states) => Json(states).into_response(),
362        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
363    }
364}
365async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
366    action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
367}
368async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
369    action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
370}
371async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
372    let (conn, item) = q.item();
373    match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
374        Some(users) => Json(users).into_response(),
375        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
376    }
377}
378async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
379    action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
380}
381async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
382    action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
383}
384
385async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
386    match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
387        Some(detail) => Json(detail).into_response(),
388        None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
389    }
390}
391async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
392    match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
393        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
394        None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
395    }
396}
397
398async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
399    action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
400}
401async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
402    action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
403}
404async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
405    action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
406}
407
408async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
409    action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
410}
411
412// ---- connection management ----
413
414#[derive(Deserialize)]
415struct IdReq {
416    id: String,
417}
418
419async fn providers() -> Json<Vec<connections::ProviderInfo>> {
420    Json(connections::providers())
421}
422async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
423    Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
424}
425async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
426    match connections::save(&s.deps.config, &s.deps.sections, req).await {
427        Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
428        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
429    }
430}
431async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
432    match connections::remove(&s.deps.config, &req.id).await {
433        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
434        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
435    }
436}
437async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
438    match connections::test(&s.deps.health, &req.id).await {
439        Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
440        None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
441    }
442}
443
444/// The repositories a connection could fetch from — the scope picker's candidate list. Only
445/// this endpoint calls provider discovery, so discovery being wrong shows an empty picker; it
446/// cannot stop an already-scoped connection fetching.
447async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
448    match s.deps.sections.discover_repositories(&q.id).await {
449        Ok(page) => Json(page).into_response(),
450        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
451    }
452}
453
454#[derive(Deserialize)]
455struct IdQuery {
456    id: String,
457}
458
459#[derive(Deserialize)]
460struct ScopeReq {
461    id: String,
462    /// The chosen repositories. An empty list is a real choice — fetch nothing — and is stored
463    /// as such; it is not the same as never having chosen.
464    scope: Vec<String>,
465}
466
467async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
468    match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
469        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
470        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
471    }
472}
473
474/// A pipeline connection's discovered definitions and which of them are fetched — the picker's
475/// candidates. Pipelines are opt-in: nothing selected (`all` false, `selected` empty) fetches
476/// nothing.
477#[derive(serde::Serialize)]
478struct PipelineDefinitionsResp {
479    definitions: Vec<forgetop_core::domain::PipelineDefinition>,
480    all: bool,
481    selected: Vec<String>,
482}
483
484async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
485    let source = match s.deps.sections.pipeline_source_for(&q.id).await {
486        Ok(Some(source)) => source,
487        Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
488        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
489    };
490    let definitions = match source.discover().await {
491        Ok(defs) => defs,
492        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
493    };
494    let cfg = s.deps.config.snapshot();
495    let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
496    Json(PipelineDefinitionsResp {
497        definitions,
498        all: sub.is_some_and(|s| s.auto_discover_all),
499        selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
500    })
501    .into_response()
502}
503
504#[derive(Deserialize)]
505struct PipelineSelectionReq {
506    id: String,
507    /// Every pipeline, including ones created later. Wins over `ids`.
508    #[serde(default)]
509    all: bool,
510    /// The chosen definitions. Empty is a real choice — fetch nothing.
511    #[serde(default)]
512    ids: Vec<String>,
513}
514
515async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
516    let saved = if req.all {
517        s.deps.config.set_pipeline_auto_discover(&req.id, true).await
518    } else {
519        s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
520    };
521    match saved {
522        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
523        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
524    }
525}