repos: Gitea provider (first-class) — sync + wizard default
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:
@@ -294,11 +294,196 @@ async fn sync_connection(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("load connection: {e}"))?;
|
.map_err(|e| format!("load connection: {e}"))?;
|
||||||
match conn.provider.as_str() {
|
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,
|
"github" => sync_github(state, workspace_id, &conn, secret_ref).await,
|
||||||
other => Err(format!("provider '{other}' not yet supported for sync")),
|
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(
|
async fn sync_github(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
workspace_id: cm_domain::WorkspaceId,
|
workspace_id: cm_domain::WorkspaceId,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { GitBranch, KeyRound, Link2, X } from "lucide-react";
|
|||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
type Provider = "github" | "gitea" | "gitlab";
|
type Provider = "gitea" | "github" | "gitlab";
|
||||||
|
|
||||||
const PROVIDERS: {
|
const PROVIDERS: {
|
||||||
key: Provider;
|
key: Provider;
|
||||||
@@ -27,8 +27,9 @@ const PROVIDERS: {
|
|||||||
needsBaseUrl: boolean;
|
needsBaseUrl: boolean;
|
||||||
defaultBase?: string;
|
defaultBase?: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "github", label: "GitHub", hint: "github.com or GHES", needsBaseUrl: false, defaultBase: "https://api.github.com" },
|
// Gitea first — most of this workspace's repos live on the fleet's Gitea.
|
||||||
{ key: "gitea", label: "Gitea", hint: "self-hosted", needsBaseUrl: true, defaultBase: "https://git.example.com" },
|
{ key: "gitea", label: "Gitea", hint: "self-hosted", needsBaseUrl: true, defaultBase: "https://git.example.com" },
|
||||||
|
{ key: "github", label: "GitHub", hint: "github.com or GHES", needsBaseUrl: false, defaultBase: "https://api.github.com" },
|
||||||
{ key: "gitlab", label: "GitLab", hint: "gitlab.com or self-hosted", needsBaseUrl: true, defaultBase: "https://gitlab.com" },
|
{ key: "gitlab", label: "GitLab", hint: "gitlab.com or self-hosted", needsBaseUrl: true, defaultBase: "https://gitlab.com" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export function RepoConnectionWizardStub({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [provider, setProvider] = useState<Provider>("github");
|
const [provider, setProvider] = useState<Provider>("gitea");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
@@ -199,7 +200,13 @@ export function RepoConnectionWizardStub({
|
|||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => setToken(e.target.value)}
|
||||||
placeholder={provider === "github" ? "ghp_… (needs `repo` scope for private)" : "PAT"}
|
placeholder={
|
||||||
|
provider === "github"
|
||||||
|
? "ghp_… (needs `repo` scope for private)"
|
||||||
|
: provider === "gitea"
|
||||||
|
? "Settings → Applications → Generate New Token (repo)"
|
||||||
|
: "PAT (read_api + read_repository)"
|
||||||
|
}
|
||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user