//! Repo connections — per-workspace binding of a repo provider (GitHub / //! Gitea / GitLab) to the app_connections credential that holds its PAT. //! Also stores the pull config: which owner to scope to, and (for self- //! hosted providers) the base URL. //! //! The row's `id` is what shows up as `connection_id` on repos and in the //! /api/repos/connections endpoints. The app_connections back-ref is //! separate so tearing down the credential (revoke, rotate) doesn't //! silently break the visible connection — deleting the app_connection //! cascades here and blows away the repos too. use cm_domain::WorkspaceId; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; pub struct RepoConnection { pub id: Uuid, pub workspace_id: Uuid, pub app_connection_id: Uuid, pub provider: String, pub owner: Option, pub base_url: Option, pub label: String, pub last_synced_at: Option, pub last_sync_error: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } pub struct NewRepoConnection<'a> { pub workspace_id: WorkspaceId, pub app_connection_id: Uuid, pub provider: &'a str, pub owner: Option<&'a str>, pub base_url: Option<&'a str>, pub label: &'a str, } pub async fn insert(pool: &PgPool, input: NewRepoConnection<'_>) -> Result { let id = Uuid::now_v7(); sqlx::query!( "INSERT INTO repo_connections (id, workspace_id, app_connection_id, provider, owner, base_url, label) VALUES ($1, $2, $3, $4, $5, $6, $7)", id, input.workspace_id.as_uuid(), input.app_connection_id, input.provider, input.owner, input.base_url, input.label, ) .execute(pool) .await?; Ok(id) } pub async fn list_for_workspace( pool: &PgPool, workspace_id: WorkspaceId, ) -> Result, DbError> { let rows = sqlx::query!( "SELECT id, workspace_id, app_connection_id, provider, owner, base_url, label, last_synced_at, last_sync_error, created_at, updated_at FROM repo_connections WHERE workspace_id = $1 ORDER BY created_at DESC", workspace_id.as_uuid(), ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|r| RepoConnection { id: r.id, workspace_id: r.workspace_id, app_connection_id: r.app_connection_id, provider: r.provider, owner: r.owner, base_url: r.base_url, label: r.label, last_synced_at: r.last_synced_at, last_sync_error: r.last_sync_error, created_at: r.created_at, updated_at: r.updated_at, }) .collect()) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: WorkspaceId, ) -> Result { let r = sqlx::query!( "SELECT id, workspace_id, app_connection_id, provider, owner, base_url, label, last_synced_at, last_sync_error, created_at, updated_at FROM repo_connections WHERE id = $1 AND workspace_id = $2", id, workspace_id.as_uuid(), ) .fetch_optional(pool) .await? .ok_or(DbError::NotFound)?; Ok(RepoConnection { id: r.id, workspace_id: r.workspace_id, app_connection_id: r.app_connection_id, provider: r.provider, owner: r.owner, base_url: r.base_url, label: r.label, last_synced_at: r.last_synced_at, last_sync_error: r.last_sync_error, created_at: r.created_at, updated_at: r.updated_at, }) } /// Patch the mutable metadata of a connection. Any `None` field is left /// untouched. Rotating the underlying PAT is a separate flow (delete + re- /// create through the wizard) — the connection row doesn't own the credential. pub async fn update( pool: &PgPool, id: Uuid, workspace_id: WorkspaceId, owner: Option>, base_url: Option>, label: Option<&str>, ) -> Result { // COALESCE lets us pass a sentinel per-field: NULL means "leave alone", // anything else means "set to this". `owner` and `base_url` need the // second-nested Option so we can distinguish clear-to-null from no-op. let owner_set = owner.is_some(); let owner_val = owner.and_then(|v| v.map(str::to_owned)); let base_url_set = base_url.is_some(); let base_url_val = base_url.and_then(|v| v.map(str::to_owned)); let label_val = label.map(str::to_owned); let res = sqlx::query!( "UPDATE repo_connections SET owner = CASE WHEN $3 THEN $4 ELSE owner END, base_url = CASE WHEN $5 THEN $6 ELSE base_url END, label = COALESCE($7, label), updated_at = now() WHERE id = $1 AND workspace_id = $2", id, workspace_id.as_uuid(), owner_set, owner_val, base_url_set, base_url_val, label_val, ) .execute(pool) .await?; Ok(res.rows_affected() == 1) } pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result { let res = sqlx::query!( "DELETE FROM repo_connections WHERE id = $1 AND workspace_id = $2", id, workspace_id.as_uuid(), ) .execute(pool) .await?; Ok(res.rows_affected() == 1) } /// Called after a sync run; timestamps the row and stores the error (or /// clears it on success). pub async fn mark_synced(pool: &PgPool, id: Uuid, error: Option<&str>) -> Result<(), DbError> { sqlx::query!( "UPDATE repo_connections SET last_synced_at = now(), last_sync_error = $2, updated_at = now() WHERE id = $1", id, error, ) .execute(pool) .await?; Ok(()) }