repos: Gitea provider (first-class) — sync + wizard default
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 42s
ci / rust (push) Successful in 2m47s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m30s

Fleet's Gitea (git.redclaw.dev) hosts most of this workspace's repos,
so Gitea gets the same inline sync treatment GitHub already had.

Backend sync_gitea:
- base_url is required — Gitea has no shared 'gitea.com'; we accept
  either the instance root (auto-appends /api/v1) or the fully-formed
  API base if the user already included the suffix
- /orgs/{owner}/repos when owner set, /repos/search when not (with the
  {data: [...], ok: bool} envelope Gitea wraps that endpoint in)
- 404 with an owner surfaces as 'org not found or PAT lacks access',
  same UX as GitHub
- 50/page, capped at 20 pages (~1000 repos); short page terminates
- upsert_gitea_repo tolerates the small field-name differences
  (stars_count vs stargazers_count, owner.login vs owner.username on
  older versions)

Frontend wizard:
- Gitea listed first — matches the workspace's actual usage
- Default provider selection is now gitea
- Token-input placeholder tailored per provider (Gitea's is
  'Settings → Applications → Generate New Token (repo)')

GitLab still returns 'not yet supported' — that's the next follow-up.
This commit is contained in:
Omar Sobh
2026-07-07 15:11:30 -07:00
parent 9cb14ddd89
commit e858a7f92f
2 changed files with 196 additions and 4 deletions
+185
View File
@@ -294,11 +294,196 @@ async fn sync_connection(
.await
.map_err(|e| format!("load connection: {e}"))?;
match conn.provider.as_str() {
"gitea" => sync_gitea(state, workspace_id, &conn, secret_ref).await,
"github" => sync_github(state, workspace_id, &conn, secret_ref).await,
other => Err(format!("provider '{other}' not yet supported for sync")),
}
}
/// Gitea sync — Gitea's REST v1 is close enough to GitHub's that we share the
/// upsert shape but diverge on URL construction and a couple of field names
/// (`stars_count`, `forks_count` vs GitHub's `stargazers_count`, `forks_count`;
/// `full_name` present on every row; `owner.login` matches). Base URL must
/// point at the instance root — we append `/api/v1` ourselves so callers
/// don't have to remember which providers include the API prefix.
///
/// Fleet default is the redclaw Gitea (`git.redclaw.dev`). Gitea gets first-
/// class treatment because it's what most of the workspace's repos live on.
async fn sync_gitea(
state: &AppState,
workspace_id: cm_domain::WorkspaceId,
conn: &cm_db::repo::repo_connections::RepoConnection,
secret_ref: Uuid,
) -> Result<usize, String> {
let socket = state
.broker_socket
.as_ref()
.ok_or_else(|| "broker socket unavailable".to_string())?;
let mut broker = cm_secrets::BrokerClient::connect(socket)
.await
.map_err(|e| format!("broker: {e}"))?;
let base_root = conn
.base_url
.as_deref()
.map(|s| s.trim().trim_end_matches('/'))
.filter(|s| !s.is_empty())
.ok_or_else(|| "gitea requires a base_url".to_string())?;
// Accept either `https://git.example.com` or `https://git.example.com/api/v1`.
let api_base = if base_root.ends_with("/api/v1") {
base_root.to_string()
} else {
format!("{base_root}/api/v1")
};
let mut page = 1u32;
let per_page = 50u32;
let mut upserted = 0usize;
loop {
let url = match conn.owner.as_deref() {
Some(owner) => format!("{api_base}/orgs/{owner}/repos?limit={per_page}&page={page}"),
None => format!("{api_base}/repos/search?limit={per_page}&page={page}"),
};
let (status, body) = broker
.fetch_authorized(secret_ref, &url)
.await
.map_err(|e| format!("broker fetch: {e}"))?;
if status == 404 && conn.owner.is_some() {
return Err(format!(
"org '{}' not found or PAT lacks access",
conn.owner.as_deref().unwrap_or("")
));
}
if !(200..300).contains(&status) {
return Err(format!("Gitea {status}: {body}"));
}
// `/orgs/:owner/repos` returns an array; `/repos/search` wraps it in
// `{data: [...]}` with an `ok` flag.
let arr = if let Some(a) = body.as_array() {
a.clone()
} else if let Some(a) = body.get("data").and_then(|v| v.as_array()) {
a.clone()
} else {
break;
};
if arr.is_empty() {
break;
}
let n = arr.len();
for repo in &arr {
if let Err(e) =
upsert_gitea_repo(&state.pool, workspace_id, conn.id, &conn.provider, repo).await
{
eprintln!(
"repos: gitea upsert failed for {}: {e}",
repo.get("full_name")
.and_then(|v| v.as_str())
.unwrap_or("?")
);
continue;
}
upserted += 1;
}
if (n as u32) < per_page {
break;
}
page += 1;
// Bounded first-sync latency: cap at 20 pages (~1000 repos on Gitea).
if page > 20 {
break;
}
}
Ok(upserted)
}
async fn upsert_gitea_repo(
pool: &sqlx::PgPool,
workspace_id: cm_domain::WorkspaceId,
connection_id: Uuid,
provider: &str,
repo: &Value,
) -> Result<(), cm_db::DbError> {
let external_id = repo
.get("id")
.and_then(|v| v.as_i64())
.map(|n| n.to_string())
.unwrap_or_default();
let owner = repo
.get("owner")
.and_then(|v| v.get("login").or_else(|| v.get("username")))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = repo
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if external_id.is_empty() || name.is_empty() {
return Ok(());
}
let description = repo
.get("description")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_owned);
let default_branch = repo
.get("default_branch")
.and_then(|v| v.as_str())
.map(str::to_owned);
let clone_url = repo
.get("clone_url")
.and_then(|v| v.as_str())
.map(str::to_owned);
let html_url = repo
.get("html_url")
.and_then(|v| v.as_str())
.map(str::to_owned);
let private = repo
.get("private")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Gitea uses `stars_count` (not `stargazers_count`) but we accept either
// for forward-compat across versions.
let stars = repo
.get("stars_count")
.or_else(|| repo.get("stargazers_count"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
let forks = repo
.get("forks_count")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
let provider_updated_at = repo
.get("updated_at")
.and_then(|v| v.as_str())
.and_then(|s| {
time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
});
cm_db::repo::repos::upsert(
pool,
cm_db::repo::repos::RepoUpsert {
connection_id,
workspace_id,
provider,
external_id: &external_id,
owner: &owner,
name: &name,
description: description.as_deref(),
default_branch: default_branch.as_deref(),
clone_url: clone_url.as_deref(),
html_url: html_url.as_deref(),
private,
stars,
forks,
provider_updated_at,
},
)
.await?;
Ok(())
}
async fn sync_github(
state: &AppState,
workspace_id: cm_domain::WorkspaceId,