Files
clawmates/crates/cm-db/src/repo/repo_connections.rs
T
Omar Sobh 637e1bdd69
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 35s
ci / rust (push) Successful in 2m43s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m7s
repos: sidebar actions (sync/edit/remove) + edit modal
Sidebar:
- Each connection header now has three inline icon buttons: Sync now
  (spins while in flight), Edit (opens the modal), Remove (opens an
  inline confirm strip). Removes cascade repos via ON DELETE CASCADE.
- The connection's last_sync_error surfaces as a red inline banner
  under the header — no more 'error status with nowhere to see why'.
- Sync is POST /api/repos/connections/:id/sync (already existed);
  after either sync or delete the sidebar re-fetches so state stays
  consistent.

Edit modal (RepoConnectionEditModal):
- Loads GET /api/repos/connections/:id, pre-fills owner/base_url/label
- PATCHes only the fields that actually changed; empty string on a
  Some(&str) field sends explicit null so the backend clears it
- Sync-now + Remove reachable from inside the modal too
- Rotating the token is out of scope: the modal says as much and
  points the user at delete + re-create through the wizard (the
  broker doesn't expose an update path, and rotating in place would
  require duplicating the whole broker->store_secret flow here)

Backend:
- GET /api/repos/connections/:id — same ConnectionSummary shape
- PATCH /api/repos/connections/:id — owner/base_url use Option<Option<T>>
  double-nesting so 'omit = leave alone' and 'null = clear' round-trip
  distinctly through serde
- repo_connections::update with COALESCE-per-field so the SQL matches
  the double-Option semantics without an OR-chain per field
2026-07-07 17:55:20 -07:00

190 lines
5.9 KiB
Rust

//! 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,
})
}
/// 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<Option<&str>>,
base_url: Option<Option<&str>>,
label: Option<&str>,
) -> Result<bool, DbError> {
// 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<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(())
}