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/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/approval", post(pipeline_approval))
155 .route("/api/pipeline/trigger", post(pipeline_trigger))
156 .route("/api/pipeline/cancel", post(pipeline_cancel))
157 .route("/api/notification/read", post(notification_read))
158 .route("/api/providers", get(providers))
159 .route("/api/connections", get(list_connections).post(save_connection))
160 .route("/api/connections/delete", post(delete_connection))
161 .route("/api/connections/test", post(test_connection))
162 .route("/api/connections/repositories", get(connection_repositories))
163 .route("/api/connections/scope", post(set_connection_scope))
164 .route("/api/pipelines/definitions", get(pipeline_definitions))
165 .route("/api/pipelines/selection", post(set_pipeline_selection))
166 .layer(middleware::from_fn_with_state(state.clone(), auth))
167 .with_state(state);
168
169 Router::new().merge(api).fallback(static_asset)
170}
171
172async fn static_asset(uri: Uri) -> Response {
175 let path = uri.path().trim_start_matches('/');
176 let path = if path.is_empty() { "index.html" } else { path };
177 serve(path)
178 .or_else(|| serve("index.html"))
179 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
180}
181
182fn serve(path: &str) -> Option<Response> {
183 let file = Assets::get(path)?;
184 let mime = mime_guess::from_path(path).first_or_octet_stream();
185 Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
186}
187
188async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
192 if token_from(&req).as_deref() == Some(&state.token) {
193 next.run(req).await
194 } else {
195 (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
196 }
197}
198
199fn token_from(req: &Request) -> Option<String> {
200 if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
201 return Some(header.to_string());
202 }
203 req.uri()
204 .query()
205 .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
206}
207
208async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
209 Json(dto::health(&s.deps.health).await)
210}
211async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
212 Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
213}
214async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
215 Json(dto::work_items(&s.deps.sections).await)
216}
217async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
218 Json(dto::pipelines(&s.deps.sections).await)
219}
220async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
221 Json(dto::notifications(&s.deps.sections).await)
222}
223async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
224 Json(dto::launchpad(&s.deps.sections).await)
225}
226
227#[derive(Deserialize)]
233struct ItemQuery {
234 conn: String,
235 id: String,
236 #[serde(default)]
237 repo: Option<String>,
238}
239
240impl ItemQuery {
241 fn item(self) -> (String, ItemRef) {
242 (self.conn, ItemRef::maybe(self.repo, self.id))
243 }
244}
245
246#[derive(Deserialize)]
248struct CommitQuery {
249 conn: String,
250 id: String,
251 sha: String,
252 #[serde(default)]
253 repo: Option<String>,
254}
255
256#[derive(Deserialize)]
259struct FileTextQuery {
260 conn: String,
261 id: String,
262 path: String,
263 #[serde(default)]
264 sha: Option<String>,
265 #[serde(default)]
266 repo: Option<String>,
267}
268
269#[derive(Deserialize)]
271struct PrListQuery {
272 #[serde(default)]
273 view: Option<String>,
274}
275
276#[derive(Deserialize)]
278struct RunQuery {
279 conn: String,
280 run_id: String,
281 #[serde(default)]
282 repo: Option<String>,
283}
284
285#[derive(Deserialize)]
287struct PipelineLogsQuery {
288 conn: String,
289 run_id: String,
290 #[serde(default)]
291 repo: Option<String>,
292 #[serde(default)]
293 job: Option<String>,
294}
295
296fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
299 match result {
300 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
301 Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
302 Err(ActionError::Failed(msg)) => {
303 forgetop_core::diag::log(operation, "provider action failed");
304 (StatusCode::BAD_GATEWAY, msg).into_response()
305 }
306 }
307}
308
309async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
310 match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
311 Some(changes) => Json(changes).into_response(),
312 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
313 }
314}
315
316async fn pr_file_text(State(s): State<AppState>, Query(q): Query<FileTextQuery>) -> Response {
320 match dto::pr_file_text(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.path, q.sha.as_deref()).await {
321 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
322 None => (StatusCode::NOT_FOUND, "file text not available").into_response(),
323 }
324}
325
326async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
327 let (conn, item) = q.item();
328 match dto::pr_detail(&s.deps.sections, &conn, &item).await {
329 Some(detail) => Json(detail).into_response(),
330 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
331 }
332}
333
334#[derive(Deserialize)]
337struct DecorationQuery {
338 conn: String,
339 id: String,
340 #[serde(default)]
341 repo: Option<String>,
342 #[serde(default)]
343 updated_at: Option<String>,
344}
345
346async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
347 let item = ItemRef::maybe(q.repo, q.id);
348 let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
349 if let Some(hit) = s.decorations.get(&key) {
350 return Json(hit).into_response();
351 }
352 match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
353 Some(decoration) => {
354 s.decorations.put(key, decoration.clone());
355 Json(decoration).into_response()
356 }
357 None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
358 }
359}
360
361async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
362 action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
363}
364async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
365 action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
366}
367async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
368 action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
369}
370async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
371 action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
372}
373async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
374 action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
375}
376async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
377 action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
378}
379async fn pr_resolve_thread(State(s): State<AppState>, Json(req): Json<actions::PrResolveThreadReq>) -> Response {
380 action_response("dashboard.pr_resolve_thread", actions::pr_resolve_thread(&s.deps.sections, req).await)
381}
382async fn pr_draft(State(s): State<AppState>, Json(req): Json<actions::PrDraftReq>) -> Response {
383 action_response("dashboard.pr_draft", actions::pr_set_draft(&s.deps.sections, req).await)
384}
385async fn pr_closed(State(s): State<AppState>, Json(req): Json<actions::PrClosedReq>) -> Response {
386 action_response("dashboard.pr_closed", actions::pr_set_closed(&s.deps.sections, req).await)
387}
388async fn pr_reviewers(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
389 let (conn, item) = q.item();
390 match actions::pr_reviewers(&s.deps.sections, &conn, &item).await {
391 Some(users) => Json(users).into_response(),
392 None => (StatusCode::NOT_FOUND, "pull request connection not found").into_response(),
393 }
394}
395async fn pr_request_reviewer(State(s): State<AppState>, Json(req): Json<actions::PrRequestReviewerReq>) -> Response {
396 action_response("dashboard.pr_request_reviewer", actions::pr_request_reviewer(&s.deps.sections, req).await)
397}
398
399async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
400 let (conn, item) = q.item();
401 match dto::wi_detail(&s.deps.sections, &conn, &item).await {
402 Some(detail) => Json(detail).into_response(),
403 None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
404 }
405}
406async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
407 let (conn, item) = q.item();
408 match actions::wi_states(&s.deps.sections, &conn, &item).await {
409 Some(states) => Json(states).into_response(),
410 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
411 }
412}
413async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
414 action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
415}
416async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
417 action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
418}
419async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
420 let (conn, item) = q.item();
421 match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
422 Some(users) => Json(users).into_response(),
423 None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
424 }
425}
426async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
427 action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
428}
429async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
430 action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
431}
432
433async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
434 match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
435 Some(detail) => Json(detail).into_response(),
436 None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
437 }
438}
439async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
440 match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
441 Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
442 None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
443 }
444}
445
446async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
447 action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
448}
449async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
450 action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
451}
452async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
453 action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
454}
455
456async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
457 action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
458}
459
460#[derive(Deserialize)]
463struct IdReq {
464 id: String,
465}
466
467async fn providers() -> Json<Vec<connections::ProviderInfo>> {
468 Json(connections::providers())
469}
470async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
471 Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
472}
473async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
474 match connections::save(&s.deps.config, &s.deps.sections, req).await {
475 Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
476 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
477 }
478}
479async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
480 match connections::remove(&s.deps.config, &req.id).await {
481 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
482 Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
483 }
484}
485async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
486 match connections::test(&s.deps.health, &req.id).await {
487 Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
488 None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
489 }
490}
491
492async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
496 match s.deps.sections.discover_repositories(&q.id).await {
497 Ok(page) => Json(page).into_response(),
498 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
499 }
500}
501
502#[derive(Deserialize)]
503struct IdQuery {
504 id: String,
505}
506
507#[derive(Deserialize)]
508struct ScopeReq {
509 id: String,
510 scope: Vec<String>,
513}
514
515async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
516 match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
517 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
518 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
519 }
520}
521
522#[derive(serde::Serialize)]
526struct PipelineDefinitionsResp {
527 definitions: Vec<forgetop_core::domain::PipelineDefinition>,
528 all: bool,
529 selected: Vec<String>,
530}
531
532async fn pipeline_definitions(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
533 let source = match s.deps.sections.pipeline_source_for(&q.id).await {
534 Ok(Some(source)) => source,
535 Ok(None) => return (StatusCode::NOT_FOUND, "that connection doesn't support pipelines").into_response(),
536 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
537 };
538 let definitions = match source.discover().await {
539 Ok(defs) => defs,
540 Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
541 };
542 let cfg = s.deps.config.snapshot();
543 let sub = cfg.pipelines.as_ref().and_then(|p| p.subscriptions.iter().find(|s| s.connection_id == q.id));
544 Json(PipelineDefinitionsResp {
545 definitions,
546 all: sub.is_some_and(|s| s.auto_discover_all),
547 selected: sub.map(|s| s.definition_ids.clone()).unwrap_or_default(),
548 })
549 .into_response()
550}
551
552#[derive(Deserialize)]
553struct PipelineSelectionReq {
554 id: String,
555 #[serde(default)]
557 all: bool,
558 #[serde(default)]
560 ids: Vec<String>,
561}
562
563async fn set_pipeline_selection(State(s): State<AppState>, Json(req): Json<PipelineSelectionReq>) -> Response {
564 let saved = if req.all {
565 s.deps.config.set_pipeline_auto_discover(&req.id, true).await
566 } else {
567 s.deps.config.set_pipeline_definitions(&req.id, req.ids).await
568 };
569 match saved {
570 Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
571 Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
582 use forgetop_core::config::{ConfigStore, ForgetopConfig, InMemoryConfigStore};
583 use forgetop_core::domain::ProviderType;
584 use forgetop_core::provider::{Connection, ProviderRegistry};
585 use forgetop_core::secret::InMemorySecretStore;
586 use forgetop_core::service::ConnectionResolver;
587 use forgetop_providers::demo::demo_factories;
588
589 async fn demo_deps() -> Deps {
591 let registry = Arc::new(ProviderRegistry::new(demo_factories()));
592 let store: Arc<dyn ConfigStore> = Arc::new(InMemoryConfigStore::new(ForgetopConfig::default()));
593 let secrets: Arc<dyn SecretStore> = Arc::new(InMemorySecretStore::default());
594 let config = Arc::new(ConfigService::new(store, secrets.clone(), registry.clone()));
595 config.load().await.unwrap();
596 let conn = Connection {
597 id: "github".into(),
598 provider_type: ProviderType::GitHub,
599 display_name: "github".into(),
600 base_url: None,
601 organization: None,
602 project: None,
603 repository: None,
604 username: None,
605 credential_ref: None,
606 repo_scope: None,
607 };
608 config.add_or_update_connection(conn, None).await.unwrap();
609 config.bind_pull_requests("github").await.unwrap();
610 let resolver = Arc::new(ConnectionResolver::new(config.clone(), registry, secrets.clone()));
611 let sections = Arc::new(SectionService::new(config.clone(), resolver.clone()));
612 let health = Arc::new(ConnectionHealthService::new(config.clone(), resolver));
613 Deps { sections, health, config, secrets }
614 }
615
616 struct Api {
617 base: String,
618 token: String,
619 client: reqwest::Client,
620 }
621
622 impl Api {
623 async fn start() -> Api {
624 let server = spawn(demo_deps().await, 0).await.expect("server binds a free port");
625 Api { base: format!("http://127.0.0.1:{}", server.port), token: server.token, client: reqwest::Client::new() }
626 }
627 async fn get(&self, path: &str) -> serde_json::Value {
628 let r = self.client.get(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).send().await.unwrap();
629 assert_eq!(r.status(), 200, "GET {path}");
630 r.json().await.unwrap()
631 }
632 async fn post(&self, path: &str, body: serde_json::Value) -> u16 {
633 let r = self.client.post(format!("{}{path}", self.base)).header("x-forgetop-token", &self.token).json(&body).send().await.unwrap();
634 r.status().as_u16()
635 }
636 async fn open_prs(&self) -> Vec<(String, String)> {
638 let rows = self.get("/api/pull-requests").await;
639 rows.as_array()
640 .unwrap()
641 .iter()
642 .map(|r| (r["pull_request"]["id"].as_str().unwrap().to_string(), r["pull_request"]["status"].as_str().unwrap().to_string()))
643 .collect()
644 }
645 async fn detail(&self, id: &str) -> serde_json::Value {
646 self.get(&format!("/api/pr/detail?conn=github&id={id}")).await
647 }
648 }
649
650 async fn open_pr(api: &Api, nth: usize) -> String {
652 let open: Vec<_> = api.open_prs().await.into_iter().filter(|(_, s)| s == "Open").collect();
653 open.get(nth).unwrap_or_else(|| panic!("demo has at least {} open PRs", nth + 1)).0.clone()
654 }
655
656 #[tokio::test]
657 async fn pr_detail_advertises_the_demo_writes() {
658 let api = Api::start().await;
659 let id = open_pr(&api, 0).await;
660 let writes = &api.detail(&id).await["writes"];
661 for k in ["resolve_threads", "draft", "close", "reopen", "request_reviewer"] {
662 assert_eq!(writes[k], true, "demo supports {k}: {writes}");
663 }
664 }
665
666 #[tokio::test]
667 async fn resolving_a_thread_flips_it_on_the_next_detail() {
668 let api = Api::start().await;
669 let id = open_pr(&api, 1).await;
670 let thread = |d: &serde_json::Value, t: &str| d["threads"].as_array().unwrap().iter().find(|x| x["id"] == t).cloned().unwrap();
671 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "t1 starts open");
672
673 let body = |resolved: bool| serde_json::json!({ "conn": "github", "id": id, "thread_id": "t1", "resolved": resolved });
674 assert_eq!(api.post("/api/pr/resolve-thread", body(true)).await, 200);
675 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], true, "resolve persists");
676 assert_eq!(api.post("/api/pr/resolve-thread", body(false)).await, 200);
677 assert_eq!(thread(&api.detail(&id).await, "t1")["is_resolved"], false, "reopen persists");
678 }
679
680 #[tokio::test]
681 async fn draft_and_close_change_the_pr_status() {
682 let api = Api::start().await;
683 let id = open_pr(&api, 2).await;
684 let status = |d: serde_json::Value| d["pull_request"]["status"].as_str().unwrap().to_string();
685
686 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": true })).await, 200);
687 assert_eq!(status(api.detail(&id).await), "Draft", "converted to a draft");
688 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "github", "id": id, "draft": false })).await, 200);
689 assert_eq!(status(api.detail(&id).await), "Open", "ready for review again");
690
691 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": true })).await, 200);
692 assert_eq!(status(api.detail(&id).await), "Closed");
693 assert!(!api.open_prs().await.iter().any(|(p, _)| *p == id), "a closed PR leaves the open list");
694 assert_eq!(api.post("/api/pr/closed", serde_json::json!({ "conn": "github", "id": id, "closed": false })).await, 200);
695 assert_eq!(status(api.detail(&id).await), "Open", "reopened");
696 assert!(api.open_prs().await.iter().any(|(p, _)| *p == id), "and back on the open list");
697
698 assert_eq!(api.post("/api/pr/draft", serde_json::json!({ "conn": "nope", "id": id, "draft": true })).await, 404);
700 }
701
702 #[tokio::test]
703 async fn requesting_a_reviewer_adds_them_to_the_pr() {
704 let api = Api::start().await;
705 let id = open_pr(&api, 3).await;
706 let users = api.get(&format!("/api/pr/reviewers?conn=github&id={id}")).await;
707 let users = users.as_array().expect("reviewers is a JSON array of users");
708 assert!(!users.is_empty(), "the demo has someone to ask");
709
710 let reviewer_ids = |d: &serde_json::Value| -> Vec<String> {
711 d["pull_request"]["reviewers"].as_array().unwrap().iter().map(|r| r["user"]["id"].as_str().unwrap().to_string()).collect()
712 };
713 let before = reviewer_ids(&api.detail(&id).await);
714 let pick = users.iter().map(|u| u["id"].as_str().unwrap().to_string()).find(|u| !before.contains(u)).expect("someone not yet reviewing");
715
716 assert_eq!(api.post("/api/pr/request-reviewer", serde_json::json!({ "conn": "github", "id": id, "user_id": pick })).await, 200);
717 let after = api.detail(&id).await;
718 let added = after["pull_request"]["reviewers"].as_array().unwrap().iter().find(|r| r["user"]["id"] == pick.as_str()).cloned();
719 let added = added.expect("the requested reviewer is on the PR");
720 assert_eq!(added["vote"], "NoVote", "requested, not yet voted");
721
722 let missing = api.client.get(format!("{}/api/pr/reviewers?conn=nope&id={id}", api.base)).header("x-forgetop-token", &api.token).send().await.unwrap();
723 assert_eq!(missing.status(), 404, "a bad connection is a 404");
724 }
725}