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        .layer(middleware::from_fn_with_state(state.clone(), auth))
159        .with_state(state);
160
161    Router::new().merge(api).fallback(static_asset)
162}
163
164/// Serves an embedded SPA asset by path, falling back to `index.html` for unknown routes so
165/// client-side routing works on refresh/deep-link.
166async fn static_asset(uri: Uri) -> Response {
167    let path = uri.path().trim_start_matches('/');
168    let path = if path.is_empty() { "index.html" } else { path };
169    serve(path)
170        .or_else(|| serve("index.html"))
171        .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
172}
173
174fn serve(path: &str) -> Option<Response> {
175    let file = Assets::get(path)?;
176    let mime = mime_guess::from_path(path).first_or_octet_stream();
177    Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
178}
179
180/// Session-token gate. The browser opens `/?t=<token>`; other calls may send it as the
181/// `x-forgetop-token` header. Combined with localhost-only binding, this keeps the
182/// (action-capable) API off-limits to other local processes and web pages.
183async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
184    if token_from(&req).as_deref() == Some(&state.token) {
185        next.run(req).await
186    } else {
187        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
188    }
189}
190
191fn token_from(req: &Request) -> Option<String> {
192    if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
193        return Some(header.to_string());
194    }
195    req.uri()
196        .query()
197        .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
198}
199
200async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
201    Json(dto::health(&s.deps.health).await)
202}
203async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
204    Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
205}
206async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
207    Json(dto::work_items(&s.deps.sections).await)
208}
209async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
210    Json(dto::pipelines(&s.deps.sections).await)
211}
212async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
213    Json(dto::notifications(&s.deps.sections).await)
214}
215async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
216    Json(dto::launchpad(&s.deps.sections).await)
217}
218
219/// Query params identifying an item within a connection (`?conn=…&id=…&repo=…`).
220///
221/// `repo` is a **query parameter**, never a path segment: an `owner/repo` always contains a
222/// slash. It is optional, so every link written before connections spanned an account still
223/// resolves (a single-repository connection needs no address).
224#[derive(Deserialize)]
225struct ItemQuery {
226    conn: String,
227    id: String,
228    #[serde(default)]
229    repo: Option<String>,
230}
231
232impl ItemQuery {
233    fn item(self) -> (String, ItemRef) {
234        (self.conn, ItemRef::maybe(self.repo, self.id))
235    }
236}
237
238/// Query params identifying a single commit's diff within a PR (`?conn=…&id=…&sha=…`).
239#[derive(Deserialize)]
240struct CommitQuery {
241    conn: String,
242    id: String,
243    sha: String,
244    #[serde(default)]
245    repo: Option<String>,
246}
247
248/// Query params for the PR list: which view to show (`?view=all|merged|review_requested`).
249#[derive(Deserialize)]
250struct PrListQuery {
251    #[serde(default)]
252    view: Option<String>,
253}
254
255/// Query params identifying a pipeline run within a connection (`?conn=…&run_id=…`).
256#[derive(Deserialize)]
257struct RunQuery {
258    conn: String,
259    run_id: String,
260    #[serde(default)]
261    repo: Option<String>,
262}
263
264/// Query params for pipeline logs: a run, optionally scoped to a single job (`&job=…`).
265#[derive(Deserialize)]
266struct PipelineLogsQuery {
267    conn: String,
268    run_id: String,
269    #[serde(default)]
270    repo: Option<String>,
271    #[serde(default)]
272    job: Option<String>,
273}
274
275/// Turns an action outcome into a response: `{ok:true}`, 404 (no such connection/capability),
276/// or 502 (the provider call failed).
277fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
278    match result {
279        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
280        Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
281        Err(ActionError::Failed(msg)) => {
282            forgetop_core::diag::log(operation, "provider action failed");
283            (StatusCode::BAD_GATEWAY, msg).into_response()
284        }
285    }
286}
287
288async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
289    match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
290        Some(changes) => Json(changes).into_response(),
291        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
292    }
293}
294
295async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
296    let (conn, item) = q.item();
297    match dto::pr_detail(&s.deps.sections, &conn, &item).await {
298        Some(detail) => Json(detail).into_response(),
299        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
300    }
301}
302
303/// Query params for one PR's decoration. `updated_at` is the provider's own last-changed stamp
304/// from the list row, and is what makes the cache entry safe to reuse.
305#[derive(Deserialize)]
306struct DecorationQuery {
307    conn: String,
308    id: String,
309    #[serde(default)]
310    repo: Option<String>,
311    #[serde(default)]
312    updated_at: Option<String>,
313}
314
315async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
316    let item = ItemRef::maybe(q.repo, q.id);
317    let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
318    if let Some(hit) = s.decorations.get(&key) {
319        return Json(hit).into_response();
320    }
321    match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
322        Some(decoration) => {
323            s.decorations.put(key, decoration.clone());
324            Json(decoration).into_response()
325        }
326        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
327    }
328}
329
330async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
331    action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
332}
333async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
334    action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
335}
336async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
337    action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
338}
339async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
340    action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
341}
342async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
343    action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
344}
345async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
346    action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
347}
348
349async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
350    let (conn, item) = q.item();
351    match dto::wi_detail(&s.deps.sections, &conn, &item).await {
352        Some(detail) => Json(detail).into_response(),
353        None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
354    }
355}
356async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
357    let (conn, item) = q.item();
358    match actions::wi_states(&s.deps.sections, &conn, &item).await {
359        Some(states) => Json(states).into_response(),
360        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
361    }
362}
363async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
364    action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
365}
366async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
367    action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
368}
369async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
370    let (conn, item) = q.item();
371    match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
372        Some(users) => Json(users).into_response(),
373        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
374    }
375}
376async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
377    action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
378}
379async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
380    action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
381}
382
383async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
384    match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
385        Some(detail) => Json(detail).into_response(),
386        None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
387    }
388}
389async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
390    match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
391        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
392        None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
393    }
394}
395
396async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
397    action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
398}
399async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
400    action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
401}
402async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
403    action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
404}
405
406async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
407    action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
408}
409
410// ---- connection management ----
411
412#[derive(Deserialize)]
413struct IdReq {
414    id: String,
415}
416
417async fn providers() -> Json<Vec<connections::ProviderInfo>> {
418    Json(connections::providers())
419}
420async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
421    Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
422}
423async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
424    match connections::save(&s.deps.config, &s.deps.sections, req).await {
425        Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
426        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
427    }
428}
429async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
430    match connections::remove(&s.deps.config, &req.id).await {
431        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
432        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
433    }
434}
435async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
436    match connections::test(&s.deps.health, &req.id).await {
437        Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
438        None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
439    }
440}
441
442/// The repositories a connection could fetch from — the scope picker's candidate list. Only
443/// this endpoint calls provider discovery, so discovery being wrong shows an empty picker; it
444/// cannot stop an already-scoped connection fetching.
445async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
446    match s.deps.sections.discover_repositories(&q.id).await {
447        Ok(page) => Json(page).into_response(),
448        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
449    }
450}
451
452#[derive(Deserialize)]
453struct IdQuery {
454    id: String,
455}
456
457#[derive(Deserialize)]
458struct ScopeReq {
459    id: String,
460    /// The chosen repositories. An empty list is a real choice — fetch nothing — and is stored
461    /// as such; it is not the same as never having chosen.
462    scope: Vec<String>,
463}
464
465async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
466    match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
467        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
468        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
469    }
470}
471