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::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#[derive(RustEmbed)]
30#[folder = "web/dist"]
31struct Assets;
32
33pub const DEFAULT_PORT: u16 = 8177;
35
36#[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
46pub struct Server {
48 pub port: u16,
49 pub token: String,
50 pub url: String,
52}
53
54#[derive(Default)]
59struct DecorationCache {
60 entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
61}
62
63impl DecorationCache {
64 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
101pub 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
111pub 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 let api = Router::new()
124 .route("/api/health", get(health))
125 .route("/api/pull-requests", get(pull_requests))
126 .route("/api/work-items", get(work_items))
127 .route("/api/pipelines", get(pipelines))
128 .route("/api/notifications", get(notifications))
129 .route("/api/launchpad", get(launchpad))
130 .route("/api/pr/detail", get(pr_detail))
131 .route("/api/pr/decoration", get(pr_decoration))
132 .route("/api/pr/commit-changes", get(pr_commit_changes))
133 .route("/api/pr/vote", post(pr_vote))
134 .route("/api/pr/merge", post(pr_merge))
135 .route("/api/pr/revert", post(pr_revert))
136 .route("/api/pr/comment", post(pr_comment))
137 .route("/api/pr/reply", post(pr_reply))
138 .route("/api/pr/review", post(pr_review))
139 .route("/api/wi/detail", get(wi_detail))
140 .route("/api/wi/states", get(wi_states))
141 .route("/api/wi/state", post(wi_state))
142 .route("/api/wi/comment", post(wi_comment))
143 .route("/api/wi/assignees", get(wi_assignees))
144 .route("/api/wi/assignee", post(wi_assignee))
145 .route("/api/wi/update", post(wi_update))
146 .route("/api/pipeline/detail", get(pipeline_detail))
147 .route("/api/pipeline/logs", get(pipeline_logs))
148 .route("/api/pipeline/approval", post(pipeline_approval))
149 .route("/api/pipeline/trigger", post(pipeline_trigger))
150 .route("/api/pipeline/cancel", post(pipeline_cancel))
151 .route("/api/notification/read", post(notification_read))
152 .route("/api/providers", get(providers))
153 .route("/api/connections", get(list_connections).post(save_connection))
154 .route("/api/connections/delete", post(delete_connection))
155 .route("/api/connections/test", post(test_connection))
156 .route("/api/connections/repositories", get(connection_repositories))
157 .route("/api/connections/scope", post(set_connection_scope))
158 .route("/api/pipelines/definitions", get(pipeline_definitions))
159 .route("/api/pipelines/selection", post(set_pipeline_selection))
160 .layer(middleware::from_fn_with_state(state.clone(), auth))
161 .with_state(state);
162
163 Router::new().merge(api).fallback(static_asset)
164}
165
166async fn static_asset(uri: Uri) -> Response {
169 let path = uri.path().trim_start_matches('/');
170 let path = if path.is_empty() { "index.html" } else { path };
171 serve(path)
172 .or_else(|| serve("index.html"))
173 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
174}
175
176fn serve(path: &str) -> Option<Response> {
177 let file = Assets::get(path)?;
178 let mime = mime_guess::from_path(path).first_or_octet_stream();
179 Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
180}
181
182async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
186 if token_from(&req).as_deref() == Some(&state.token) {
187 next.run(req).await
188 } else {
189 (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
190 }
191}
192
193fn token_from(req: &Request) -> Option<String> {
194 if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
195 return Some(header.to_string());
196 }
197 req.uri()
198 .query()
199 .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
200}
201
202async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
203 Json(dto::health(&s.deps.health).await)
204}
205async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
206 Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
207}
208async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
209 Json(dto::work_items(&s.deps.sections).await)
210}
211async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
212 Json(dto::pipelines(&s.deps.sections).await)
213}
214async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
215 Json(dto::notifications(&s.deps.sections).await)
216}
217async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
218 Json(dto::launchpad(&s.deps.sections).await)
219}
220
221#[derive(Deserialize)]
227struct ItemQuery {
228 conn: String,
229 id: String,
230 #[serde(default)]
231 repo: Option<String>,
232}
233
234impl ItemQuery {
235 fn item(self) -> (String, ItemRef) {
236 (self.conn, ItemRef::maybe(self.repo, self.id))
237 }
238}
239
240#[derive(Deserialize)]
242struct CommitQuery {
243 conn: String,
244 id: String,
245 sha: String,
246 #[serde(default)]
247 repo: Option<String>,
248}
249
250#[derive(Deserialize)]
252struct PrListQuery {
253 #[serde(default)]
254 view: Option<String>,
255}
256
257#[derive(Deserialize)]
259struct RunQuery {
260 conn: String,
261 run_id: String,
262 #[serde(default)]
263 repo: Option<String>,
264}
265
266#[derive(Deserialize)]
268struct PipelineLogsQuery {
269 conn: String,
270 run_id: String,
271 #[serde(default)]
272 repo: Option<String>,
273 #[serde(default)]
274 job: Option<String>,
275}
276
277fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
280 match result {
281 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
282 Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
283 Err(ActionError::Failed(msg)) => {
284 forgetop_core::diag::log(operation, "provider action failed");
285 (StatusCode::BAD_GATEWAY, msg).into_response()
286 }
287 }
288}
289
290async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
291 match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
292 Some(changes) => Json(changes).into_response(),
293 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
294 }
295}
296
297async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
298 let (conn, item) = q.item();
299 match dto::pr_detail(&s.deps.sections, &conn, &item).await {
300 Some(detail) => Json(detail).into_response(),
301 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
302 }
303}
304
305#[derive(Deserialize)]
308struct DecorationQuery {
309 conn: String,
310 id: String,
311 #[serde(default)]
312 repo: Option<String>,
313 #[serde(default)]
314 updated_at: Option<String>,
315}
316
317async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
318 let item = ItemRef::maybe(q.repo, q.id);
319 let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
320 if let Some(hit) = s.decorations.get(&key) {
321 return Json(hit).into_response();
322 }
323 match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
324 Some(decoration) => {
325 s.decorations.put(key, decoration.clone());
326 Json(decoration).into_response()
327 }
328 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
329 }
330}
331
332async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
333 action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
334}
335async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
336 action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
337}
338async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
339 action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
340}
341async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
342 action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
343}
344async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
345 action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
346}
347async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
348 action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
349}
350
351async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
352 let (conn, item) = q.item();
353 match dto::wi_detail(&s.deps.sections, &conn, &item).await {
354 Some(detail) => Json(detail).into_response(),
355 None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
356 }
357}
358async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
359 let (conn, item) = q.item();
360 match actions::wi_states(&s.deps.sections, &conn, &item).await {
361 Some(states) => Json(states).into_response(),
362 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
363 }
364}
365async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
366 action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
367}
368async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
369 action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
370}
371async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
372 let (conn, item) = q.item();
373 match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
374 Some(users) => Json(users).into_response(),
375 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
376 }
377}
378async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
379 action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
380}
381async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
382 action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
383}
384
385async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
386 match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
387 Some(detail) => Json(detail).into_response(),
388 None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
389 }
390}
391async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
392 match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
393 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
394 None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
395 }
396}
397
398async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
399 action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
400}
401async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
402 action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
403}
404async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
405 action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
406}
407
408async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
409 action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
410}
411
412#[derive(Deserialize)]
415struct IdReq {
416 id: String,
417}
418
419async fn providers() -> Json<Vec<connections::ProviderInfo>> {
420 Json(connections::providers())
421}
422async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
423 Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
424}
425async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
426 match connections::save(&s.deps.config, &s.deps.sections, req).await {
427 Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
428 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
429 }
430}
431async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
432 match connections::remove(&s.deps.config, &req.id).await {
433 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
434 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
435 }
436}
437async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
438 match connections::test(&s.deps.health, &req.id).await {
439 Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
440 None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
441 }
442}
443
444async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
448 match s.deps.sections.discover_repositories(&q.id).await {
449 Ok(page) => Json(page).into_response(),
450 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
451 }
452}
453
454#[derive(Deserialize)]
455struct IdQuery {
456 id: String,
457}
458
459#[derive(Deserialize)]
460struct ScopeReq {
461 id: String,
462 scope: Vec<String>,
465}
466
467async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
468 match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
469 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
470 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
471 }
472}
473
474#[derive(serde::Serialize)]
478struct PipelineDefinitionsResp {
479 definitions: Vec<forgetop_core::domain::PipelineDefinition>,
480 all: bool,
481 selected: Vec<String>,
482}
483
484async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
485 let source = match s.deps.sections.pipeline_source_for(&q.id).await {
486 Ok(Some(source)) => source,
487 Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
488 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
489 };
490 let definitions = match source.discover().await {
491 Ok(defs) => defs,
492 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
493 };
494 let cfg = s.deps.config.snapshot();
495 let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
496 Json(PipelineDefinitionsResp {
497 definitions,
498 all: sub.is_some_and(|s| s.auto_discover_all),
499 selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
500 })
501 .into_response()
502}
503
504#[derive(Deserialize)]
505struct PipelineSelectionReq {
506 id: String,
507 #[serde(default)]
509 all: bool,
510 #[serde(default)]
512 ids: Vec<String>,
513}
514
515async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
516 let saved = if req.all {
517 s.deps.config.set_pipeline_auto_discover(&req.id, true).await
518 } else {
519 s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
520 };
521 match saved {
522 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
523 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
524 }
525}