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:
Omar Sobh
2026-07-07 14:52:47 -07:00
parent 076f7724ca
commit 6d087bf537
21 changed files with 1532 additions and 5 deletions
+22
View File
@@ -112,6 +112,28 @@ pub async fn find_provider(
Ok(row)
}
/// Fetch a single connection by id, workspace-scoped so cross-tenant reads
/// return NotFound. Used when another table (e.g. repo_connections) needs
/// to resolve the secret_ref of the credential it points at.
pub async fn get(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
) -> Result<AppConnection, DbError> {
let row = sqlx::query_as!(
AppConnection,
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
FROM app_connections
WHERE id = $1 AND workspace_id = $2"#,
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(row)
}
pub async fn disconnect(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
let result = sqlx::query!(
"UPDATE app_connections SET status = 'disconnected' WHERE id = $1",
+2
View File
@@ -16,6 +16,8 @@ pub mod node_tools;
pub mod nodes;
pub mod orgs;
pub mod outbox;
pub mod repo_connections;
pub mod repos;
pub mod research_publish_approvals;
pub mod research_topics;
pub mod routine_runs;
+150
View File
@@ -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(())
}
+192
View File
@@ -0,0 +1,192 @@
//! Repos — workspace-scoped cache of the repositories pulled from each
//! connected provider. Rows are populated by the provider-specific sync
//! path (see cm-api::providers) and served to the REPOS tier UI.
//!
//! Ownership: `workspace_id` scopes visibility; `connection_id` groups by
//! provider account. The (`connection_id`, `external_id`) unique makes
//! upsert idempotent across re-syncs.
use cm_domain::WorkspaceId;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// A repo row, as pulled from the provider.
pub struct Repo {
pub id: Uuid,
pub workspace_id: Uuid,
pub connection_id: Uuid,
pub provider: String,
pub external_id: String,
pub owner: String,
pub name: String,
pub description: Option<String>,
pub default_branch: Option<String>,
pub clone_url: Option<String>,
pub html_url: Option<String>,
pub private: bool,
pub stars: i32,
pub forks: i32,
pub provider_updated_at: Option<OffsetDateTime>,
pub last_synced_at: OffsetDateTime,
}
/// Payload for one sync-driven upsert. Fields mirror the columns 1:1 minus
/// the workspace + timestamps the DB owns.
pub struct RepoUpsert<'a> {
pub connection_id: Uuid,
pub workspace_id: WorkspaceId,
pub provider: &'a str,
pub external_id: &'a str,
pub owner: &'a str,
pub name: &'a str,
pub description: Option<&'a str>,
pub default_branch: Option<&'a str>,
pub clone_url: Option<&'a str>,
pub html_url: Option<&'a str>,
pub private: bool,
pub stars: i32,
pub forks: i32,
pub provider_updated_at: Option<OffsetDateTime>,
}
/// Insert-or-update on (connection_id, external_id). Bumps `last_synced_at`
/// on every call so the UI can show a "last synced" hint even when the
/// provider hasn't changed anything.
pub async fn upsert(pool: &PgPool, r: RepoUpsert<'_>) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
let row = sqlx::query!(
"INSERT INTO repos
(id, workspace_id, connection_id, provider, external_id,
owner, name, description, default_branch, clone_url, html_url,
private, stars, forks, provider_updated_at, last_synced_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, now())
ON CONFLICT (connection_id, external_id) DO UPDATE
SET owner = EXCLUDED.owner,
name = EXCLUDED.name,
description = EXCLUDED.description,
default_branch = EXCLUDED.default_branch,
clone_url = EXCLUDED.clone_url,
html_url = EXCLUDED.html_url,
private = EXCLUDED.private,
stars = EXCLUDED.stars,
forks = EXCLUDED.forks,
provider_updated_at = EXCLUDED.provider_updated_at,
last_synced_at = now()
RETURNING id",
id,
r.workspace_id.as_uuid(),
r.connection_id,
r.provider,
r.external_id,
r.owner,
r.name,
r.description,
r.default_branch,
r.clone_url,
r.html_url,
r.private,
r.stars,
r.forks,
r.provider_updated_at,
)
.fetch_one(pool)
.await?;
Ok(row.id)
}
/// All repos in a workspace, newest-provider-update first, cap-limited.
pub async fn list_for_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
limit: i64,
) -> Result<Vec<Repo>, DbError> {
let rows = sqlx::query!(
"SELECT id, workspace_id, connection_id, provider, external_id,
owner, name, description, default_branch, clone_url, html_url,
private, stars, forks, provider_updated_at, last_synced_at
FROM repos
WHERE workspace_id = $1
ORDER BY provider_updated_at DESC NULLS LAST, name
LIMIT $2",
workspace_id.as_uuid(),
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| Repo {
id: r.id,
workspace_id: r.workspace_id,
connection_id: r.connection_id,
provider: r.provider,
external_id: r.external_id,
owner: r.owner,
name: r.name,
description: r.description,
default_branch: r.default_branch,
clone_url: r.clone_url,
html_url: r.html_url,
private: r.private,
stars: r.stars,
forks: r.forks,
provider_updated_at: r.provider_updated_at,
last_synced_at: r.last_synced_at,
})
.collect())
}
/// A single repo, workspace-scoped so cross-tenant reads return NotFound.
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Repo, DbError> {
let r = sqlx::query!(
"SELECT id, workspace_id, connection_id, provider, external_id,
owner, name, description, default_branch, clone_url, html_url,
private, stars, forks, provider_updated_at, last_synced_at
FROM repos
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(Repo {
id: r.id,
workspace_id: r.workspace_id,
connection_id: r.connection_id,
provider: r.provider,
external_id: r.external_id,
owner: r.owner,
name: r.name,
description: r.description,
default_branch: r.default_branch,
clone_url: r.clone_url,
html_url: r.html_url,
private: r.private,
stars: r.stars,
forks: r.forks,
provider_updated_at: r.provider_updated_at,
last_synced_at: r.last_synced_at,
})
}
/// Delete every repo cached under a connection. Called when the
/// connection itself is deleted, or as part of a full re-sync.
pub async fn delete_by_connection(
pool: &PgPool,
workspace_id: WorkspaceId,
connection_id: Uuid,
) -> Result<u64, DbError> {
let res = sqlx::query!(
"DELETE FROM repos WHERE connection_id = $1 AND workspace_id = $2",
connection_id,
workspace_id.as_uuid(),
)
.execute(pool)
.await?;
Ok(res.rows_affected())
}