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/file-text", get(pr_file_text))
134        .route("/api/pr/vote", post(pr_vote))
135        .route("/api/pr/merge", post(pr_merge))
136        .route("/api/pr/revert", post(pr_revert))
137        .route("/api/pr/comment", post(pr_comment))
138        .route("/api/pr/reply", post(pr_reply))
139        .route("/api/pr/review", post(pr_review))
140        .route("/api/pr/resolve-thread", post(pr_resolve_thread))
141        .route("/api/pr/draft", post(pr_draft))
142        .route("/api/pr/closed", post(pr_closed))
143        .route("/api/pr/reviewers", get(pr_reviewers))
144        .route("/api/pr/request-reviewer", post(pr_request_reviewer))
145        .route("/api/wi/detail", get(wi_detail))
146        .route("/api/wi/states", get(wi_states))
147        .route("/api/wi/state", post(wi_state))
148        .route("/api/wi/comment", post(wi_comment))
149        .route("/api/wi/assignees", get(wi_assignees))
150        .route("/api/wi/assignee", post(wi_assignee))
151        .route("/api/wi/update", post(wi_update))
152        .route("/api/pipeline/detail", get(pipeline_detail))
153        .route("/api/pipeline/logs", get(pipeline_logs))
154        .route("/api/pipeline/annotations", get(pipeline_annotations))
155        .route("/api/pipeline/artifacts", get(pipeline_artifacts))
156        .route("/api/pipeline/approval", post(pipeline_approval))
157        .route("/api/pipeline/trigger", post(pipeline_trigger))
158        .route("/api/pipeline/cancel", post(pipeline_cancel))
159        .route("/api/pipeline/rerun", post(pipeline_rerun))
160        .route("/api/notification/read", post(notification_read))
161        .route("/api/providers", get(providers))
162        .route("/api/connections", get(list_connections).post(save_connection))
163        .route("/api/connections/delete", post(delete_connection))
164        .route("/api/connections/test", post(test_connection))
165        .route("/api/connections/repositories", get(connection_repositories))
166        .route("/api/connections/scope", post(set_connection_scope))
167        .route("/api/pipelines/definitions", get(pipeline_definitions))
168        .route("/api/pipelines/selection", post(set_pipeline_selection))
169        .layer(middleware::from_fn_with_state(state.clone(), auth))
170        .with_state(state);
171
172    Router::new().merge(api).fallback(static_asset)
173}
174
175/// Serves an embedded SPA asset by path, falling back to `index.html` for unknown routes so
176/// client-side routing works on refresh/deep-link.
177async fn static_asset(uri: Uri) -> Response {
178    let path = uri.path().trim_start_matches('/');
179    let path = if path.is_empty() { "index.html" } else { path };
180    serve(path)
181        .or_else(|| serve("index.html"))
182        .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
183}
184
185fn serve(path: &str) -> Option<Response> {
186    let file = Assets::get(path)?;
187    let mime = mime_guess::from_path(path).first_or_octet_stream();
188    Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
189}
190
191/// Session-token gate. The browser opens `/?t=<token>`; other calls may send it as the
192/// `x-forgetop-token` header. Combined with localhost-only binding, this keeps the
193/// (action-capable) API off-limits to other local processes and web pages.
194async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
195    if token_from(&req).as_deref() == Some(&state.token) {
196        next.run(req).await
197    } else {
198        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
199    }
200}
201
202fn token_from(req: &Request) -> Option<String> {
203    if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
204        return Some(header.to_string());
205    }
206    req.uri()
207        .query()
208        .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
209}
210
211async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
212    Json(dto::health(&s.deps.health).await)
213}
214async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
215    Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
216}
217async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
218    Json(dto::work_items(&s.deps.sections).await)
219}
220async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
221    Json(dto::pipelines(&s.deps.sections).await)
222}
223async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
224    Json(dto::notifications(&s.deps.sections).await)
225}
226async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
227    Json(dto::launchpad(&s.deps.sections).await)
228}
229
230/// Query params identifying an item within a connection (`?conn=…&id=…&repo=…`).
231///
232/// `repo` is a **query parameter**, never a path segment: an `owner/repo` always contains a
233/// slash. It is optional, so every link written before connections spanned an account still
234/// resolves (a single-repository connection needs no address).
235#[derive(Deserialize)]
236struct ItemQuery {
237    conn: String,
238    id: String,
239    #[serde(default)]
240    repo: Option<String>,
241}
242
243impl ItemQuery {
244    fn item(self) -> (String, ItemRef) {
245        (self.conn, ItemRef::maybe(self.repo, self.id))
246    }
247}
248
249/// Query params identifying a single commit's diff within a PR (`?conn=…&id=…&sha=…`).
250#[derive(Deserialize)]
251struct CommitQuery {
252    conn: String,
253    id: String,
254    sha: String,
255    #[serde(default)]
256    repo: Option<String>,
257}
258
259/// Query params naming one file of a PR (`?conn=…&id=…&path=…`), as of a commit (`&sha=…`) or,
260/// without one, the PR's head.
261#[derive(Deserialize)]
262struct FileTextQuery {
263    conn: String,
264    id: String,
265    path: String,
266    #[serde(default)]
267    sha: Option<String>,
268    #[serde(default)]
269    repo: Option<String>,
270}
271
272/// Query params for the PR list: which view to show (`?view=all|merged|review_requested`).
273#[derive(Deserialize)]
274struct PrListQuery {
275    #[serde(default)]
276    view: Option<String>,
277}
278
279/// Query params identifying a pipeline run within a connection (`?conn=…&run_id=…`).
280#[derive(Deserialize)]
281struct RunQuery {
282    conn: String,
283    run_id: String,
284    #[serde(default)]
285    repo: Option<String>,
286}
287
288/// Query params for pipeline logs: a run, optionally scoped to a single job (`&job=…`).
289#[derive(Deserialize)]
290struct PipelineLogsQuery {
291    conn: String,
292    run_id: String,
293    #[serde(default)]
294    repo: Option<String>,
295    #[serde(default)]
296    job: Option<String>,
297}
298
299/// Turns an action outcome into a response: `{ok:true}`, 404 (no such connection/capability),
300/// or 502 (the provider call failed).
301fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
302    action_response_with(operation, result.map(|()| serde_json::json!({ "ok": true })))
303}
304
305/// Like [`action_response`], for an action that reports something back: `Ok` carries the JSON body.
306fn action_response_with(operation: &'static str, result: Result<serde_json::Value, ActionError>) -> Response {
307    match result {
308        Ok(body) => Json(body).into_response(),
309        Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
310        Err(ActionError::Failed(msg)) => {
311            forgetop_core::diag::log(operation, "provider action failed");
312            (StatusCode::BAD_GATEWAY, msg).into_response()
313        }
314    }
315}
316
317async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
318    match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
319        Some(changes) => Json(changes).into_response(),
320        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
321    }
322}
323
324/// One file's full text, so a diff can expand the unchanged lines between its hunks — plain text,
325/// or a 404 when there's no such connection, or the forge has no text for it (binary, oversized,
326/// missing at that commit, or a provider without a content API).
327async fn pr_file_text(State(s): State<AppState>, Query(q): Query<FileTextQuery>) -> Response {
328    match dto::pr_file_text(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.path, q.sha.as_deref()).await {
329        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
330        None => (StatusCode::NOT_FOUND, "file text not available").into_response(),
331    }
332}
333
334async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
335    let (conn, item) = q.item();
336    match dto::pr_detail(&s.deps.sections, &conn, &item).await {
337        Some(detail) => Json(detail).into_response(),
338        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
339    }
340}
341
342/// Query params for one PR's decoration. `updated_at` is the provider's own last-changed stamp
343/// from the list row, and is what makes the cache entry safe to reuse.
344#[derive(Deserialize)]
345struct DecorationQuery {
346    conn: String,
347    id: String,
348    #[serde(default)]
349    repo: Option<String>,
350    #[serde(default)]
351    updated_at: Option<String>,
352}
353
354async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
355    let item = ItemRef::maybe(q.repo, q.id);
356    let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
357    if let Some(hit) = s.decorations.get(&key) {
358        return Json(hit).into_response();
359    }
360    match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
361        Some(decoration) => {
362            s.decorations.put(key, decoration.clone());
363            Json(decoration).into_response()
364        }
365        None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
366    }
367}
368
369async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
370    action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
371}
372async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
373    action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
374}
375async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
376    action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
377}
378async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
379    action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
380}
381async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
382    action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
383}
384async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
385    action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
386}
387async fn pr_resolve_thread(State(s): State<AppState>, Json(req): Json<actions::PrResolveThreadReq>) -> Response {
388    action_response("dashboard.pr_resolve_thread", actions::pr_resolve_thread(&s.deps.sections, req).await)
389}
390async fn pr_draft(State(s): State<AppState>, Json(req): Json<actions::PrDraftReq>) -> Response {
391    action_response("dashboard.pr_draft", actions::pr_set_draft(&s.deps.sections, req).await)
392}
393async fn pr_closed(State(s): State<AppState>, Json(req): Json<actions::PrClosedReq>) -> Response {
394    action_response("dashboard.pr_closed", actions::pr_set_closed(&s.deps.sections, req).await)
395}
396async fn pr_reviewers(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
397    let (conn, item) = q.item();
398    match actions::pr_reviewers(&s.deps.sections, &conn, &item).await {
399        Some(users) => Json(users).into_response(),
400        None => (StatusCode::NOT_FOUND, "pull request connection not found").into_response(),
401    }
402}
403async fn pr_request_reviewer(State(s): State<AppState>, Json(req): Json<actions::PrRequestReviewerReq>) -> Response {
404    action_response("dashboard.pr_request_reviewer", actions::pr_request_reviewer(&s.deps.sections, req).await)
405}
406
407async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
408    let (conn, item) = q.item();
409    match dto::wi_detail(&s.deps.sections, &conn, &item).await {
410        Some(detail) => Json(detail).into_response(),
411        None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
412    }
413}
414async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
415    let (conn, item) = q.item();
416    match actions::wi_states(&s.deps.sections, &conn, &item).await {
417        Some(states) => Json(states).into_response(),
418        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
419    }
420}
421async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
422    action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
423}
424async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
425    action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
426}
427async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
428    let (conn, item) = q.item();
429    match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
430        Some(users) => Json(users).into_response(),
431        None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
432    }
433}
434async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
435    action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
436}
437async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
438    action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
439}
440
441async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
442    match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
443        Some(detail) => Json(detail).into_response(),
444        None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
445    }
446}
447async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
448    match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
449        Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
450        None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
451    }
452}
453
454async fn pipeline_annotations(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
455    match dto::pipeline_annotations(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
456        Some(annotations) => Json(annotations).into_response(),
457        None => (StatusCode::NOT_FOUND, "annotations not available").into_response(),
458    }
459}
460async fn pipeline_artifacts(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
461    match dto::pipeline_artifacts(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
462        Some(artifacts) => Json(artifacts).into_response(),
463        None => (StatusCode::NOT_FOUND, "artifacts not available").into_response(),
464    }
465}
466
467async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
468    action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
469}
470async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
471    action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
472}
473async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
474    action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
475}
476async fn pipeline_rerun(State(s): State<AppState>, Json(req): Json<actions::PipelineRerunReq>) -> Response {
477    let result = actions::pipeline_rerun(&s.deps.sections, req).await;
478    action_response_with("dashboard.pipeline_rerun", result.map(|new_run_id| serde_json::json!({ "ok": true, "new_run_id": new_run_id })))
479}
480
481async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
482    action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
483}
484
485// ---- connection management ----
486
487#[derive(Deserialize)]
488struct IdReq {
489    id: String,
490}
491
492async fn providers() -> Json<Vec<connections::ProviderInfo>> {
493    Json(connections::providers())
494}
495async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
496    Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
497}
498async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
499    match connections::save(&s.deps.config, &s.deps.sections, req).await {
500        Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
501        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
502    }
503}
504async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
505    match connections::remove(&s.deps.config, &req.id).await {
506        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
507        Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
508    }
509}
510async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
511    match connections::test(&s.deps.health, &req.id).await {
512        Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
513        None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
514    }
515}
516
517/// The repositories a connection could fetch from — the scope picker's candidate list. Only
518/// this endpoint calls provider discovery, so discovery being wrong shows an empty picker; it
519/// cannot stop an already-scoped connection fetching.
520async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
521    match s.deps.sections.discover_repositories(&q.id).await {
522        Ok(page) => Json(page).into_response(),
523        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
524    }
525}
526
527#[derive(Deserialize)]
528struct IdQuery {
529    id: String,
530}
531
532#[derive(Deserialize)]
533struct ScopeReq {
534    id: String,
535    /// The chosen repositories. An empty list is a real choice — fetch nothing — and is stored
536    /// as such; it is not the same as never having chosen.
537    scope: Vec<String>,
538}
539
540async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
541    match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
542        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
543        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
544    }
545}
546
547/// A pipeline connection's discovered definitions and which of them are fetched — the picker's
548/// candidates. Pipelines are opt-in: nothing selected (`all` false, `selected` empty) fetches
549/// nothing.
550#[derive(serde::Serialize)]
551struct PipelineDefinitionsResp {
552    definitions: Vec<forgetop_core::domain::PipelineDefinition>,
553    all: bool,
554    selected: Vec<String>,
555}
556
557async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
558    let source = match s.deps.sections.pipeline_source_for(&q.id).await {
559        Ok(Some(source)) => source,
560        Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
561        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
562    };
563    let definitions = match source.discover().await {
564        Ok(defs) => defs,
565        Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
566    };
567    let cfg = s.deps.config.snapshot();
568    let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
569    Json(PipelineDefinitionsResp {
570        definitions,
571        all: sub.is_some_and(|s| s.auto_discover_all),
572        selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
573    })
574    .into_response()
575}
576
577#[derive(Deserialize)]
578struct PipelineSelectionReq {
579    id: String,
580    /// Every pipeline, including ones created later. Wins over `ids`.
581    #[serde(default)]
582    all: bool,
583    /// The chosen definitions. Empty is a real choice — fetch nothing.
584    #[serde(default)]
585    ids: Vec<String>,
586}
587
588async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
589    let saved = if req.all {
590        s.deps.config.set_pipeline_auto_discover(&req.id, true).await
591    } else {
592        s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
593    };
594    match saved {
595        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
596        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    //! The optional pull-request writes (resolve a thread, draft, close/reopen, request a
603    //! reviewer) end to end through the HTTP API, against the in-memory demo provider — the same
604    //! harness `tests/api.rs` uses for the other routes.
605
606    use super::*;
607    use forgetop_core::config::{ConfigStore, ForgetopConfig, InMemoryConfigStore};
608    use forgetop_core::domain::ProviderType;
609    use forgetop_core::provider::{Connection, ProviderRegistry};
610    use forgetop_core::secret::InMemorySecretStore;
611    use forgetop_core::service::ConnectionResolver;
612    use forgetop_providers::demo::demo_factories;
613
614    /// One demo GitHub connection bound to the PR section — the shape `forgetop --demo` wires.
615    async fn demo_deps() -> Deps {
616        let registry = Arc::new(ProviderRegistry::new(demo_factories()));
617        let store: Arc<dyn ConfigStore> = Arc::new(InMemoryConfigStore::new(ForgetopConfig::default()));
618        let secrets: Arc<dyn SecretStore> = Arc::new(InMemorySecretStore::default());
619        let config = Arc::new(ConfigService::new(store, secrets.clone(), registry.clone()));
620        config.load().await.unwrap();
621        let conn = Connection {
622            id: "github".into(),
623            provider_type: ProviderType::GitHub,
624            display_name: "github".into(),
625            base_url: None,
626            organization: None,
627            project: None,
628            repository: None,
629            username: None,
630            credential_ref: None,
631            repo_scope: None,
632        };
633        config.add_or_update_connection(conn, None).await.unwrap();
634        config.bind_pull_requests("github").await.unwrap();
635        let resolver = Arc::new(ConnectionResolver::new(config.clone(), registry, secrets.clone()));
636        let sections = Arc::new(SectionService::new(config.clone(), resolver.clone()));
637        let health = Arc::new(ConnectionHealthService::new(config.clone(), resolver));
638        Deps { sections, health, config, secrets }
639    }
640
641    struct Api {
642        base: String,
643        token: String,
644        client: reqwest::Client,
645    }
646
647    impl Api {
648        async fn start() -> Api {
649            let server = spawn(demo_deps().await, 0).await.expect("server binds a free port");
650            Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
651        }
652        async fn get(&self, path: &str) -> serde_json::Value {
653            let r = self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.unwrap();
654            assert_eq!(r.status(), 200, "GET {path}");
655            r.json().await.unwrap()
656        }
657        async fn post(&self, path: &str, body: serde_json::Value) -> u16 {
658            let r = self.client.post(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).json(&body).send().await.unwrap();
659            r.status().as_u16()
660        }
661        /// The open PRs the list shows, as `(id, status)`.
662        async fn open_prs(&self) -> Vec<(String, String)> {
663            let rows = self.get("/api/pull-requests").await;
664            rows.as_array()
665                .unwrap()
666                .iter()
667                .map(|r| (r["pull_request"]["id"].as_str().unwrap().to_string(), r["pull_request"]["status"].as_str().unwrap().to_string()))
668                .collect()
669        }
670        async fn detail(&self, id: &str) -> serde_json::Value {
671            self.get(&format!("/api/pr/detail?conn=github&id={id}")).await
672        }
673    }
674
675    /// The demo PRs are global to the process, so each test picks its own open PR to write to.
676    async fn open_pr(api: &Api, nth: usize) -> String {
677        let open: Vec<_> = api.open_prs().await.into_iter().filter(|(_, s)| s == "Open").collect();
678        open.get(nth).unwrap_or_else(|| panic!("demo has at least {} open PRs", nth + 1)).0.clone()
679    }
680
681    #[tokio::test]
682    async fn pr_detail_advertises_the_demo_writes() {
683        let api = Api::start().await;
684        let id = open_pr(&api, 0).await;
685        let writes = &api.detail(&id).await["writes"];
686        for k in ["resolve_threads", "draft", "close", "reopen", "request_reviewer"] {
687            assert_eq!(writes[k], true, "demo supports {k}: {writes}");
688        }
689    }
690
691    #[tokio::test]
692    async fn resolving_a_thread_flips_it_on_the_next_detail() {
693        let api = Api::start().await;
694        let id = open_pr(&api, 1).await;
695        let thread = |d: &serde_json::Value, t: &str| d["threads"].as_array().unwrap().iter().find(|x| x["id"] == t).cloned().unwrap();
696        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "t1 starts open");
697
698        let body = |resolved: bool| serde_json::json!({ "conn": "github", "id": id, "thread_id": "t1", "resolved": resolved });
699        assert_eq!(api.post("/api/pr/resolve-thread", body(true)).await, 200);
700        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], true, "resolve persists");
701        assert_eq!(api.post("/api/pr/resolve-thread", body(false)).await, 200);
702        assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "reopen persists");
703    }
704
705    #[tokio::test]
706    async fn draft_and_close_change_the_pr_status() {
707        let api = Api::start().await;
708        let id = open_pr(&api, 2).await;
709        let status = |d: serde_json::Value| d["pull_request"]["status"].as_str().unwrap().to_string();
710
711        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": true })).await, 200);
712        assert_eq!(status(api.detail(&id).await), "Draft", "converted to a draft");
713        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": false })).await, 200);
714        assert_eq!(status(api.detail(&id).await), "Open", "ready for review again");
715
716        assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": true })).await, 200);
717        assert_eq!(status(api.detail(&id).await), "Closed");
718        assert!(!api.open_prs().await.iter().any(|(p, _)| *p == id), "a closed PR leaves the open list");
719        assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": false })).await, 200);
720        assert_eq!(status(api.detail(&id).await), "Open", "reopened");
721        assert!(api.open_prs().await.iter().any(|(p, _)| *p == id), "and back on the open list");
722
723        // A missing connection is a 404, as for every other action.
724        assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "nope", "id": id, "draft": true })).await, 404);
725    }
726
727    #[tokio::test]
728    async fn requesting_a_reviewer_adds_them_to_the_pr() {
729        let api = Api::start().await;
730        let id = open_pr(&api, 3).await;
731        let users = api.get(&format!("/api/pr/reviewers?conn=github&id={id}")).await;
732        let users = users.as_array().expect("reviewers is a JSON array of users");
733        assert!(!users.is_empty(), "the demo has someone to ask");
734
735        let reviewer_ids = |d: &serde_json::Value| -> Vec<String> {
736            d["pull_request"]["reviewers"].as_array().unwrap().iter().map(|r| r["user"]["id"].as_str().unwrap().to_string()).collect()
737        };
738        let before = reviewer_ids(&api.detail(&id).await);
739        let pick = users.iter().map(|u| u["id"].as_str().unwrap().to_string()).find(|u| !before.contains(u)).expect("someone not yet reviewing");
740
741        assert_eq!(api.post("/api/pr/request-reviewer", serde_json::json!({ "conn": "github", "id": id, "user_id": pick })).await, 200);
742        let after = api.detail(&id).await;
743        let added = after["pull_request"]["reviewers"].as_array().unwrap().iter().find(|r| r["user"]["id"] == pick.as_str()).cloned();
744        let added = added.expect("the requested reviewer is on the PR");
745        assert_eq!(added["vote"], "NoVote", "requested, not yet voted");
746
747        let missing = api.client.get(format!("{}/api/pr/reviewers?conn=nope&id={id}", api.base)).header("x-forgetop-token", &api.token).send().await.unwrap();
748        assert_eq!(missing.status(), 404, "a bad connection is a 404");
749    }
750}