repos: backend — schema, /api/repos routes + GitHub sync provider
Migration 0034: two tables. repo_connections carries the workspace's per-provider config (owner, base_url, label, last_synced_at, last_sync_error) and points at an app_connections row for the PAT. repos is the per-connection cache with (connection_id, external_id) unique so upsert is idempotent across re-syncs. Cascading deletes clean up cleanly on connection removal. cm-secrets grows a FetchAuthorized op — GET with the stored PAT injected as bearer, returns status + JSON body without ever exposing the credential to cm-api. This is the least-privilege door for read-only provider APIs (list repos), distinct from the InvokeHttp path that still requires a single-use approval grant for outbound writes. cm-api::routes::repos wires: - POST /api/repos/connections (broker store_secret + insert both rows + initial sync + mark_synced) - GET /api/repos/connections - DELETE /api/repos/connections/:id - POST /api/repos/connections/:id/sync - GET /api/repos (500 cap, newest provider_updated first) - GET /api/repos/:id (full detail incl. clone_url + html_url) GitHub provider inline for v1 — paginated pull of /orgs/:owner/repos (when owner set) or /user/repos (when absent), 100/page, capped at 20 pages (~2k repos) to keep first-sync latency bounded. Non-2xx surface back to the caller as sync_error; parse failures are best-effort per repo (skipped, logged, don't abort the batch). Gitea + GitLab providers land in a follow-up — mostly URL swap + response-shape adapter.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//! 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<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub label: String,
|
||||
pub last_synced_at: Option<OffsetDateTime>,
|
||||
pub last_sync_error: Option<String>,
|
||||
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<Uuid, DbError> {
|
||||
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<Vec<RepoConnection>, 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<RepoConnection, DbError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<bool, DbError> {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user