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.
193 lines
6.3 KiB
Rust
193 lines
6.3 KiB
Rust
//! 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())
|
|
}
|