1use 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::provider::{ItemRef, PrDecoration};
18use forgetop_core::secret::SecretStore;
19use forgetop_core::service::{ConfigService, ConnectionHealthService, SectionService};
20use rust_embed::RustEmbed;
21use serde::Deserialize;
22
23use crate::actions::ActionError;
24
25pub mod cached;
26
27mod actions;
28mod connections;
29mod dto;
30
31#[derive(RustEmbed)]
33#[folder = "web/dist"]
34struct Assets;
35
36pub const DEFAULT_PORT: u16 = 8177;
38
39#[derive(Clone)]
42pub struct Deps {
43 pub sections: Arc<SectionService>,
44 pub health: Arc<ConnectionHealthService>,
45 pub config: Arc<ConfigService>,
46 pub secrets: Arc<dyn SecretStore>,
47 pub cache: Arc<CacheStore>,
51}
52
53pub struct Server {
55 pub port: u16,
56 pub token: String,
57 pub url: String,
59}
60
61#[derive(Default)]
66struct DecorationCache {
67 entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
68}
69
70impl DecorationCache {
71 const MAX: usize = 500;
74
75 fn key(conn: &str, item: &ItemRef, updated_at: Option<&str>) -> String {
76 format!("{conn}\u{1}{}\u{1}{}\u{1}{}", item.repo.as_deref().unwrap_or(""), item.id, updated_at.unwrap_or(""))
77 }
78
79 fn get(&self, key: &str) -> Option<PrDecoration> {
80 self.entries.lock().unwrap().get(key).cloned()
81 }
82
83 fn put(&self, key: String, value: PrDecoration) {
84 let mut entries = self.entries.lock().unwrap();
85 if entries.len() >= Self::MAX {
86 entries.clear();
87 }
88 entries.insert(key, value);
89 }
90}
91
92#[derive(Clone)]
93struct AppState {
94 deps: Deps,
95 token: Arc<str>,
96 decorations: Arc<DecorationCache>,
97 inflight: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
100 flusher: Arc<cached::Flusher>,
101}
102
103async fn bind(deps: Deps, port: u16) -> std::io::Result<(tokio::net::TcpListener, Server, AppState)> {
104 let token = uuid::Uuid::new_v4().to_string();
105 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await?;
106 let bound = listener.local_addr()?.port();
107 let url = format!("http://127.0.0.1:{bound}/?t={token}");
108 let flusher = Arc::new(cached::Flusher::new(deps.cache.clone()));
109 let state = AppState {
110 deps,
111 token: Arc::from(token.as_str()),
112 decorations: Arc::new(DecorationCache::default()),
113 inflight: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
114 flusher,
115 };
116 Ok((listener, Server { port: bound, token, url }, state))
117}
118
119pub async fn spawn(deps: Deps, port: u16) -> std::io::Result<Server> {
122 let (listener, server, state) = bind(deps, port).await?;
123 tokio::spawn(async move {
124 let _ = axum::serve(listener, router(state)).await;
125 });
126 Ok(server)
127}
128
129pub async fn serve_blocking(deps: Deps, port: u16, on_ready: impl FnOnce(&str)) -> std::io::Result<()> {
132 let (listener, server, state) = bind(deps, port).await?;
133 on_ready(&server.url);
134 axum::serve(listener, router(state)).await
135}
136
137fn router(state: AppState) -> Router {
138 let api = Router::new()
142 .route("/api/health", get(health))
143 .route("/api/pull-requests", get(pull_requests))
144 .route("/api/work-items", get(work_items))
145 .route("/api/pipelines", get(pipelines))
146 .route("/api/notifications", get(notifications))
147 .route("/api/launchpad", get(launchpad))
148 .route("/api/pr/detail", get(pr_detail))
149 .route("/api/pr/decoration", get(pr_decoration))
150 .route("/api/pr/commit-changes", get(pr_commit_changes))
151 .route("/api/pr/file-text", get(pr_file_text))
152 .route("/api/highlight", post(highlight))
153 .route("/api/pr/vote", post(pr_vote))
154 .route("/api/pr/merge", post(pr_merge))
155 .route("/api/pr/revert", post(pr_revert))
156 .route("/api/pr/comment", post(pr_comment))
157 .route("/api/pr/reply", post(pr_reply))
158 .route("/api/pr/review", post(pr_review))
159 .route("/api/pr/resolve-thread", post(pr_resolve_thread))
160 .route("/api/pr/draft", post(pr_draft))
161 .route("/api/pr/closed", post(pr_closed))
162 .route("/api/pr/reviewers", get(pr_reviewers))
163 .route("/api/pr/request-reviewer", post(pr_request_reviewer))
164 .route("/api/wi/detail", get(wi_detail))
165 .route("/api/wi/states", get(wi_states))
166 .route("/api/wi/state", post(wi_state))
167 .route("/api/wi/comment", post(wi_comment))
168 .route("/api/wi/assignees", get(wi_assignees))
169 .route("/api/wi/assignee", post(wi_assignee))
170 .route("/api/wi/update", post(wi_update))
171 .route("/api/pipeline/detail", get(pipeline_detail))
172 .route("/api/pipeline/logs", get(pipeline_logs))
173 .route("/api/pipeline/annotations", get(pipeline_annotations))
174 .route("/api/pipeline/artifacts", get(pipeline_artifacts))
175 .route("/api/pipeline/approval", post(pipeline_approval))
176 .route("/api/pipeline/trigger", post(pipeline_trigger))
177 .route("/api/pipeline/cancel", post(pipeline_cancel))
178 .route("/api/pipeline/rerun", post(pipeline_rerun))
179 .route("/api/notification/read", post(notification_read))
180 .route("/api/providers", get(providers))
181 .route("/api/connections", get(list_connections).post(save_connection))
182 .route("/api/connections/delete", post(delete_connection))
183 .route("/api/connections/test", post(test_connection))
184 .route("/api/connections/repositories", get(connection_repositories))
185 .route("/api/connections/scope", post(set_connection_scope))
186 .route("/api/pipelines/definitions", get(pipeline_definitions))
187 .route("/api/pipelines/selection", post(set_pipeline_selection))
188 .layer(middleware::from_fn_with_state(state.clone(), auth))
189 .with_state(state);
190
191 Router::new().merge(api).fallback(static_asset)
192}
193
194async fn static_asset(uri: Uri) -> Response {
197 let path = uri.path().trim_start_matches('/');
198 let path = if path.is_empty() { "index.html" } else { path };
199 serve(path)
200 .or_else(|| serve("index.html"))
201 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
202}
203
204fn serve(path: &str) -> Option<Response> {
205 let file = Assets::get(path)?;
206 let mime = mime_guess::from_path(path).first_or_octet_stream();
207 Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
208}
209
210async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
214 if token_from(&req).as_deref() == Some(&state.token) {
215 next.run(req).await
216 } else {
217 (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
218 }
219}
220
221fn token_from(req: &Request) -> Option<String> {
222 if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
223 return Some(header.to_string());
224 }
225 req.uri()
226 .query()
227 .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
228}
229
230async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
231 Json(dto::health(&s.deps.health).await)
232}
233pub const FETCHED_AT_HEADER: &str = "x-forgetop-fetched-at";
235
236const STALE_WAIT: std::time::Duration = std::time::Duration::from_millis(1500);
241
242fn stale_wait() -> std::time::Duration {
243 std::env::var("FORGETOP_CACHE_WAIT_MS")
244 .ok()
245 .and_then(|v| v.parse().ok())
246 .map(std::time::Duration::from_millis)
247 .unwrap_or(STALE_WAIT)
248}
249
250async fn serve_cached<T, F, Fut>(s: &AppState, key: &str, cached: Option<cached::Cached<T>>, fetch: F) -> Response
256where
257 T: serde::Serialize + Send + 'static,
258 F: FnOnce(AppState) -> Fut + Send + 'static,
259 Fut: std::future::Future<Output = T> + Send + 'static,
260{
261 let claim = |s: &AppState| s.inflight.lock().unwrap_or_else(|p| p.into_inner()).insert(key.to_string());
262 let from_cache = |hit: cached::Cached<T>| {
263 let mut res = Json(hit.value).into_response();
264 if let Ok(v) = hit.fetched_at.to_rfc3339().parse() {
265 res.headers_mut().insert(FETCHED_AT_HEADER, v);
266 }
267 res
268 };
269 if !claim(s) {
270 return match cached {
272 Some(hit) => from_cache(hit),
273 None => Json(fetch(s.clone()).await).into_response(),
274 };
275 }
276 let live = {
277 let s2 = s.clone();
278 let key = key.to_string();
279 tokio::spawn(async move {
280 let value = fetch(s2.clone()).await;
281 s2.inflight.lock().unwrap_or_else(|p| p.into_inner()).remove(&key);
282 s2.flusher.schedule();
283 value
284 })
285 };
286 let Some(hit) = cached else {
287 return match live.await {
288 Ok(value) => Json(value).into_response(),
289 Err(_) => (StatusCode::BAD_GATEWAY, "the fetch behind this list failed").into_response(),
290 };
291 };
292 match tokio::time::timeout(stale_wait(), live).await {
293 Ok(Ok(value)) => Json(value).into_response(),
294 _ => from_cache(hit),
296 }
297}
298
299async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Response {
300 let view = dto::PrView::parse(q.view.as_deref());
301 let hit = cached::pull_requests(&s.deps.cache, view);
302 serve_cached(&s, &format!("prs.{view:?}"), hit, move |s: AppState| async move { dto::pull_requests(&s.deps.sections, &s.deps.cache, view).await }).await
303}
304async fn work_items(State(s): State<AppState>) -> Response {
305 let hit = cached::work_items(&s.deps.cache);
306 serve_cached(&s, "work_items", hit, |s: AppState| async move { dto::work_items(&s.deps.sections, &s.deps.cache).await }).await
307}
308async fn pipelines(State(s): State<AppState>) -> Response {
309 let hit = cached::pipelines(&s.deps.cache);
312 serve_cached(&s, "pipelines", hit, |s: AppState| async move { dto::pipelines(&s.deps.sections).await }).await
313}
314async fn notifications(State(s): State<AppState>) -> Response {
315 let hit = cached::notifications(&s.deps.cache);
316 serve_cached(&s, "notifications", hit, |s: AppState| async move { dto::notifications(&s.deps.sections, &s.deps.cache).await }).await
317}
318async fn launchpad(State(s): State<AppState>) -> Response {
319 let hit = cached::launchpad(&s.deps.cache);
320 serve_cached(&s, "launchpad", hit, |s: AppState| async move { dto::launchpad(&s.deps.sections, &s.deps.cache).await }).await
321}
322
323#[derive(Deserialize)]
329struct ItemQuery {
330 conn: String,
331 id: String,
332 #[serde(default)]
333 repo: Option<String>,
334}
335
336impl ItemQuery {
337 fn item(self) -> (String, ItemRef) {
338 (self.conn, ItemRef::maybe(self.repo, self.id))
339 }
340}
341
342#[derive(Deserialize)]
344struct CommitQuery {
345 conn: String,
346 id: String,
347 sha: String,
348 #[serde(default)]
349 repo: Option<String>,
350}
351
352#[derive(Deserialize)]
355struct FileTextQuery {
356 conn: String,
357 id: String,
358 path: String,
359 #[serde(default)]
360 sha: Option<String>,
361 #[serde(default)]
362 repo: Option<String>,
363}
364
365#[derive(Deserialize)]
367struct PrListQuery {
368 #[serde(default)]
369 view: Option<String>,
370}
371
372#[derive(Deserialize)]
374struct RunQuery {
375 conn: String,
376 run_id: String,
377 #[serde(default)]
378 repo: Option<String>,
379}
380
381#[derive(Deserialize)]
383struct PipelineLogsQuery {
384 conn: String,
385 run_id: String,
386 #[serde(default)]
387 repo: Option<String>,
388 #[serde(default)]
389 job: Option<String>,
390}
391
392fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
395 action_response_with(operation, result.map(|()| serde_json::json!({ "ok": true })))
396}
397
398fn action_response_with(operation: &'static str, result: Result<serde_json::Value, ActionError>) -> Response {
400 match result {
401 Ok(body) => Json(body).into_response(),
402 Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
403 Err(ActionError::Failed(msg)) => {
404 forgetop_core::diag::log(operation, "provider action failed");
405 (StatusCode::BAD_GATEWAY, msg).into_response()
406 }
407 }
408}
409
410async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
411 match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
412 Some(changes) => Json(changes).into_response(),
413 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
414 }
415}
416
417async fn pr_file_text(State(s): State<AppState>, Query(q): Query<FileTextQuery>) -> Response {
421 match dto::pr_file_text(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.path, q.sha.as_deref()).await {
422 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
423 None => (StatusCode::NOT_FOUND, "file text not available").into_response(),
424 }
425}
426
427#[derive(Deserialize)]
429struct HighlightReq {
430 path: String,
431 lines: Vec<String>,
432}
433
434async fn highlight(Json(req): Json<HighlightReq>) -> Json<dto::Highlight> {
438 Json(dto::highlight(&req.path, &req.lines))
439}
440
441async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
442 let (conn, item) = q.item();
443 match dto::pr_detail(&s.deps.sections, &conn, &item).await {
444 Some(detail) => Json(detail).into_response(),
445 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
446 }
447}
448
449#[derive(Deserialize)]
452struct DecorationQuery {
453 conn: String,
454 id: String,
455 #[serde(default)]
456 repo: Option<String>,
457 #[serde(default)]
458 updated_at: Option<String>,
459}
460
461async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
462 let item = ItemRef::maybe(q.repo, q.id);
463 let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
464 if let Some(hit) = s.decorations.get(&key) {
465 return Json(hit).into_response();
466 }
467 match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
468 Some(decoration) => {
469 s.decorations.put(key, decoration.clone());
470 Json(decoration).into_response()
471 }
472 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
473 }
474}
475
476async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
477 action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
478}
479async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
480 action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
481}
482async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
483 action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
484}
485async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
486 action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
487}
488async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
489 action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
490}
491async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
492 action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
493}
494async fn pr_resolve_thread(State(s): State<AppState>, Json(req): Json<actions::PrResolveThreadReq>) -> Response {
495 action_response("dashboard.pr_resolve_thread", actions::pr_resolve_thread(&s.deps.sections, req).await)
496}
497async fn pr_draft(State(s): State<AppState>, Json(req): Json<actions::PrDraftReq>) -> Response {
498 action_response("dashboard.pr_draft", actions::pr_set_draft(&s.deps.sections, req).await)
499}
500async fn pr_closed(State(s): State<AppState>, Json(req): Json<actions::PrClosedReq>) -> Response {
501 action_response("dashboard.pr_closed", actions::pr_set_closed(&s.deps.sections, req).await)
502}
503async fn pr_reviewers(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
504 let (conn, item) = q.item();
505 match actions::pr_reviewers(&s.deps.sections, &conn, &item).await {
506 Some(users) => Json(users).into_response(),
507 None => (StatusCode::NOT_FOUND, "pull request connection not found").into_response(),
508 }
509}
510async fn pr_request_reviewer(State(s): State<AppState>, Json(req): Json<actions::PrRequestReviewerReq>) -> Response {
511 action_response("dashboard.pr_request_reviewer", actions::pr_request_reviewer(&s.deps.sections, req).await)
512}
513
514async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
515 let (conn, item) = q.item();
516 match dto::wi_detail(&s.deps.sections, &conn, &item).await {
517 Some(detail) => Json(detail).into_response(),
518 None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
519 }
520}
521async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
522 let (conn, item) = q.item();
523 match actions::wi_states(&s.deps.sections, &conn, &item).await {
524 Some(states) => Json(states).into_response(),
525 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
526 }
527}
528async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
529 action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
530}
531async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
532 action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
533}
534async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
535 let (conn, item) = q.item();
536 match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
537 Some(users) => Json(users).into_response(),
538 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
539 }
540}
541async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
542 action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
543}
544async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
545 action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
546}
547
548async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
549 match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
550 Some(detail) => Json(detail).into_response(),
551 None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
552 }
553}
554async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
555 match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
556 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
557 None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
558 }
559}
560
561async fn pipeline_annotations(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
562 match dto::pipeline_annotations(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
563 Some(annotations) => Json(annotations).into_response(),
564 None => (StatusCode::NOT_FOUND, "annotations not available").into_response(),
565 }
566}
567async fn pipeline_artifacts(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
568 match dto::pipeline_artifacts(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
569 Some(artifacts) => Json(artifacts).into_response(),
570 None => (StatusCode::NOT_FOUND, "artifacts not available").into_response(),
571 }
572}
573
574async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
575 action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
576}
577async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
578 action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
579}
580async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
581 action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
582}
583async fn pipeline_rerun(State(s): State<AppState>, Json(req): Json<actions::PipelineRerunReq>) -> Response {
584 let result = actions::pipeline_rerun(&s.deps.sections, req).await;
585 action_response_with("dashboard.pipeline_rerun", result.map(|new_run_id| serde_json::json!({ "ok": true, "new_run_id": new_run_id })))
586}
587
588async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
589 action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
590}
591
592#[derive(Deserialize)]
595struct IdReq {
596 id: String,
597}
598
599async fn providers() -> Json<Vec<connections::ProviderInfo>> {
600 Json(connections::providers())
601}
602async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
603 Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
604}
605async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
606 match connections::save(&s.deps.config, &s.deps.sections, req).await {
607 Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
608 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
609 }
610}
611async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
612 match connections::remove(&s.deps.config, &req.id).await {
613 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
614 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
615 }
616}
617async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
618 match connections::test(&s.deps.health, &req.id).await {
619 Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
620 None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
621 }
622}
623
624async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
628 match s.deps.sections.discover_repositories(&q.id).await {
629 Ok(page) => Json(page).into_response(),
630 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
631 }
632}
633
634#[derive(Deserialize)]
635struct IdQuery {
636 id: String,
637}
638
639#[derive(Deserialize)]
640struct ScopeReq {
641 id: String,
642 scope: Vec<String>,
645}
646
647async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
648 match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
649 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
650 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
651 }
652}
653
654#[derive(serde::Serialize)]
658struct PipelineDefinitionsResp {
659 definitions: Vec<forgetop_core::domain::PipelineDefinition>,
660 all: bool,
661 selected: Vec<String>,
662}
663
664async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
665 let source = match s.deps.sections.pipeline_source_for(&q.id).await {
666 Ok(Some(source)) => source,
667 Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
668 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
669 };
670 let definitions = match source.discover().await {
671 Ok(defs) => defs,
672 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
673 };
674 let cfg = s.deps.config.snapshot();
675 let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
676 Json(PipelineDefinitionsResp {
677 definitions,
678 all: sub.is_some_and(|s| s.auto_discover_all),
679 selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
680 })
681 .into_response()
682}
683
684#[derive(Deserialize)]
685struct PipelineSelectionReq {
686 id: String,
687 #[serde(default)]
689 all: bool,
690 #[serde(default)]
692 ids: Vec<String>,
693}
694
695async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
696 let saved = if req.all {
697 s.deps.config.set_pipeline_auto_discover(&req.id, true).await
698 } else {
699 s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
700 };
701 match saved {
702 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
703 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
714 use forgetop_core::config::{ConfigStore, ForgetopConfig, InMemoryConfigStore};
715 use forgetop_core::domain::ProviderType;
716 use forgetop_core::provider::{Connection, ProviderRegistry};
717 use forgetop_core::secret::InMemorySecretStore;
718 use forgetop_core::service::ConnectionResolver;
719 use forgetop_providers::demo::demo_factories;
720
721 async fn demo_deps() -> Deps {
723 deps_with(Arc::new(CacheStore::disabled())).await
724 }
725
726 async fn deps_with(cache: Arc<CacheStore>) -> Deps {
727 let registry = Arc::new(ProviderRegistry::new(demo_factories()));
728 let store: Arc<dyn ConfigStore> = Arc::new(InMemoryConfigStore::new(ForgetopConfig::default()));
729 let secrets: Arc<dyn SecretStore> = Arc::new(InMemorySecretStore::default());
730 let config = Arc::new(ConfigService::new(store, secrets.clone(), registry.clone()));
731 config.load().await.unwrap();
732 let conn = Connection {
733 id: "github".into(),
734 provider_type: ProviderType::GitHub,
735 display_name: "github".into(),
736 base_url: None,
737 organization: None,
738 project: None,
739 repository: None,
740 username: None,
741 credential_ref: None,
742 repo_scope: None,
743 };
744 config.add_or_update_connection(conn, None).await.unwrap();
745 config.bind_pull_requests("github").await.unwrap();
746 config.bind_work_items("github").await.unwrap();
747 config.set_pipeline_auto_discover("github", true).await.unwrap();
748 let resolver = Arc::new(ConnectionResolver::new(config.clone(), registry, secrets.clone()));
749 let sections = Arc::new(SectionService::new(config.clone(), resolver.clone()));
750 let health = Arc::new(ConnectionHealthService::new(config.clone(), resolver));
751 Deps { sections, health, config, secrets, cache }
752 }
753
754 struct Api {
755 base: String,
756 token: String,
757 client: reqwest::Client,
758 }
759
760 impl Api {
761 async fn start() -> Api {
762 let server = spawn(demo_deps().await, 0).await.expect("server binds a free port");
763 Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
764 }
765
766 async fn start_with(deps: Deps) -> Api {
767 let server = spawn(deps, 0).await.expect("server binds a free port");
768 Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
769 }
770
771 async fn get_raw(&self, path: &str) -> reqwest::Response {
772 self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.expect("request")
773 }
774 async fn get(&self, path: &str) -> serde_json::Value {
775 let r = self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.unwrap();
776 assert_eq!(r.status(), 200, "GET {path}");
777 r.json().await.unwrap()
778 }
779 async fn post(&self, path: &str, body: serde_json::Value) -> u16 {
780 let r = self.client.post(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).json(&body).send().await.unwrap();
781 r.status().as_u16()
782 }
783 async fn open_prs(&self) -> Vec<(String, String)> {
785 let rows = self.get("/api/pull-requests").await;
786 rows.as_array()
787 .unwrap()
788 .iter()
789 .map(|r| (r["pull_request"]["id"].as_str().unwrap().to_string(), r["pull_request"]["status"].as_str().unwrap().to_string()))
790 .collect()
791 }
792 async fn detail(&self, id: &str) -> serde_json::Value {
793 self.get(&format!("/api/pr/detail?conn=github&id={id}")).await
794 }
795 }
796
797 static DEMO_PRS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
802
803 async fn open_pr(api: &Api, nth: usize) -> String {
804 let open: Vec<_> = api.open_prs().await.into_iter().filter(|(_, s)| s == "Open").collect();
805 open.get(nth).unwrap_or_else(|| panic!("demo has at least {} open PRs", nth + 1)).0.clone()
806 }
807
808 #[tokio::test]
809 async fn pr_detail_advertises_the_demo_writes() {
810 let _one_at_a_time = DEMO_PRS.lock().await;
811 let api = Api::start().await;
812 let id = open_pr(&api, 0).await;
813 let writes = &api.detail(&id).await["writes"];
814 for k in ["resolve_threads", "draft", "close", "reopen", "request_reviewer"] {
815 assert_eq!(writes[k], true, "demo supports {k}: {writes}");
816 }
817 }
818
819 #[tokio::test]
820 async fn resolving_a_thread_flips_it_on_the_next_detail() {
821 let _one_at_a_time = DEMO_PRS.lock().await;
822 let api = Api::start().await;
823 let id = open_pr(&api, 1).await;
824 let thread = |d: &serde_json::Value, t: &str| d["threads"].as_array().unwrap().iter().find(|x| x["id"] == t).cloned().unwrap();
825 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "t1 starts open");
826
827 let body = |resolved: bool| serde_json::json!({ "conn": "github", "id": id, "thread_id": "t1", "resolved": resolved });
828 assert_eq!(api.post("/api/pr/resolve-thread", body(true)).await, 200);
829 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], true, "resolve persists");
830 assert_eq!(api.post("/api/pr/resolve-thread", body(false)).await, 200);
831 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "reopen persists");
832 }
833
834 #[tokio::test]
835 async fn draft_and_close_change_the_pr_status() {
836 let _one_at_a_time = DEMO_PRS.lock().await;
837 let api = Api::start().await;
838 let id = open_pr(&api, 2).await;
839 let status = |d: serde_json::Value| d["pull_request"]["status"].as_str().unwrap().to_string();
840
841 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": true })).await, 200);
842 assert_eq!(status(api.detail(&id).await), "Draft", "converted to a draft");
843 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": false })).await, 200);
844 assert_eq!(status(api.detail(&id).await), "Open", "ready for review again");
845
846 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": true })).await, 200);
847 assert_eq!(status(api.detail(&id).await), "Closed");
848 assert!(!api.open_prs().await.iter().any(|(p, _)| *p == id), "a closed PR leaves the open list");
849 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": false })).await, 200);
850 assert_eq!(status(api.detail(&id).await), "Open", "reopened");
851 assert!(api.open_prs().await.iter().any(|(p, _)| *p == id), "and back on the open list");
852
853 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "nope", "id": id, "draft": true })).await, 404);
855 }
856
857 #[tokio::test]
858 async fn requesting_a_reviewer_adds_them_to_the_pr() {
859 let _one_at_a_time = DEMO_PRS.lock().await;
860 let api = Api::start().await;
861 let id = open_pr(&api, 3).await;
862 let users = api.get(&format!("/api/pr/reviewers?conn=github&id={id}")).await;
863 let users = users.as_array().expect("reviewers is a JSON array of users");
864 assert!(!users.is_empty(), "the demo has someone to ask");
865
866 let reviewer_ids = |d: &serde_json::Value| -> Vec<String> {
867 d["pull_request"]["reviewers"].as_array().unwrap().iter().map(|r| r["user"]["id"].as_str().unwrap().to_string()).collect()
868 };
869 let before = reviewer_ids(&api.detail(&id).await);
870 let pick = users.iter().map(|u| u["id"].as_str().unwrap().to_string()).find(|u| !before.contains(u)).expect("someone not yet reviewing");
871
872 assert_eq!(api.post("/api/pr/request-reviewer", serde_json::json!({ "conn": "github", "id": id, "user_id": pick })).await, 200);
873 let after = api.detail(&id).await;
874 let added = after["pull_request"]["reviewers"].as_array().unwrap().iter().find(|r| r["user"]["id"] == pick.as_str()).cloned();
875 let added = added.expect("the requested reviewer is on the PR");
876 assert_eq!(added["vote"], "NoVote", "requested, not yet voted");
877
878 let missing = api.client.get(format!("{}/api/pr/reviewers?conn=nope&id={id}", api.base)).header("x-forgetop-token", &api.token).send().await.unwrap();
879 assert_eq!(missing.status(), 404, "a bad connection is a 404");
880 }
881
882 fn scratch_cache() -> (Arc<CacheStore>, std::path::PathBuf) {
883 let path = std::env::temp_dir().join(format!("forgetop-api-cache-{}.json", uuid::Uuid::new_v4()));
884 (Arc::new(CacheStore::new(&path)), path)
885 }
886
887 #[tokio::test]
890 async fn lists_answer_from_the_shared_cache_then_refresh_behind_it() {
891 std::env::set_var("FORGETOP_CACHE_WAIT_MS", "0");
893 let (cache, path) = scratch_cache();
894 let api = Api::start_with(deps_with(cache.clone()).await).await;
895
896 let cold = api.get_raw("/api/work-items").await;
898 assert!(cold.headers().get(FETCHED_AT_HEADER).is_none(), "a live answer carries no cache date");
899 let live: Vec<serde_json::Value> = cold.json().await.unwrap();
900 assert!(!live.is_empty());
901 assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some(), "the live fetch wrote the TUI's key");
902
903 assert!(cache.rewrite::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS, |mut rows| {
905 rows[0].wi.title = "Left here by the terminal".into();
906 rows
907 }));
908 let warm = api.get_raw("/api/work-items").await;
909 let fetched_at = warm.headers().get(FETCHED_AT_HEADER).expect("a cached answer says when it was fetched").clone();
910 assert!(chrono::DateTime::parse_from_rfc3339(fetched_at.to_str().unwrap()).is_ok());
911 let rows: Vec<serde_json::Value> = warm.json().await.unwrap();
912 assert_eq!(rows[0]["work_item"]["title"], "Left here by the terminal");
913
914 let mut fresh = Vec::new();
916 for _ in 0..50 {
917 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
918 fresh = api.get_raw("/api/work-items").await.json::<Vec<serde_json::Value>>().await.unwrap();
919 if fresh[0]["work_item"]["title"] != "Left here by the terminal" {
920 break;
921 }
922 }
923 assert_ne!(fresh[0]["work_item"]["title"], "Left here by the terminal", "the live fetch wrote back");
924 std::env::remove_var("FORGETOP_CACHE_WAIT_MS");
925 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
927 assert!(path.exists(), "the cache was flushed to {}", path.display());
928 let _ = std::fs::remove_file(path);
929 }
930
931 #[tokio::test]
933 async fn live_fetches_write_the_terminal_ui_keys() {
934 let (cache, path) = scratch_cache();
935 let api = Api::start_with(deps_with(cache.clone()).await).await;
936 let _ = api.get_raw("/api/pull-requests?view=all").await;
937 let _ = api.get_raw("/api/pull-requests?view=review_requested").await;
938 let _ = api.get_raw("/api/launchpad").await;
939 let _ = api.get_raw("/api/notifications").await;
940 let all = cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::All, false)).expect("All view");
941 assert!(!all.value.is_empty());
942 assert!(cache.get::<Vec<cached::TuiPrRow>>(&cached::prs_key(forgetop_core::provider::PullRequestFilter::ReviewRequested, false)).is_some());
943 assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_MINE).is_some());
944 assert!(cache.get::<Vec<cached::TuiPrRow>>(cached::KEY_LAUNCHPAD_REVIEW).is_some());
945 assert!(cache.get::<Vec<cached::TuiWiRow>>(cached::KEY_WORK_ITEMS).is_some());
946 assert!(cache.get::<Vec<cached::TuiPipeRow>>(cached::KEY_PIPELINES).is_some());
947 assert!(cache.get::<Vec<dto::NotifRow>>(cached::KEY_INBOX).is_some());
948 let live: serde_json::Value = api.get_raw("/api/launchpad").await.json().await.unwrap();
950 let from_cache = cached::launchpad(&cache).expect("launchpad from cache");
951 assert_eq!(serde_json::to_value(&from_cache.value).unwrap(), live);
952 let _ = std::fs::remove_file(path);
953 }
954}