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
190 lines
5.9 KiB
Rust
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(())
|
|
}
|