use std::net::Ipv4Addr;
use std::sync::Arc;
use axum::extract::{Query, Request, State};
use axum::http::{header, StatusCode, Uri};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::Router;
use forgetop_core::provider::{ItemRef, PrDecoration};
use forgetop_core::secret::SecretStore;
use forgetop_core::service::{ConfigService, ConnectionHealthService, SectionService};
use rust_embed::RustEmbed;
use serde::Deserialize;
use crate::actions::ActionError;
mod actions;
mod connections;
mod dto;
#[derive(RustEmbed)]
#[folder = "web/dist"]
struct Assets;
pub const DEFAULT_PORT: u16 = 8177;
#[derive(Clone)]
pub struct Deps {
pub sections: Arc<SectionService>,
pub health: Arc<ConnectionHealthService>,
pub config: Arc<ConfigService>,
pub secrets: Arc<dyn SecretStore>,
}
pub struct Server {
pub port: u16,
pub token: String,
pub url: String,
}
#[derive(Default)]
struct DecorationCache {
entries: std::sync::Mutex<std::collections::HashMap<String, PrDecoration>>,
}
impl DecorationCache {
const MAX: usize = 500;
fn key(conn: &str, item: &ItemRef, updated_at: Option<&str>) -> String {
format!("{conn}\u{1}{}\u{1}{}\u{1}{}", item.repo.as_deref().unwrap_or(""), item.id, updated_at.unwrap_or(""))
}
fn get(&self, key: &str) -> Option<PrDecoration> {
self.entries.lock().unwrap().get(key).cloned()
}
fn put(&self, key: String, value: PrDecoration) {
let mut entries = self.entries.lock().unwrap();
if entries.len() >= Self::MAX {
entries.clear();
}
entries.insert(key, value);
}
}
#[derive(Clone)]
struct AppState {
deps: Deps,
token: Arc<str>,
decorations: Arc<DecorationCache>,
}
async fn bind(deps: Deps, port: u16) -> std::io::Result<(tokio::net::TcpListener, Server, AppState)> {
let token = uuid::Uuid::new_v4().to_string();
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await?;
let bound = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{bound}/?t={token}");
let state = AppState { deps, token: Arc::from(token.as_str()), decorations: Arc::new(DecorationCache::default()) };
Ok((listener, Server { port: bound, token, url }, state))
}
pub async fn spawn(deps: Deps, port: u16) -> std::io::Result<Server> {
let (listener, server, state) = bind(deps, port).await?;
tokio::spawn(async move {
let _ = axum::serve(listener, router(state)).await;
});
Ok(server)
}
pub async fn serve_blocking(deps: Deps, port: u16, on_ready: impl FnOnce(&str)) -> std::io::Result<()> {
let (listener, server, state) = bind(deps, port).await?;
on_ready(&server.url);
axum::serve(listener, router(state)).await
}
fn router(state: AppState) -> Router {
let api = Router::new()
.route("/api/health", get(health))
.route("/api/pull-requests", get(pull_requests))
.route("/api/work-items", get(work_items))
.route("/api/pipelines", get(pipelines))
.route("/api/notifications", get(notifications))
.route("/api/launchpad", get(launchpad))
.route("/api/pr/detail", get(pr_detail))
.route("/api/pr/decoration", get(pr_decoration))
.route("/api/pr/commit-changes", get(pr_commit_changes))
.route("/api/pr/vote", post(pr_vote))
.route("/api/pr/merge", post(pr_merge))
.route("/api/pr/revert", post(pr_revert))
.route("/api/pr/comment", post(pr_comment))
.route("/api/pr/reply", post(pr_reply))
.route("/api/pr/review", post(pr_review))
.route("/api/wi/detail", get(wi_detail))
.route("/api/wi/states", get(wi_states))
.route("/api/wi/state", post(wi_state))
.route("/api/wi/comment", post(wi_comment))
.route("/api/wi/assignees", get(wi_assignees))
.route("/api/wi/assignee", post(wi_assignee))
.route("/api/wi/update", post(wi_update))
.route("/api/pipeline/detail", get(pipeline_detail))
.route("/api/pipeline/logs", get(pipeline_logs))
.route("/api/pipeline/approval", post(pipeline_approval))
.route("/api/pipeline/trigger", post(pipeline_trigger))
.route("/api/pipeline/cancel", post(pipeline_cancel))
.route("/api/notification/read", post(notification_read))
.route("/api/providers", get(providers))
.route("/api/connections", get(list_connections).post(save_connection))
.route("/api/connections/delete", post(delete_connection))
.route("/api/connections/test", post(test_connection))
.route("/api/connections/repositories", get(connection_repositories))
.route("/api/connections/scope", post(set_connection_scope))
.layer(middleware::from_fn_with_state(state.clone(), auth))
.with_state(state);
Router::new().merge(api).fallback(static_asset)
}
async fn static_asset(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
serve(path)
.or_else(|| serve("index.html"))
.unwrap_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())
}
fn serve(path: &str) -> Option<Response> {
let file = Assets::get(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
Some(([(header::CONTENT_TYPE, mime.as_ref().to_string())], file.data).into_response())
}
async fn auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
if token_from(&req).as_deref() == Some(&state.token) {
next.run(req).await
} else {
(StatusCode::UNAUTHORIZED, "unauthorized").into_response()
}
}
fn token_from(req: &Request) -> Option<String> {
if let Some(header) = req.headers().get("x-forgetop-token").and_then(|v| v.to_str().ok()) {
return Some(header.to_string());
}
req.uri()
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("t=").map(str::to_string)))
}
async fn health(State(s): State<AppState>) -> Json<Vec<dto::HealthRow>> {
Json(dto::health(&s.deps.health).await)
}
async fn pull_requests(State(s): State<AppState>, Query(q): Query<PrListQuery>) -> Json<Vec<dto::PrRow>> {
Json(dto::pull_requests(&s.deps.sections, dto::PrView::parse(q.view.as_deref())).await)
}
async fn work_items(State(s): State<AppState>) -> Json<Vec<dto::WiRow>> {
Json(dto::work_items(&s.deps.sections).await)
}
async fn pipelines(State(s): State<AppState>) -> Json<Vec<dto::PipeRow>> {
Json(dto::pipelines(&s.deps.sections).await)
}
async fn notifications(State(s): State<AppState>) -> Json<Vec<dto::NotifRow>> {
Json(dto::notifications(&s.deps.sections).await)
}
async fn launchpad(State(s): State<AppState>) -> Json<dto::LaunchpadResponse> {
Json(dto::launchpad(&s.deps.sections).await)
}
#[derive(Deserialize)]
struct ItemQuery {
conn: String,
id: String,
#[serde(default)]
repo: Option<String>,
}
impl ItemQuery {
fn item(self) -> (String, ItemRef) {
(self.conn, ItemRef::maybe(self.repo, self.id))
}
}
#[derive(Deserialize)]
struct CommitQuery {
conn: String,
id: String,
sha: String,
#[serde(default)]
repo: Option<String>,
}
#[derive(Deserialize)]
struct PrListQuery {
#[serde(default)]
view: Option<String>,
}
#[derive(Deserialize)]
struct RunQuery {
conn: String,
run_id: String,
#[serde(default)]
repo: Option<String>,
}
#[derive(Deserialize)]
struct PipelineLogsQuery {
conn: String,
run_id: String,
#[serde(default)]
repo: Option<String>,
#[serde(default)]
job: Option<String>,
}
fn action_response(operation: &'static str, result: Result<(), ActionError>) -> Response {
match result {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(ActionError::NotFound) => (StatusCode::NOT_FOUND, "connection or capability not found").into_response(),
Err(ActionError::Failed(msg)) => {
forgetop_core::diag::log(operation, "provider action failed");
(StatusCode::BAD_GATEWAY, msg).into_response()
}
}
}
async fn pr_commit_changes(State(s): State<AppState>, Query(q): Query<CommitQuery>) -> Response {
match dto::pr_commit_changes(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.id), &q.sha).await {
Some(changes) => Json(changes).into_response(),
None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
}
}
async fn pr_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
let (conn, item) = q.item();
match dto::pr_detail(&s.deps.sections, &conn, &item).await {
Some(detail) => Json(detail).into_response(),
None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
}
}
#[derive(Deserialize)]
struct DecorationQuery {
conn: String,
id: String,
#[serde(default)]
repo: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
async fn pr_decoration(State(s): State<AppState>, Query(q): Query<DecorationQuery>) -> Response {
let item = ItemRef::maybe(q.repo, q.id);
let key = DecorationCache::key(&q.conn, &item, q.updated_at.as_deref());
if let Some(hit) = s.decorations.get(&key) {
return Json(hit).into_response();
}
match dto::pr_decoration(&s.deps.sections, &q.conn, &item).await {
Some(decoration) => {
s.decorations.put(key, decoration.clone());
Json(decoration).into_response()
}
None => (StatusCode::NOT_FOUND, "pull request not found").into_response(),
}
}
async fn pr_vote(State(s): State<AppState>, Json(req): Json<actions::PrVoteReq>) -> Response {
action_response("dashboard.pr_vote", actions::pr_vote(&s.deps.sections, req).await)
}
async fn pr_merge(State(s): State<AppState>, Json(req): Json<actions::PrMergeReq>) -> Response {
action_response("dashboard.pr_merge", actions::pr_merge(&s.deps.sections, req).await)
}
async fn pr_revert(State(s): State<AppState>, Json(req): Json<actions::PrRevertReq>) -> Response {
action_response("dashboard.pr_revert", actions::pr_revert(&s.deps.sections, req).await)
}
async fn pr_comment(State(s): State<AppState>, Json(req): Json<actions::PrCommentReq>) -> Response {
action_response("dashboard.pr_comment", actions::pr_comment(&s.deps.sections, req).await)
}
async fn pr_reply(State(s): State<AppState>, Json(req): Json<actions::PrReplyReq>) -> Response {
action_response("dashboard.pr_reply", actions::pr_reply(&s.deps.sections, req).await)
}
async fn pr_review(State(s): State<AppState>, Json(req): Json<actions::PrReviewReq>) -> Response {
action_response("dashboard.pr_review", actions::pr_review(&s.deps.sections, req).await)
}
async fn wi_detail(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
let (conn, item) = q.item();
match dto::wi_detail(&s.deps.sections, &conn, &item).await {
Some(detail) => Json(detail).into_response(),
None => (StatusCode::NOT_FOUND, "work item not found").into_response(),
}
}
async fn wi_states(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
let (conn, item) = q.item();
match actions::wi_states(&s.deps.sections, &conn, &item).await {
Some(states) => Json(states).into_response(),
None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
}
}
async fn wi_state(State(s): State<AppState>, Json(req): Json<actions::WiStateReq>) -> Response {
action_response("dashboard.wi_state", actions::wi_set_state(&s.deps.sections, req).await)
}
async fn wi_comment(State(s): State<AppState>, Json(req): Json<actions::WiCommentReq>) -> Response {
action_response("dashboard.wi_comment", actions::wi_comment(&s.deps.sections, req).await)
}
async fn wi_assignees(State(s): State<AppState>, Query(q): Query<ItemQuery>) -> Response {
let (conn, item) = q.item();
match actions::wi_assignees(&s.deps.sections, &conn, &item).await {
Some(users) => Json(users).into_response(),
None => (StatusCode::NOT_FOUND, "work item connection not found").into_response(),
}
}
async fn wi_assignee(State(s): State<AppState>, Json(req): Json<actions::WiAssigneeReq>) -> Response {
action_response("dashboard.wi_assignee", actions::wi_set_assignee(&s.deps.sections, req).await)
}
async fn wi_update(State(s): State<AppState>, Json(req): Json<actions::WiUpdateReq>) -> Response {
action_response("dashboard.wi_update", actions::wi_update(&s.deps.sections, req).await)
}
async fn pipeline_detail(State(s): State<AppState>, Query(q): Query<RunQuery>) -> Response {
match dto::pipeline_detail(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id)).await {
Some(detail) => Json(detail).into_response(),
None => (StatusCode::NOT_FOUND, "pipeline run not found").into_response(),
}
}
async fn pipeline_logs(State(s): State<AppState>, Query(q): Query<PipelineLogsQuery>) -> Response {
match dto::pipeline_logs(&s.deps.sections, &q.conn, &ItemRef::maybe(q.repo, q.run_id), q.job.as_deref()).await {
Some(text) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response(),
None => (StatusCode::NOT_FOUND, "logs not available").into_response(),
}
}
async fn pipeline_approval(State(s): State<AppState>, Json(req): Json<actions::PipelineApprovalReq>) -> Response {
action_response("dashboard.pipeline_approval", actions::pipeline_approval(&s.deps.sections, req).await)
}
async fn pipeline_trigger(State(s): State<AppState>, Json(req): Json<actions::PipelineTriggerReq>) -> Response {
action_response("dashboard.pipeline_trigger", actions::pipeline_trigger(&s.deps.sections, req).await)
}
async fn pipeline_cancel(State(s): State<AppState>, Json(req): Json<actions::PipelineCancelReq>) -> Response {
action_response("dashboard.pipeline_cancel", actions::pipeline_cancel(&s.deps.sections, req).await)
}
async fn notification_read(State(s): State<AppState>, Json(req): Json<actions::NotifReadReq>) -> Response {
action_response("dashboard.notification_read", actions::notif_read(&s.deps.sections, req).await)
}
#[derive(Deserialize)]
struct IdReq {
id: String,
}
async fn providers() -> Json<Vec<connections::ProviderInfo>> {
Json(connections::providers())
}
async fn list_connections(State(s): State<AppState>) -> Json<Vec<connections::ConnectionRow>> {
Json(connections::list(&s.deps.config, s.deps.secrets.as_ref()))
}
async fn save_connection(State(s): State<AppState>, Json(req): Json<connections::SaveConnectionReq>) -> Response {
match connections::save(&s.deps.config, &s.deps.sections, req).await {
Ok(id) => Json(serde_json::json!({ "ok": true, "id": id })).into_response(),
Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
}
}
async fn delete_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
match connections::remove(&s.deps.config, &req.id).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(msg) => (StatusCode::BAD_GATEWAY, msg).into_response(),
}
}
async fn test_connection(State(s): State<AppState>, Json(req): Json<IdReq>) -> Response {
match connections::test(&s.deps.health, &req.id).await {
Some(healthy) => Json(serde_json::json!({ "healthy": healthy })).into_response(),
None => (StatusCode::NOT_FOUND, "connection not found").into_response(),
}
}
async fn connection_repositories(State(s): State<AppState>, Query(q): Query<IdQuery>) -> Response {
match s.deps.sections.discover_repositories(&q.id).await {
Ok(page) => Json(page).into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
struct IdQuery {
id: String,
}
#[derive(Deserialize)]
struct ScopeReq {
id: String,
scope: Vec<String>,
}
async fn set_connection_scope(State(s): State<AppState>, Json(req): Json<ScopeReq>) -> Response {
match s.deps.config.set_repo_scope(&req.id, Some(req.scope)).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
}
}