//! Repos — workspace-scoped view of connected git providers and the //! repositories they own. //! //! POST /api/repos/connections create a provider connection //! GET /api/repos/connections list connections //! DELETE /api/repos/connections/:id remove a connection (cascades to repos) //! POST /api/repos/connections/:id/sync refetch repos from the provider //! GET /api/repos list every repo in the workspace //! GET /api/repos/:id one repo detail //! //! Provider credentials (PATs) live in the secret broker; only their ref is //! kept in `app_connections`. Repo fetching goes through //! `BrokerClient::fetch_authorized`, so the PAT never leaves the broker. //! v1 supports GitHub inline; Gitea + GitLab are next (mostly a URL swap + //! response shape adapter). use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::format_description::well_known::Rfc3339; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; #[derive(Deserialize)] pub struct CreateConnectionRequest { /// `github` | `gitea` | `gitlab` pub provider: String, pub token: String, /// Optional org/user login. When absent GitHub pulls `/user/repos`. #[serde(default)] pub owner: Option, /// Optional base URL for self-hosted providers. #[serde(default)] pub base_url: Option, /// Optional human label — defaults to a "/" pattern. #[serde(default)] pub label: Option, } #[derive(Serialize)] pub struct ConnectionSummary { pub id: String, pub provider: String, pub owner: Option, pub base_url: Option, pub label: String, pub status: String, pub last_synced_at: Option, pub last_sync_error: Option, pub created_at: String, } fn known_provider(p: &str) -> bool { matches!(p, "github" | "gitea" | "gitlab") } fn default_label(provider: &str, owner: Option<&str>) -> String { match owner { Some(o) => format!("{provider}/{o}"), None => provider.to_string(), } } /// `POST /api/repos/connections` — store the PAT in the broker, create the /// app_connections + repo_connections rows, and kick off an initial sync. pub async fn create_connection( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let provider = body.provider.trim().to_lowercase(); if !known_provider(&provider) { return Err(ApiError::BadRequest); } if body.token.trim().is_empty() { return Err(ApiError::BadRequest); } let owner = body .owner .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let base_url = body .base_url .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let label = body .label .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) .unwrap_or_else(|| default_label(&provider, owner)); // Stash the PAT in the broker; only the ref persists in Postgres. let socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?; let mut broker = cm_secrets::BrokerClient::connect(socket) .await .map_err(|_| ApiError::Internal)?; let secret_ref = broker .store_secret( user.workspace_id, &format!("{provider}_pat"), body.token.trim(), ) .await .map_err(|_| ApiError::Internal)?; let app_conn = cm_db::repo::connections::insert( &state.pool, user.workspace_id, None, &provider, "keys", secret_ref, ) .await?; let repo_conn_id = cm_db::repo::repo_connections::insert( &state.pool, cm_db::repo::repo_connections::NewRepoConnection { workspace_id: user.workspace_id, app_connection_id: app_conn.id, provider: &provider, owner, base_url, label: &label, }, ) .await?; // First sync — best-effort. Errors mark the connection with the message; // the caller can retry via POST /:id/sync. let sync_result = sync_connection(&state, user.workspace_id, repo_conn_id, secret_ref).await; let (synced, sync_error) = match sync_result { Ok(n) => (n, None), Err(e) => (0, Some(e)), }; let _ = cm_db::repo::repo_connections::mark_synced( &state.pool, repo_conn_id, sync_error.as_deref(), ) .await; Ok(( StatusCode::CREATED, Json(serde_json::json!({ "id": repo_conn_id.to_string(), "provider": provider, "owner": owner, "synced": synced, "sync_error": sync_error, })), )) } /// `GET /api/repos/connections` — list every provider connection in the workspace. pub async fn list_connections( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::repo_connections::list_for_workspace(&state.pool, user.workspace_id).await?; let out = rows .into_iter() .map(|c| ConnectionSummary { id: c.id.to_string(), provider: c.provider, owner: c.owner, base_url: c.base_url, label: c.label, status: match c.last_sync_error.as_deref() { Some(_) => "error".into(), None if c.last_synced_at.is_some() => "connected".into(), None => "pending".into(), }, last_synced_at: c.last_synced_at.and_then(|t| t.format(&Rfc3339).ok()), last_sync_error: c.last_sync_error, created_at: c.created_at.format(&Rfc3339).unwrap_or_default(), }) .collect(); Ok(Json(out)) } /// `GET /api/repos/connections/:id` — full detail for the edit modal. pub async fn get_connection( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let c = cm_db::repo::repo_connections::get(&state.pool, id, user.workspace_id).await?; Ok(Json(ConnectionSummary { id: c.id.to_string(), provider: c.provider, owner: c.owner, base_url: c.base_url, label: c.label, status: match c.last_sync_error.as_deref() { Some(_) => "error".into(), None if c.last_synced_at.is_some() => "connected".into(), None => "pending".into(), }, last_synced_at: c.last_synced_at.and_then(|t| t.format(&Rfc3339).ok()), last_sync_error: c.last_sync_error, created_at: c.created_at.format(&Rfc3339).unwrap_or_default(), })) } /// `PATCH /api/repos/connections/:id` — edit owner / base_url / label on an /// existing connection. Any field omitted from the body is left as-is; /// explicit `null` on `owner` or `base_url` clears the value. Rotating the /// PAT is out-of-band: delete + re-create through the wizard. /// /// The response is the freshly-loaded connection so the client can react to /// derived fields (`status`, `last_sync_error` cleared by a preceding sync). pub async fn update_connection( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let owner = body .owner .map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())); let base_url = body .base_url .map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())); let label = body .label .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let owner_ref = owner.as_ref().map(|opt| opt.as_deref()); let base_url_ref = base_url.as_ref().map(|opt| opt.as_deref()); let ok = cm_db::repo::repo_connections::update( &state.pool, id, user.workspace_id, owner_ref, base_url_ref, label, ) .await?; if !ok { return Err(ApiError::NotFound); } get_connection(State(state), Authed(user), Path(id)).await } #[derive(Deserialize)] pub struct UpdateConnectionRequest { /// `Some(None)` clears; `None` leaves unchanged. #[serde(default, deserialize_with = "de_double_option")] pub owner: Option>, #[serde(default, deserialize_with = "de_double_option")] pub base_url: Option>, #[serde(default)] pub label: Option, } // serde default treats a missing field as `None` and an explicit `null` as // `Some(None)` when the target type is Option>. Manual deserializer // is needed because serde otherwise conflates the two. fn de_double_option<'de, D>(d: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { Option::>::deserialize(d) } /// `DELETE /api/repos/connections/:id` — remove the repo_connections row. /// `ON DELETE CASCADE` cleans out its repos; the underlying app_connections /// row + broker secret stay (the workspace may reuse the token elsewhere). pub async fn delete_connection( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { let removed = cm_db::repo::repo_connections::delete(&state.pool, id, user.workspace_id).await?; if removed { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::NotFound) } } /// `POST /api/repos/connections/:id/sync` — refetch from the provider. pub async fn sync_now( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let conn = cm_db::repo::repo_connections::get(&state.pool, id, user.workspace_id).await?; let app_conn = cm_db::repo::connections::get(&state.pool, conn.app_connection_id, user.workspace_id) .await?; let secret_ref = app_conn.secret_ref.ok_or(ApiError::Conflict)?; let result = sync_connection(&state, user.workspace_id, id, secret_ref).await; let (synced, sync_error) = match result { Ok(n) => (n, None), Err(e) => (0, Some(e)), }; let _ = cm_db::repo::repo_connections::mark_synced(&state.pool, id, sync_error.as_deref()).await; Ok(Json(serde_json::json!({ "synced": synced, "sync_error": sync_error, }))) } /// `GET /api/repos` — every cached repo in the workspace, newest updated first. pub async fn list_repos( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let rows = cm_db::repo::repos::list_for_workspace(&state.pool, user.workspace_id, 500).await?; let out: Vec = rows .into_iter() .map(|r| { serde_json::json!({ "id": r.id.to_string(), "connection_id": r.connection_id.to_string(), "provider": r.provider, "owner": r.owner, "name": r.name, "private": r.private, "description": r.description, "default_branch": r.default_branch, "stars": r.stars, "forks": r.forks, "updated_at": r.provider_updated_at.and_then(|t| t.format(&Rfc3339).ok()), }) }) .collect(); Ok(Json(Value::Array(out))) } /// `GET /api/repos/:id` — one repo detail (includes clone_url + html_url). pub async fn get_repo( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let r = cm_db::repo::repos::get(&state.pool, id, user.workspace_id).await?; Ok(Json(serde_json::json!({ "id": r.id.to_string(), "connection_id": r.connection_id.to_string(), "provider": r.provider, "owner": r.owner, "name": r.name, "private": r.private, "description": r.description, "default_branch": r.default_branch, "clone_url": r.clone_url, "html_url": r.html_url, "stars": r.stars, "forks": r.forks, "updated_at": r.provider_updated_at.and_then(|t| t.format(&Rfc3339).ok()), "last_synced_at": r.last_synced_at.format(&Rfc3339).ok(), }))) } // ── provider sync ──────────────────────────────────────────────────────── /// Fetch repos from the provider (via the broker) + upsert into the cache. /// Returns the number of upserted rows. Errors on network / provider errors — /// the caller stashes the message on repo_connections.last_sync_error. async fn sync_connection( state: &AppState, workspace_id: cm_domain::WorkspaceId, repo_conn_id: Uuid, secret_ref: Uuid, ) -> Result { let conn = cm_db::repo::repo_connections::get(&state.pool, repo_conn_id, workspace_id) .await .map_err(|e| format!("load connection: {e}"))?; match conn.provider.as_str() { "gitea" => sync_gitea(state, workspace_id, &conn, secret_ref).await, "github" => sync_github(state, workspace_id, &conn, secret_ref).await, other => Err(format!("provider '{other}' not yet supported for sync")), } } /// Gitea sync — Gitea's REST v1 is close enough to GitHub's that we share the /// upsert shape but diverge on URL construction and a couple of field names /// (`stars_count`, `forks_count` vs GitHub's `stargazers_count`, `forks_count`; /// `full_name` present on every row; `owner.login` matches). Base URL must /// point at the instance root — we append `/api/v1` ourselves so callers /// don't have to remember which providers include the API prefix. /// /// Fleet default is the redclaw Gitea (`git.redclaw.dev`). Gitea gets first- /// class treatment because it's what most of the workspace's repos live on. async fn sync_gitea( state: &AppState, workspace_id: cm_domain::WorkspaceId, conn: &cm_db::repo::repo_connections::RepoConnection, secret_ref: Uuid, ) -> Result { let socket = state .broker_socket .as_ref() .ok_or_else(|| "broker socket unavailable".to_string())?; let mut broker = cm_secrets::BrokerClient::connect(socket) .await .map_err(|e| format!("broker: {e}"))?; let base_root = conn .base_url .as_deref() .map(|s| s.trim().trim_end_matches('/')) .filter(|s| !s.is_empty()) .ok_or_else(|| "gitea requires a base_url".to_string())?; // Accept either `https://git.example.com` or `https://git.example.com/api/v1`. let api_base = if base_root.ends_with("/api/v1") { base_root.to_string() } else { format!("{base_root}/api/v1") }; let mut page = 1u32; let per_page = 50u32; let mut upserted = 0usize; loop { let url = match conn.owner.as_deref() { Some(owner) => format!("{api_base}/orgs/{owner}/repos?limit={per_page}&page={page}"), None => format!("{api_base}/repos/search?limit={per_page}&page={page}"), }; let (status, body) = broker .fetch_authorized(secret_ref, &url) .await .map_err(|e| format!("broker fetch: {e}"))?; if status == 404 && conn.owner.is_some() { return Err(format!( "org '{}' not found or PAT lacks access", conn.owner.as_deref().unwrap_or("") )); } if !(200..300).contains(&status) { return Err(format!("Gitea {status}: {body}")); } // `/orgs/:owner/repos` returns an array; `/repos/search` wraps it in // `{data: [...]}` with an `ok` flag. let arr = if let Some(a) = body.as_array() { a.clone() } else if let Some(a) = body.get("data").and_then(|v| v.as_array()) { a.clone() } else { break; }; if arr.is_empty() { break; } let n = arr.len(); for repo in &arr { if let Err(e) = upsert_gitea_repo(&state.pool, workspace_id, conn.id, &conn.provider, repo).await { eprintln!( "repos: gitea upsert failed for {}: {e}", repo.get("full_name") .and_then(|v| v.as_str()) .unwrap_or("?") ); continue; } upserted += 1; } if (n as u32) < per_page { break; } page += 1; // Bounded first-sync latency: cap at 20 pages (~1000 repos on Gitea). if page > 20 { break; } } Ok(upserted) } async fn upsert_gitea_repo( pool: &sqlx::PgPool, workspace_id: cm_domain::WorkspaceId, connection_id: Uuid, provider: &str, repo: &Value, ) -> Result<(), cm_db::DbError> { let external_id = repo .get("id") .and_then(|v| v.as_i64()) .map(|n| n.to_string()) .unwrap_or_default(); let owner = repo .get("owner") .and_then(|v| v.get("login").or_else(|| v.get("username"))) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let name = repo .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if external_id.is_empty() || name.is_empty() { return Ok(()); } let description = repo .get("description") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(str::to_owned); let default_branch = repo .get("default_branch") .and_then(|v| v.as_str()) .map(str::to_owned); let clone_url = repo .get("clone_url") .and_then(|v| v.as_str()) .map(str::to_owned); let html_url = repo .get("html_url") .and_then(|v| v.as_str()) .map(str::to_owned); let private = repo .get("private") .and_then(|v| v.as_bool()) .unwrap_or(false); // Gitea uses `stars_count` (not `stargazers_count`) but we accept either // for forward-compat across versions. let stars = repo .get("stars_count") .or_else(|| repo.get("stargazers_count")) .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let forks = repo .get("forks_count") .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let provider_updated_at = repo .get("updated_at") .and_then(|v| v.as_str()) .and_then(|s| { time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() }); cm_db::repo::repos::upsert( pool, cm_db::repo::repos::RepoUpsert { connection_id, workspace_id, provider, external_id: &external_id, owner: &owner, name: &name, description: description.as_deref(), default_branch: default_branch.as_deref(), clone_url: clone_url.as_deref(), html_url: html_url.as_deref(), private, stars, forks, provider_updated_at, }, ) .await?; Ok(()) } async fn sync_github( state: &AppState, workspace_id: cm_domain::WorkspaceId, conn: &cm_db::repo::repo_connections::RepoConnection, secret_ref: Uuid, ) -> Result { let socket = state .broker_socket .as_ref() .ok_or_else(|| "broker socket unavailable".to_string())?; let mut broker = cm_secrets::BrokerClient::connect(socket) .await .map_err(|e| format!("broker: {e}"))?; // GitHub API paginates with `per_page` + `page`. 100 is the max; stop // when we get a short page (< per_page) — sufficient for v1. let base = conn.base_url.as_deref().unwrap_or("https://api.github.com"); let mut page = 1u32; let per_page = 100u32; let mut upserted = 0usize; loop { let url = match conn.owner.as_deref() { Some(owner) => format!( "{base}/orgs/{owner}/repos?per_page={per_page}&page={page}&type=all" ), None => format!("{base}/user/repos?per_page={per_page}&page={page}&affiliation=owner,organization_member"), }; let (status, body) = broker .fetch_authorized(secret_ref, &url) .await .map_err(|e| format!("broker fetch: {e}"))?; if status == 404 && conn.owner.is_some() { return Err(format!( "org '{}' not found or PAT lacks access", conn.owner.as_deref().unwrap_or("") )); } if !(200..300).contains(&status) { return Err(format!("GitHub {status}: {body}")); } let arr = match body.as_array() { Some(a) => a.clone(), None => break, }; if arr.is_empty() { break; } let n = arr.len(); for repo in &arr { if let Err(e) = upsert_github_repo(&state.pool, workspace_id, conn.id, &conn.provider, repo).await { eprintln!( "repos: upsert failed for {}: {e}", repo.get("full_name") .and_then(|v| v.as_str()) .unwrap_or("?") ); continue; } upserted += 1; } if (n as u32) < per_page { break; } page += 1; // Cap at 20 pages (~2k repos) to keep first-sync latency bounded. if page > 20 { break; } } Ok(upserted) } async fn upsert_github_repo( pool: &sqlx::PgPool, workspace_id: cm_domain::WorkspaceId, connection_id: Uuid, provider: &str, repo: &Value, ) -> Result<(), cm_db::DbError> { let external_id = repo .get("id") .and_then(|v| v.as_i64()) .map(|n| n.to_string()) .unwrap_or_default(); let owner = repo .get("owner") .and_then(|v| v.get("login")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let name = repo .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if external_id.is_empty() || name.is_empty() { return Ok(()); } let description = repo .get("description") .and_then(|v| v.as_str()) .map(str::to_owned); let default_branch = repo .get("default_branch") .and_then(|v| v.as_str()) .map(str::to_owned); let clone_url = repo .get("clone_url") .and_then(|v| v.as_str()) .map(str::to_owned); let html_url = repo .get("html_url") .and_then(|v| v.as_str()) .map(str::to_owned); let private = repo .get("private") .and_then(|v| v.as_bool()) .unwrap_or(false); let stars = repo .get("stargazers_count") .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let forks = repo .get("forks_count") .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let provider_updated_at = repo .get("updated_at") .and_then(|v| v.as_str()) .and_then(|s| { time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok() }); cm_db::repo::repos::upsert( pool, cm_db::repo::repos::RepoUpsert { connection_id, workspace_id, provider, external_id: &external_id, owner: &owner, name: &name, description: description.as_deref(), default_branch: default_branch.as_deref(), clone_url: clone_url.as_deref(), html_url: html_url.as_deref(), private, stars, forks, provider_updated_at, }, ) .await?; Ok(()) }