diff --git a/sidecar/scripts/stage-vendor.ts b/sidecar/scripts/stage-vendor.ts index 86c4d03e8..8c7a5bc0b 100644 --- a/sidecar/scripts/stage-vendor.ts +++ b/sidecar/scripts/stage-vendor.ts @@ -1,4 +1,4 @@ -// Stage claude-code + codex + gh + glab into `sidecar/dist/vendor/` +// Stage claude-code + codex + gh + glab + tea into `sidecar/dist/vendor/` // for Tauri to ship as bundle resources. macOS host only. // // Cross-arch staging: in CI the host is always Apple Silicon (macos-26 @@ -35,6 +35,7 @@ const BUNDLE_CACHE = join(SIDECAR_ROOT, ".bundle-cache"); // Bumping any version: update SHA256 below + wipe sidecar/.bundle-cache. // gh: github.com/cli/cli/releases/download/v$VER/gh_${VER}_checksums.txt // glab: gitlab.com/gitlab-org/cli/-/releases/v$VER/downloads/checksums.txt +// tea: gitea.com/gitea/tea/releases/download/v$VER/checksums.txt // codex: shasum -a 256 of the npm tarball at // registry.npmjs.org/@openai/codex/-/codex-$VER-darwin-{arm64,x64}.tgz // claude-code: shasum -a 256 of the npm tarballs at @@ -52,6 +53,12 @@ const GLAB_SHA256 = { amd64: "79d1a4f933919689c5fb7774feb1dd08f30b9c896dff4283b4a7387689ee0531", } as const; +const TEA_VERSION = "0.14.1"; +const TEA_SHA256 = { + arm64: "52c482b964de63977b5a836b4976f0b098a1dfde6486ca7b8d0a6ee29b4f7945", + amd64: "4b6828c7dec67cfbd4b911e9391fc9e32eddeea693025ee99a20c444c251f53a", +} as const; + // Codex version is whatever sidecar/package.json pulled in. The SHAs below // must match THAT version — bump them together (or staging cross-arch will // abort with a clear error). @@ -114,6 +121,8 @@ interface TargetInfo { ghArch: "arm64" | "amd64"; /** `glab` release naming: `arm64` / `amd64`. */ glabArch: "arm64" | "amd64"; + /** `tea` release naming: `arm64` / `amd64`. */ + teaArch: "arm64" | "amd64"; } function infoForArch(arch: DarwinArch): TargetInfo { @@ -127,6 +136,7 @@ function infoForArch(arch: DarwinArch): TargetInfo { codexNpmSuffix: "darwin-arm64", ghArch: "arm64", glabArch: "arm64", + teaArch: "arm64", }; } return { @@ -138,6 +148,7 @@ function infoForArch(arch: DarwinArch): TargetInfo { codexNpmSuffix: "darwin-x64", ghArch: "amd64", glabArch: "amd64", + teaArch: "amd64", }; } @@ -352,6 +363,20 @@ function stageGlabBinary(arch: "arm64" | "amd64"): string { return binDest; } +function stageTeaBinary(arch: "arm64" | "amd64"): string { + ensureCacheDir(); + const slug = `tea-${TEA_VERSION}-darwin-${arch}`; + const archive = join(BUNDLE_CACHE, slug); + const url = `https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/${slug}`; + downloadAndVerify(url, archive, TEA_SHA256[arch]); + + const binDest = join(DIST_VENDOR, "tea", "tea"); + copyFile(archive, binDest); + chmodSync(binDest, 0o755); + maybeSignMacBinary(binDest, false); + return binDest; +} + // --------------------------------------------------------------------------- // claude-code — prefer the platform sub-package already on disk; fall back to // downloading the npm tarball when staging for a non-host architecture. @@ -695,9 +720,10 @@ stageClaudeCodeBinary(target); // ----- Codex ----- stageCodexBinary(target); -// ----- gh + glab (forge CLIs) ----- +// ----- gh + glab + tea (forge CLIs) ----- stageGhBinary(target.ghArch); stageGlabBinary(target.glabArch); +stageTeaBinary(target.teaArch); // ----- llama.cpp (local LLM server for auto-rename / Local AI) ----- stageLlamaCppBinaries(target); @@ -709,3 +735,4 @@ console.log(` codex ${humanSize(join(DIST_VENDOR, "codex"))}`); console.log(` gh ${humanSize(join(DIST_VENDOR, "gh"))}`); console.log(` glab ${humanSize(join(DIST_VENDOR, "glab"))}`); console.log(` llama-cpp ${humanSize(join(DIST_VENDOR, "llama-cpp"))}`); +console.log(` tea ${humanSize(join(DIST_VENDOR, "tea"))}`); diff --git a/src-tauri/src/forge/accounts.rs b/src-tauri/src/forge/accounts.rs index 56e2cd9c4..c68d0b388 100644 --- a/src-tauri/src/forge/accounts.rs +++ b/src-tauri/src/forge/accounts.rs @@ -1,9 +1,9 @@ -//! Per-repo gh/glab account binding — orchestration layer. +//! Per-repo gh/glab/tea account binding — orchestration layer. //! //! Mirrors the [`super::provider::WorkspaceForgeBackend`] pattern: a //! [`ForgeAccountBackend`] trait sits in the `forge::` umbrella, with //! provider-specific implementations living under [`super::github::accounts`] -//! and [`super::gitlab::accounts`]. Top-level helpers in this file +//! and [`super::gitlab::accounts`] / [`super::gitea::accounts`]. Top-level helpers in this file //! dispatch by provider so cross-cutting callers (the auto-bind hook, //! the Settings → Account panel, the right-top workspace chip) never //! need to branch on `ForgeProvider` themselves. @@ -17,7 +17,7 @@ use super::remote::parse_remote; use super::types::ForgeProvider; use crate::repos; -/// Public profile of a single gh/glab account, surfaced to the +/// Public profile of a single gh/glab/tea account, surfaced to the /// frontend's Settings → Account panel. `active` is true for the gh /// account currently marked active by `gh auth switch`; for GitLab /// (one-account-per-host) it's always true. @@ -73,9 +73,9 @@ pub(crate) enum RepoAccess { } /// Provider-agnostic account operations. Each method may interpret -/// `host` / `login` slightly differently — GitLab ignores `login` since -/// it has at most one account per host, while GitHub uses `(host, -/// login)` as the full identity. +/// `host` / `login` slightly differently — GitHub uses `(host, login)` +/// as the full identity, while GitLab and Gitea use one account per +/// host and ignore `login` for API execution. pub(crate) trait ForgeAccountBackend: Sync { /// Enumerate all accounts (with profile) for this forge. /// `hosts_hint` is ignored by GitHub (gh exposes its own host list) @@ -112,13 +112,14 @@ pub(crate) fn backend_for(provider: ForgeProvider) -> Option<&'static dyn ForgeA match provider { ForgeProvider::Github => Some(&super::github::accounts::BACKEND), ForgeProvider::Gitlab => Some(&super::gitlab::accounts::BACKEND), + ForgeProvider::Gitea => Some(&super::gitea::accounts::BACKEND), ForgeProvider::Unknown => None, } } // ---------------- Top-level dispatchers ---------------- -/// All gh accounts plus one glab account per `gitlab_hosts` entry. +/// All gh accounts plus one glab/tea account per known host entry. /// Errors from individual backends are logged and skipped so a transient /// problem with one CLI doesn't blank the whole panel. pub(crate) fn list_forge_accounts(gitlab_hosts: &[String]) -> Vec { @@ -141,6 +142,15 @@ pub(crate) fn list_forge_accounts(gitlab_hosts: &[String]) -> Vec ), } } + if let Some(backend) = backend_for(ForgeProvider::Gitea) { + match backend.list_accounts(gitlab_hosts) { + Ok(items) => accounts.extend(items), + Err(error) => tracing::warn!( + error = %format!("{error:#}"), + "Failed to enumerate Gitea accounts" + ), + } + } accounts } @@ -153,6 +163,7 @@ pub(crate) fn invalidate_caches_for_host(provider: ForgeProvider, host: &str) { match provider { ForgeProvider::Github => crate::forge::github::accounts::invalidate_caches_for_host(host), ForgeProvider::Gitlab => crate::forge::gitlab::accounts::invalidate_caches_for_host(host), + ForgeProvider::Gitea => crate::forge::gitea::accounts::invalidate_caches_for_host(host), ForgeProvider::Unknown => {} } } diff --git a/src-tauri/src/forge/bundled.rs b/src-tauri/src/forge/bundled.rs index 94ae8a004..bbc542c78 100644 --- a/src-tauri/src/forge/bundled.rs +++ b/src-tauri/src/forge/bundled.rs @@ -1,15 +1,17 @@ -//! Paths to bundled `gh` / `glab` inside `Resources/vendor/`. +//! Paths to bundled `gh` / `glab` / `tea` inside `Resources/vendor/`. use std::path::{Path, PathBuf}; use std::sync::OnceLock; pub const GH_PATH_ENV: &str = "HELMOR_GH_BIN_PATH"; pub const GLAB_PATH_ENV: &str = "HELMOR_GLAB_BIN_PATH"; +pub const TEA_PATH_ENV: &str = "HELMOR_TEA_BIN_PATH"; #[derive(Debug, Default, Clone)] pub struct BundledForgeCliPaths { pub gh: Option, pub glab: Option, + pub tea: Option, } static BUNDLED_PATHS: OnceLock = OnceLock::new(); @@ -23,6 +25,7 @@ pub fn init() { tracing::info!( gh = ?paths.and_then(|p| p.gh.as_deref()), glab = ?paths.and_then(|p| p.glab.as_deref()), + tea = ?paths.and_then(|p| p.tea.as_deref()), "Resolved bundled forge CLI paths" ); } @@ -41,6 +44,7 @@ pub fn bundled_path_for(program: &str) -> Option { match program { "gh" => cached.gh.clone(), "glab" => cached.glab.clone(), + "tea" => cached.tea.clone(), _ => None, } } @@ -49,6 +53,7 @@ fn env_key_for(program: &str) -> Option<&'static str> { match program { "gh" => Some(GH_PATH_ENV), "glab" => Some(GLAB_PATH_ENV), + "tea" => Some(TEA_PATH_ENV), _ => None, } } @@ -77,13 +82,16 @@ fn resolve_for_exe(exe: &Path) -> Option { let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" }; let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" }; + let tea_name = if cfg!(windows) { "tea.exe" } else { "tea" }; let gh = resources_dir.join(format!("vendor/gh/{gh_name}")); let glab = resources_dir.join(format!("vendor/glab/{glab_name}")); + let tea = resources_dir.join(format!("vendor/tea/{tea_name}")); Some(BundledForgeCliPaths { gh: gh.is_file().then_some(gh), glab: glab.is_file().then_some(glab), + tea: tea.is_file().then_some(tea), }) } @@ -93,6 +101,7 @@ impl BundledForgeCliPaths { BundledForgeCliPaths { gh: self.gh.or(fallback.gh), glab: self.glab.or(fallback.glab), + tea: self.tea.or(fallback.tea), } } } @@ -110,13 +119,16 @@ fn resolve_for_dev_workspace(workspace_root: &Path) -> BundledForgeCliPaths { let vendor = workspace_root.join("sidecar/dist/vendor"); let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" }; let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" }; + let tea_name = if cfg!(windows) { "tea.exe" } else { "tea" }; let gh = vendor.join(format!("gh/{gh_name}")); let glab = vendor.join(format!("glab/{glab_name}")); + let tea = vendor.join(format!("tea/{tea_name}")); BundledForgeCliPaths { gh: gh.is_file().then_some(gh), glab: glab.is_file().then_some(glab), + tea: tea.is_file().then_some(tea), } } diff --git a/src-tauri/src/forge/cli_status.rs b/src-tauri/src/forge/cli_status.rs index 6deeac194..47fdc0415 100644 --- a/src-tauri/src/forge/cli_status.rs +++ b/src-tauri/src/forge/cli_status.rs @@ -1,4 +1,4 @@ -//! Terminal-side helpers for the gh / glab auth-login flow: +//! Terminal-side helpers for the gh / glab / tea auth-login flow: //! - [`forge_cli_auth_command`] — produces the shell command we hand //! off to the embedded Helmor terminal session. //! - [`labels_for`] — provider-name / cli-name / connect-action @@ -22,21 +22,41 @@ pub(crate) fn forge_cli_auth_command( Ok(match provider { ForgeProvider::Github => format!("{} auth login", bundled_program_token("gh")?), ForgeProvider::Gitlab => { - let host = host.unwrap_or("gitlab.com"); - // Reject obviously broken hostnames before they reach AppleScript: - // a newline would let the user inject extra `do script` commands. - if host.contains(['\n', '\r']) { - bail!("Invalid hostname (contains newline): {host:?}"); - } + let host = validate_shell_host(host.unwrap_or("gitlab.com"))?; format!( - "{} auth login --hostname {host}", - bundled_program_token("glab")? + "{} auth login --hostname {}", + bundled_program_token("glab")?, + shell_single_quote(host) + ) + } + ForgeProvider::Gitea => { + let host = validate_shell_host(host.unwrap_or("gitea.com"))?; + let login_name = format!("helmor-{host}"); + format!( + "{} login add --name {} --url {}", + bundled_program_token("tea")?, + shell_single_quote(&login_name), + shell_single_quote(&format!("https://{host}")) ) } ForgeProvider::Unknown => bail!("Unknown forge provider."), }) } +fn validate_shell_host(host: &str) -> Result<&str> { + let host = host.trim(); + if host.is_empty() { + bail!("Invalid hostname (empty)"); + } + if !host + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | ':')) + { + bail!("Invalid hostname: {host:?}"); + } + Ok(host) +} + /// Absolute bundled path (shell-quoted). In release builds, missing the /// bundled binary means the .app payload is broken — fail loudly rather /// than spawning a Terminal session that immediately dies on @@ -83,6 +103,13 @@ pub(crate) fn labels_for(provider: ForgeProvider) -> ForgeLabels { change_request_full_name: "merge request".to_string(), connect_action: "Connect GitLab".to_string(), }, + ForgeProvider::Gitea => ForgeLabels { + provider_name: "Gitea".to_string(), + cli_name: "tea".to_string(), + change_request_name: "PR".to_string(), + change_request_full_name: "pull request".to_string(), + connect_action: "Connect Gitea".to_string(), + }, ForgeProvider::Unknown => ForgeLabels { provider_name: "Git".to_string(), cli_name: String::new(), @@ -106,4 +133,13 @@ mod tests { ); assert_eq!(shell_single_quote("a'b'c"), "'a'\\''b'\\''c'"); } + + #[test] + fn validate_shell_host_rejects_shell_metacharacters() { + assert!(validate_shell_host("gitea.example.com").is_ok()); + assert!(validate_shell_host("gitea.example.com:3000").is_ok()); + assert!(validate_shell_host("gitea.example.com;open -a Calculator").is_err()); + assert!(validate_shell_host("gitea.example.com && whoami").is_err()); + assert!(validate_shell_host("").is_err()); + } } diff --git a/src-tauri/src/forge/detect.rs b/src-tauri/src/forge/detect.rs index 0b949077f..0a1c0b92a 100644 --- a/src-tauri/src/forge/detect.rs +++ b/src-tauri/src/forge/detect.rs @@ -2,17 +2,17 @@ //! //! Runs a chain of progressively more expensive checks against a git //! remote URL (and optionally the repo root) to classify it as GitHub / -//! GitLab / Unknown. Each layer that fires contributes a human-readable +//! GitLab / Gitea / Unknown. Each layer that fires contributes a human-readable //! `DetectionSignal` so the UI can explain *why* we picked a provider. //! //! Layer order (cheapest → strongest, short-circuits on first confident //! hit): //! -//! 1. Well-known hosts (`github.com`, `gitlab.com`, …). -//! 2. Host prefix/suffix heuristics (`gitlab.*`, `*.ghe.com`, …). +//! 1. Well-known hosts (`github.com`, `gitlab.com`, `gitea.com`, …). +//! 2. Host prefix/suffix heuristics (`gitlab.*`, `gitea.*`, `*.ghe.com`, …). //! 3. URL path heuristics (`/-/` is GitLab-exclusive). -//! 4. Repo-root filesystem signals (`.gitlab-ci.yml`, `.github/workflows/`). -//! 5. HTTPS probe (`/api/v4/version` for GitLab, `/api/v3/` for GH Enterprise). +//! 4. Repo-root filesystem signals (`.gitlab-ci.yml`, `.github/workflows/`, `.gitea/`). +//! 5. HTTPS probe (`/api/v4/version` for GitLab, `/api/v3/` for GH Enterprise, `/api/v1/version` for Gitea). //! 6. CLI probe (`glab repo view` / `gh repo view`) when the CLI is present. use std::path::Path; @@ -71,6 +71,13 @@ fn detect_provider_for_repo_impl( }); return (ForgeProvider::Gitlab, signals); } + if matches_wellknown_gitea(host) { + signals.push(DetectionSignal { + layer: "wellKnownHost", + detail: format!("Host `{host}` is a well-known Gitea host"), + }); + return (ForgeProvider::Gitea, signals); + } } // Layer 2 — host prefix/suffix heuristics. @@ -88,6 +95,11 @@ fn detect_provider_for_repo_impl( layer: "hostPattern", detail: format!("Host `{host}` matches a GitLab naming pattern"), }); + } else if host_looks_like_gitea(host) { + signals.push(DetectionSignal { + layer: "hostPattern", + detail: format!("Host `{host}` matches a Gitea naming pattern"), + }); } } @@ -116,6 +128,12 @@ fn detect_provider_for_repo_impl( detail: "`.github/workflows/` present at repo root".to_string(), }); } + if root.join(".gitea").is_dir() { + signals.push(DetectionSignal { + layer: "repoFile", + detail: "`.gitea/` present at repo root".to_string(), + }); + } } // If Layer 2 + Layer 4 combined give us a consistent read, trust it @@ -134,6 +152,10 @@ fn detect_provider_for_repo_impl( signals.push(signal); return (ForgeProvider::Gitlab, signals); } + if let Some(signal) = probe_gitea_api(&remote.host) { + signals.push(signal); + return (ForgeProvider::Gitea, signals); + } if let Some(signal) = probe_github_api(&remote.host) { signals.push(signal); return (ForgeProvider::Github, signals); @@ -174,9 +196,13 @@ fn resolve_from_signals(signals: &[DetectionSignal]) -> Option { let mentions_github = signals .iter() .any(|s| s.detail.to_ascii_lowercase().contains("github")); - match (mentions_gitlab, mentions_github) { - (true, false) => Some(ForgeProvider::Gitlab), - (false, true) => Some(ForgeProvider::Github), + let mentions_gitea = signals + .iter() + .any(|s| s.detail.to_ascii_lowercase().contains("gitea")); + match (mentions_gitlab, mentions_github, mentions_gitea) { + (true, false, false) => Some(ForgeProvider::Gitlab), + (false, true, false) => Some(ForgeProvider::Github), + (false, false, true) => Some(ForgeProvider::Gitea), _ => None, } } @@ -220,6 +246,13 @@ fn matches_wellknown_gitlab(host: &str) -> bool { ) } +fn matches_wellknown_gitea(host: &str) -> bool { + matches!( + host.to_ascii_lowercase().as_str(), + "gitea.com" | "www.gitea.com" | "try.gitea.io" + ) +} + fn host_looks_like_github(host: &str) -> bool { let host = host.to_ascii_lowercase(); host.starts_with("github.") @@ -236,6 +269,13 @@ fn host_looks_like_gitlab(host: &str) -> bool { || host.split('.').any(|segment| segment == "gitlab") } +fn host_looks_like_gitea(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host.starts_with("gitea.") + || host.ends_with(".gitea.com") + || host.split('.').any(|segment| segment == "gitea") +} + /// Short-timeout GET against GitLab's `/api/v4/version`. A 200/401 with a /// GitLab server header is a strong positive; anything else is /// inconclusive, so we return None and let the next layer try. @@ -288,6 +328,19 @@ fn probe_github_api(host: &str) -> Option { None } +fn probe_gitea_api(host: &str) -> Option { + let client = build_probe_client()?; + let url = format!("https://{host}/api/v1/version"); + let response = client.get(&url).send().ok()?; + if response.status().is_success() || response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Some(DetectionSignal { + layer: "httpProbe", + detail: format!("`{url}` returned a Gitea API response"), + }); + } + None +} + fn build_probe_client() -> Option { reqwest::blocking::Client::builder() .user_agent("helmor-forge-probe/1.0") @@ -388,6 +441,14 @@ mod tests { assert!(signals.iter().any(|s| s.layer == "hostPattern")); } + #[test] + fn self_hosted_gitea_detected_via_host_pattern_without_network() { + let (provider, signals) = + detect_provider_for_repo_offline(Some("git@gitea.internal.example:team/svc.git"), None); + assert_eq!(provider, ForgeProvider::Gitea); + assert!(signals.iter().any(|s| s.layer == "hostPattern")); + } + #[test] fn self_hosted_github_enterprise_detected_via_host_pattern() { let (provider, signals) = diff --git a/src-tauri/src/forge/gitea/accounts.rs b/src-tauri/src/forge/gitea/accounts.rs new file mode 100644 index 000000000..67f175019 --- /dev/null +++ b/src-tauri/src/forge/gitea/accounts.rs @@ -0,0 +1,163 @@ +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +use crate::forge::accounts::{AuthCheck, ForgeAccount, ForgeAccountBackend, RepoAccess}; +use crate::forge::command::CommandOutput; +use crate::forge::types::ForgeProvider; + +use super::api::{ + command_detail, looks_like_auth_error, looks_like_missing_error, run_tea, tea_api, +}; +use super::types::GiteaUser; + +pub(crate) static BACKEND: GiteaAccountBackend = GiteaAccountBackend; + +pub(crate) struct GiteaAccountBackend; + +#[derive(Debug, Clone, Deserialize)] +struct TeaLoginRow { + name: String, + url: String, + user: String, + default: String, +} + +impl ForgeAccountBackend for GiteaAccountBackend { + fn list_accounts(&self, _hosts_hint: &[String]) -> Result> { + let logins = list_gitea_logins_full()?; + let mut out = Vec::with_capacity(logins.len()); + for login in logins { + let host = host_from_url(&login.url)?; + let profile = fetch_profile_for_login(&login.name, &host, &login.user).ok(); + out.push(ForgeAccount { + provider: ForgeProvider::Gitea, + host, + login: login.user.clone(), + name: profile + .as_ref() + .and_then(|user| user.full_name.clone()) + .filter(|value| !value.trim().is_empty()), + avatar_url: profile.as_ref().and_then(|user| user.avatar_url.clone()), + email: profile.as_ref().and_then(|user| user.email.clone()), + active: login.default == "true", + }); + } + Ok(out) + } + + fn list_logins(&self, host: &str) -> Result> { + Ok(list_gitea_logins_full()? + .into_iter() + .filter_map(|login| (host_from_url(&login.url).ok()?.eq(host)).then_some(login.user)) + .collect()) + } + + fn check_auth(&self, host: &str, login: &str) -> AuthCheck { + match self.list_logins(host) { + Ok(logins) => { + if logins.iter().any(|candidate| candidate == login) { + AuthCheck::LoggedIn + } else { + AuthCheck::LoggedOut + } + } + Err(_) => AuthCheck::Indeterminate, + } + } + + fn repo_access(&self, host: &str, login: &str, owner: &str, name: &str) -> Result { + let Some(login_name) = find_login_name(host, login)? else { + return Ok(RepoAccess::None); + }; + let path = format!("/repos/{owner}/{name}"); + let output = tea_api(&login_name, [path.as_str()])?; + if !output.success { + let detail = command_detail(&output); + if looks_like_auth_error(&detail) || looks_like_missing_error(&detail) { + return Ok(RepoAccess::None); + } + return Err(anyhow!("tea api {path} failed: {detail}")); + } + Ok(RepoAccess::Probable) + } + + fn fetch_profile(&self, host: &str, login: &str) -> Result { + let Some(login_name) = find_login_name(host, login)? else { + return Err(anyhow!("No Gitea login for {host} / {login}")); + }; + let user = fetch_profile_for_login(&login_name, host, login)?; + Ok(ForgeAccount { + provider: ForgeProvider::Gitea, + host: host.to_string(), + login: user + .login + .or(user.user_name) + .unwrap_or_else(|| login.to_string()), + name: user.full_name, + avatar_url: user.avatar_url, + email: user.email, + active: true, + }) + } + + fn run_cli(&self, host: &str, login: &str, args: &[&str]) -> Result { + let Some(login_name) = resolve_login_name(Some(host), login)? else { + return Err(anyhow!("No Gitea login for {host} / {login}")); + }; + let mut full_args = vec!["--login", login_name.as_str()]; + full_args.extend_from_slice(args); + run_tea(full_args) + } +} + +fn list_gitea_logins_full() -> Result> { + let output = run_tea(["login", "ls", "--output", "json"])?; + if !output.success { + return Err(anyhow!( + "`tea login ls --output json` failed: {}", + command_detail(&output) + )); + } + serde_json::from_str::>(&output.stdout) + .context("Failed to decode tea login list output") +} + +fn fetch_profile_for_login(login_name: &str, _host: &str, _user: &str) -> Result { + let output = tea_api(login_name, ["/user"])?; + if !output.success { + return Err(anyhow!( + "`tea api /user` failed: {}", + command_detail(&output) + )); + } + serde_json::from_str::(&output.stdout).context("Failed to decode Gitea /user") +} + +fn host_from_url(url: &str) -> Result { + let parsed = url::Url::parse(url).with_context(|| format!("Invalid Gitea login URL: {url}"))?; + parsed + .host_str() + .map(str::to_string) + .ok_or_else(|| anyhow!("Missing host in Gitea login URL: {url}")) +} + +pub(super) fn resolve_login_name(host: Option<&str>, login: &str) -> Result> { + for row in list_gitea_logins_full()? { + let host_matches = match host { + Some(host) => host_from_url(&row.url) + .ok() + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(host)), + None => true, + }; + if host_matches && row.user == login { + return Ok(Some(row.name)); + } + } + Ok(None) +} + +fn find_login_name(host: &str, login: &str) -> Result> { + resolve_login_name(Some(host), login) +} + +pub(crate) fn invalidate_caches_for_host(_host: &str) {} diff --git a/src-tauri/src/forge/gitea/api.rs b/src-tauri/src/forge/gitea/api.rs new file mode 100644 index 000000000..43b5df7fa --- /dev/null +++ b/src-tauri/src/forge/gitea/api.rs @@ -0,0 +1,53 @@ +use crate::{ + error::{AnyhowCodedExt, ErrorCode}, + forge::command::{run_command, CommandOutput}, +}; + +pub(super) fn tea_api<'a>( + login: &str, + args: impl IntoIterator, +) -> anyhow::Result { + let mut full_args = vec!["api".to_string(), "--login".to_string(), login.to_string()]; + full_args.extend(args.into_iter().map(str::to_string)); + let output = run_command("tea", full_args).map_err(anyhow::Error::new)?; + if !output.success && output.status.is_none() { + return Err(anyhow::anyhow!("failed to run tea api").with_code(ErrorCode::ForgeOnboarding)); + } + Ok(output) +} + +pub(super) fn run_tea<'a>( + args: impl IntoIterator, +) -> anyhow::Result { + run_command("tea", args).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!(error.to_string()).with_code(ErrorCode::ForgeOnboarding) + } else { + anyhow::Error::new(error) + } + }) +} + +pub(super) fn command_detail(output: &CommandOutput) -> String { + crate::forge::command::command_detail(output) +} + +pub(super) fn encode_query_value(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +pub(super) fn looks_like_auth_error(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("401") + || normalized.contains("403") + || normalized.contains("forbidden") + || normalized.contains("unauthorized") + || normalized.contains("authentication required") + || normalized.contains("not logged in") + || normalized.contains("token") +} + +pub(super) fn looks_like_missing_error(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("404") || normalized.contains("not found") +} diff --git a/src-tauri/src/forge/gitea/context.rs b/src-tauri/src/forge/gitea/context.rs new file mode 100644 index 000000000..33a203988 --- /dev/null +++ b/src-tauri/src/forge/gitea/context.rs @@ -0,0 +1,68 @@ +use anyhow::{bail, Result}; + +use crate::forge::branch::forge_head_branch_for; +use crate::forge::remote::{parse_remote, ParsedRemote}; +use crate::models::workspaces as workspace_models; +use crate::workspace_state::WorkspaceState; + +pub(super) struct GiteaContext { + pub(super) remote: ParsedRemote, + pub(super) branch: String, + pub(super) published: bool, + pub(super) login_name: String, +} + +pub(super) enum GiteaResolution { + Ready(GiteaContext), + Initializing, + Unavailable(&'static str), + Unauthenticated, +} + +pub(super) fn load_gitea_context(workspace_id: &str) -> Result { + let Some(record) = workspace_models::load_workspace_record_by_id(workspace_id)? else { + bail!("Workspace not found: {workspace_id}"); + }; + if record.state == WorkspaceState::Initializing { + return Ok(GiteaResolution::Initializing); + } + + let Some(remote_url) = record.remote_url.as_deref() else { + return Ok(GiteaResolution::Unavailable("Workspace has no remote")); + }; + let Some(remote) = parse_remote(remote_url) else { + return Ok(GiteaResolution::Unavailable( + "Workspace remote is not a Gitea repository", + )); + }; + let Some(branch) = record + .branch + .as_deref() + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + else { + return Ok(GiteaResolution::Unavailable( + "Workspace has no current branch", + )); + }; + let Some(login) = record + .forge_login + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + else { + return Ok(GiteaResolution::Unauthenticated); + }; + let Some(login_name) = super::accounts::resolve_login_name(Some(&remote.host), &login)? else { + return Ok(GiteaResolution::Unauthenticated); + }; + + let (branch, published) = forge_head_branch_for(&record, &branch); + Ok(GiteaResolution::Ready(GiteaContext { + remote, + branch, + published, + login_name, + })) +} diff --git a/src-tauri/src/forge/gitea/inbox.rs b/src-tauri/src/forge/gitea/inbox.rs new file mode 100644 index 000000000..32cbb9e8c --- /dev/null +++ b/src-tauri/src/forge/gitea/inbox.rs @@ -0,0 +1,408 @@ +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; + +use super::accounts::resolve_login_name; +use super::api::{command_detail, encode_query_value, looks_like_auth_error, tea_api}; +use super::types::{GiteaIssue, GiteaLabel, GiteaPullRequest}; +use crate::forge::inbox::{ + ForgeLabelOption, InboxDraftFilter, InboxFilters, InboxItem, InboxItemDetail, InboxPage, + InboxScopeFilter, InboxSortFilter, InboxSource, InboxState, InboxStateFilter, InboxStateTone, + InboxToggles, +}; + +pub mod detail; + +use detail::{GiteaIssueDetail, GiteaPullRequestDetail}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct GiteaCursor { + page: u32, +} + +pub fn list_inbox_items( + login: &str, + host: Option<&str>, + toggles: InboxToggles, + cursor: Option<&str>, + limit: usize, + repo_filter: Option<&str>, + filters: Option, +) -> Result { + let login_name = gitea_login_name(host, login)?; + let state = decode_cursor(cursor)?; + let page = state.page.max(1); + if toggles.issues { + return fetch_issues(&login_name, page, limit, repo_filter, filters); + } + if toggles.prs { + return fetch_prs(&login_name, page, limit, repo_filter, filters); + } + Ok(InboxPage { + items: Vec::new(), + next_cursor: None, + }) +} + +pub fn get_inbox_item_detail( + login: &str, + host: Option<&str>, + source: InboxSource, + external_id: &str, +) -> Result> { + let login_name = gitea_login_name(host, login)?; + let (repo, number) = parse_repo_number(external_id)?; + let (owner, name) = split_repo(&repo)?; + match source { + InboxSource::GiteaIssue => { + let path = format!("/repos/{owner}/{name}/issues/{number}"); + let output = tea_api(&login_name, [path.as_str()])?; + if !output.success { + return Ok(None); + } + let issue = serde_json::from_str::(&output.stdout) + .context("Failed to decode Gitea issue detail")?; + Ok(Some(InboxItemDetail::GiteaIssue(Box::new( + GiteaIssueDetail { + external_id: external_id.to_string(), + title: issue.title, + body: issue.body, + url: issue.html_url, + state: issue.state, + author_login: issue.user.and_then(|user| user.login.or(user.user_name)), + created_at: issue.created_at, + updated_at: issue.updated_at, + closed_at: issue.closed_at, + }, + )))) + } + InboxSource::GiteaPr => { + let path = format!("/repos/{owner}/{name}/pulls/{number}"); + let output = tea_api(&login_name, [path.as_str()])?; + if !output.success { + return Ok(None); + } + let pr = serde_json::from_str::(&output.stdout) + .context("Failed to decode Gitea pull request detail")?; + Ok(Some(InboxItemDetail::GiteaPr(Box::new( + GiteaPullRequestDetail { + external_id: external_id.to_string(), + title: pr.title, + body: pr.body, + url: pr.html_url, + state: pr.state, + merged: pr.merged.unwrap_or(false), + draft: pr.draft.unwrap_or(false), + author_login: pr.user.and_then(|user| user.login.or(user.user_name)), + source_branch: pr.head.and_then(|head| head.branch_ref), + target_branch: pr.base.and_then(|base| base.branch_ref), + created_at: pr.created_at, + updated_at: pr.updated_at, + }, + )))) + } + _ => unreachable!("non-Gitea source routed to Gitea inbox backend"), + } +} + +pub fn list_repo_labels( + host: &str, + login: &str, + repos: &[String], +) -> Result> { + let login_name = gitea_login_name(Some(host), login)?; + let mut out = std::collections::BTreeMap::::new(); + for repo in repos { + let (owner, name) = match split_repo(repo) { + Ok(parts) => parts, + Err(_) => continue, + }; + let path = format!("/repos/{owner}/{name}/labels"); + let output = tea_api(&login_name, [path.as_str()])?; + if !output.success { + continue; + } + let labels = serde_json::from_str::>(&output.stdout) + .context("Failed to decode Gitea labels")?; + for label in labels { + out.entry(label.name.clone()).or_insert(ForgeLabelOption { + name: label.name, + color: label.color, + description: label.description, + }); + } + } + Ok(out.into_values().collect()) +} + +fn fetch_issues( + login: &str, + page: u32, + limit: usize, + repo_filter: Option<&str>, + filters: Option, +) -> Result { + let repo = repo_filter.ok_or_else(|| anyhow!("Gitea inbox requires a repository filter"))?; + let (owner, name) = split_repo(repo)?; + let mut query = vec![ + ("page", page.to_string()), + ("limit", limit.to_string()), + ( + "state", + map_issue_state(filters.as_ref().and_then(|f| f.state)).to_string(), + ), + ("type", "issues".to_string()), + ]; + if let Some(q) = filters + .as_ref() + .and_then(|f| f.query.as_deref()) + .filter(|q| !q.trim().is_empty()) + { + query.push(("q", q.trim().to_string())); + } + if let Some(labels) = filters + .as_ref() + .and_then(|f| f.labels.as_deref()) + .filter(|value| !value.trim().is_empty()) + { + query.push(("labels", labels.trim().to_string())); + } + if let Some(scope) = filters + .as_ref() + .and_then(|f| f.scope.as_deref()) + .and_then(|scopes| scopes.first()) + { + match scope { + InboxScopeFilter::Assigned => query.push(("assigned_by", "@me".to_string())), + InboxScopeFilter::Created => query.push(("created_by", "@me".to_string())), + InboxScopeFilter::Mentioned => query.push(("mentioned_by", "@me".to_string())), + _ => {} + } + } + + let path = format!("/repos/{owner}/{name}/issues?{}", encode_query(&query)); + let output = tea_api(login, [path.as_str()])?; + if !output.success { + let detail = command_detail(&output); + if looks_like_auth_error(&detail) { + return Ok(InboxPage { + items: Vec::new(), + next_cursor: None, + }); + } + return Err(anyhow!("Gitea issues lookup failed: {detail}")); + } + let issues = serde_json::from_str::>(&output.stdout) + .context("Failed to decode Gitea issues list")?; + let items: Vec = issues + .into_iter() + .filter(|issue| issue.pull_request.is_none()) + .filter_map(|issue| { + Some(InboxItem { + id: format!("gitea_issue:{repo}#{}", issue.number), + source: InboxSource::GiteaIssue, + external_id: format!("{repo}#{}", issue.number), + external_url: issue.html_url, + title: issue.title, + subtitle: Some(repo.to_string()), + state: Some(issue_state(&issue.state)), + last_activity_at: parse_ts(issue.updated_at.as_deref())?, + }) + }) + .collect(); + Ok(InboxPage { + next_cursor: (items.len() >= limit).then(|| encode_cursor(page + 1)), + items, + }) +} + +fn fetch_prs( + login: &str, + page: u32, + limit: usize, + repo_filter: Option<&str>, + filters: Option, +) -> Result { + let repo = repo_filter.ok_or_else(|| anyhow!("Gitea inbox requires a repository filter"))?; + let (owner, name) = split_repo(repo)?; + let mut query = vec![ + ("page", page.to_string()), + ("limit", limit.to_string()), + ( + "state", + map_pr_state(filters.as_ref().and_then(|f| f.state)).to_string(), + ), + ]; + if let Some(sort) = filters.as_ref().and_then(|f| f.sort) { + query.push(("sort", map_pr_sort(sort).to_string())); + } + let path = format!("/repos/{owner}/{name}/pulls?{}", encode_query(&query)); + let output = tea_api(login, [path.as_str()])?; + if !output.success { + let detail = command_detail(&output); + if looks_like_auth_error(&detail) { + return Ok(InboxPage { + items: Vec::new(), + next_cursor: None, + }); + } + return Err(anyhow!("Gitea pull request lookup failed: {detail}")); + } + let mut prs = serde_json::from_str::>(&output.stdout) + .context("Failed to decode Gitea pull request list")?; + if let Some(q) = filters + .as_ref() + .and_then(|f| f.query.as_deref()) + .filter(|q| !q.trim().is_empty()) + { + let query_lower = q.to_ascii_lowercase(); + prs.retain(|pr| { + pr.title.to_ascii_lowercase().contains(&query_lower) + || pr + .body + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .contains(&query_lower) + }); + } + match filters.as_ref().and_then(|f| f.draft) { + Some(InboxDraftFilter::Exclude) => prs.retain(|pr| !pr.draft.unwrap_or(false)), + Some(InboxDraftFilter::Only) => prs.retain(|pr| pr.draft.unwrap_or(false)), + _ => {} + } + let items: Vec = prs + .into_iter() + .filter_map(|pr| { + let state = pr_state(&pr); + let last_activity_at = parse_ts(pr.updated_at.as_deref())?; + Some(InboxItem { + id: format!("gitea_pr:{repo}#{}", pr.number), + source: InboxSource::GiteaPr, + external_id: format!("{repo}#{}", pr.number), + external_url: pr.html_url, + title: pr.title, + subtitle: Some(repo.to_string()), + state: Some(state), + last_activity_at, + }) + }) + .collect(); + Ok(InboxPage { + next_cursor: (items.len() >= limit).then(|| encode_cursor(page + 1)), + items, + }) +} + +fn map_issue_state(filter: Option) -> &'static str { + match filter { + Some(InboxStateFilter::Closed) => "closed", + Some(InboxStateFilter::All) => "all", + _ => "open", + } +} + +fn map_pr_state(filter: Option) -> &'static str { + match filter { + Some(InboxStateFilter::Closed) | Some(InboxStateFilter::Merged) => "closed", + Some(InboxStateFilter::All) => "all", + _ => "open", + } +} + +fn map_pr_sort(sort: InboxSortFilter) -> &'static str { + match sort { + InboxSortFilter::Created => "oldest", + InboxSortFilter::Comments => "mostcomment", + InboxSortFilter::Updated => "recentupdate", + } +} + +fn issue_state(state: &str) -> InboxState { + match state { + "open" => InboxState { + label: "Open".to_string(), + tone: InboxStateTone::Open, + }, + "closed" => InboxState { + label: "Closed".to_string(), + tone: InboxStateTone::Closed, + }, + _ => InboxState { + label: state.to_string(), + tone: InboxStateTone::Neutral, + }, + } +} + +fn pr_state(pr: &GiteaPullRequest) -> InboxState { + if pr.merged.unwrap_or(false) { + return InboxState { + label: "Merged".to_string(), + tone: InboxStateTone::Merged, + }; + } + if pr.draft.unwrap_or(false) && pr.state == "open" { + return InboxState { + label: "Draft".to_string(), + tone: InboxStateTone::Draft, + }; + } + issue_state(&pr.state) +} + +fn split_repo(repo: &str) -> Result<(String, String)> { + let (owner, name) = repo + .split_once('/') + .ok_or_else(|| anyhow!("Invalid Gitea repo slug: {repo}"))?; + Ok((owner.to_string(), name.to_string())) +} + +fn gitea_login_name(host: Option<&str>, login: &str) -> Result { + resolve_login_name(host, login)?.ok_or_else(|| { + anyhow!( + "No Gitea login found for {}", + host.unwrap_or("configured host") + ) + }) +} + +fn parse_repo_number(external_id: &str) -> Result<(String, i64)> { + let (repo, number) = external_id + .rsplit_once('#') + .ok_or_else(|| anyhow!("Invalid Gitea external id: {external_id}"))?; + Ok((repo.to_string(), number.parse()?)) +} + +fn parse_ts(value: Option<&str>) -> Option { + chrono::DateTime::parse_from_rfc3339(value?) + .ok() + .map(|value| value.timestamp_millis()) +} + +fn encode_query(values: &[(impl AsRef, impl AsRef)]) -> String { + values + .iter() + .map(|(k, v)| { + format!( + "{}={}", + encode_query_value(k.as_ref()), + encode_query_value(v.as_ref()) + ) + }) + .collect::>() + .join("&") +} + +fn decode_cursor(cursor: Option<&str>) -> Result { + let Some(raw) = cursor else { + return Ok(GiteaCursor { page: 1 }); + }; + let bytes = URL_SAFE_NO_PAD.decode(raw)?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn encode_cursor(page: u32) -> String { + URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&GiteaCursor { page }).expect("serialize gitea cursor")) +} diff --git a/src-tauri/src/forge/gitea/inbox/detail.rs b/src-tauri/src/forge/gitea/inbox/detail.rs new file mode 100644 index 000000000..2870cc9ee --- /dev/null +++ b/src-tauri/src/forge/gitea/inbox/detail.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GiteaIssueDetail { + pub external_id: String, + pub title: String, + pub body: Option, + pub url: String, + pub state: String, + pub author_login: Option, + pub created_at: Option, + pub updated_at: Option, + pub closed_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GiteaPullRequestDetail { + pub external_id: String, + pub title: String, + pub body: Option, + pub url: String, + pub state: String, + pub merged: bool, + pub draft: bool, + pub author_login: Option, + pub source_branch: Option, + pub target_branch: Option, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/src-tauri/src/forge/gitea/mod.rs b/src-tauri/src/forge/gitea/mod.rs new file mode 100644 index 000000000..04b94efea --- /dev/null +++ b/src-tauri/src/forge/gitea/mod.rs @@ -0,0 +1,279 @@ +use anyhow::{anyhow, bail, Context, Result}; + +use crate::error::ErrorCode; +use crate::forge::{ + ActionProvider, ActionStatusKind, ChangeRequestInfo, ForgeActionItem, ForgeActionStatus, + RemoteState, +}; + +pub(super) mod accounts; +mod api; +mod context; +pub(super) mod inbox; +mod types; + +use self::api::{command_detail, looks_like_auth_error, tea_api}; +use self::context::{load_gitea_context, GiteaContext, GiteaResolution}; +use self::types::{ + GiteaCombinedStatus, GiteaCommitStatus, GiteaPullRequest, GiteaPullRequestMergeability, + GiteaWorkflowRunsResponse, +}; + +pub(super) fn lookup_workspace_pr(workspace_id: &str) -> Result> { + let context = match load_gitea_context(workspace_id)? { + GiteaResolution::Ready(ctx) if ctx.published => ctx, + _ => return Ok(None), + }; + Ok(find_workspace_pr(&context)?.map(pr_info)) +} + +pub(super) fn lookup_workspace_pr_action_status(workspace_id: &str) -> Result { + let context = match load_gitea_context(workspace_id)? { + GiteaResolution::Ready(ctx) => ctx, + GiteaResolution::Initializing => return Ok(ForgeActionStatus::no_change_request()), + GiteaResolution::Unavailable(message) => { + return Ok(ForgeActionStatus::unavailable(message)) + } + GiteaResolution::Unauthenticated => { + return Ok(ForgeActionStatus::unauthenticated( + "Gitea account is not connected for this repository", + )); + } + }; + + if !context.published { + return Ok(ForgeActionStatus::no_change_request()); + } + + let Some(pr) = find_workspace_pr(&context)? else { + return Ok(ForgeActionStatus::no_change_request()); + }; + + let checks = load_checks(&context, &pr).unwrap_or_default(); + let mergeability = fetch_pr_mergeability(&context, pr.number).ok(); + + Ok(ForgeActionStatus { + change_request: Some(pr_info(pr.clone())), + review_decision: None, + mergeable: mergeability + .as_ref() + .and_then(|value| value.mergeable) + .map(|value| if value { "MERGEABLE" } else { "CONFLICTING" }.to_string()), + merge_state_status: None, + deployments: Vec::new(), + checks, + remote_state: RemoteState::Ok, + message: None, + }) +} + +pub(super) fn lookup_workspace_pr_check_insert_text( + workspace_id: &str, + item_id: &str, +) -> Result { + let status = lookup_workspace_pr_action_status(workspace_id)?; + let item = status + .checks + .into_iter() + .find(|check| check.id == item_id) + .with_context(|| format!("Check item not found: {item_id}"))?; + Ok(format!( + "Check: {}\nProvider: Gitea\nStatus: {}{}{}", + item.name, + action_status_label(item.status), + item.duration + .as_deref() + .map(|value| format!("\nDuration: {value}")) + .unwrap_or_default(), + item.url + .as_deref() + .map(|value| format!("\nURL: {value}")) + .unwrap_or_default() + )) +} + +pub(super) fn merge_workspace_pr(workspace_id: &str) -> Result> { + let Some(context) = mutation_context(workspace_id)? else { + return Ok(None); + }; + let Some(pr) = find_workspace_pr(&context)? else { + return Ok(None); + }; + let path = format!( + "/repos/{}/{}/pulls/{}/merge", + context.remote.namespace, context.remote.repo, pr.number + ); + let output = tea_api( + &context.login_name, + ["-X", "POST", "-f", "do=merge", path.as_str()], + )?; + if !output.success { + bail!("Gitea PR merge failed: {}", command_detail(&output)); + } + lookup_workspace_pr(workspace_id) +} + +pub(super) fn close_workspace_pr(workspace_id: &str) -> Result> { + let Some(context) = mutation_context(workspace_id)? else { + return Ok(None); + }; + let Some(pr) = find_workspace_pr(&context)? else { + return Ok(None); + }; + let path = format!( + "/repos/{}/{}/issues/{}", + context.remote.namespace, context.remote.repo, pr.number + ); + let output = tea_api( + &context.login_name, + ["-X", "PATCH", "-f", "state=closed", path.as_str()], + )?; + if !output.success { + bail!("Gitea PR close failed: {}", command_detail(&output)); + } + lookup_workspace_pr(workspace_id) +} + +fn mutation_context(workspace_id: &str) -> Result> { + match load_gitea_context(workspace_id)? { + GiteaResolution::Ready(ctx) if ctx.published => Ok(Some(ctx)), + _ => Ok(None), + } +} + +fn find_workspace_pr(context: &GiteaContext) -> Result> { + let path = format!( + "/repos/{}/{}/pulls?state=all&limit=50", + context.remote.namespace, context.remote.repo + ); + let output = tea_api(&context.login_name, [path.as_str()])?; + if !output.success { + let detail = command_detail(&output); + if looks_like_auth_error(&detail) { + crate::bail_coded!(ErrorCode::ForgeOnboarding, "{detail}"); + } + return Err(anyhow!("Gitea pull request lookup failed: {detail}")); + } + let prs = serde_json::from_str::>(&output.stdout) + .context("Failed to decode Gitea pull requests")?; + Ok(prs.into_iter().find(|pr| { + pr.head.as_ref().and_then(|head| head.branch_ref.as_deref()) + == Some(context.branch.as_str()) + })) +} + +fn fetch_pr_mergeability( + context: &GiteaContext, + number: i64, +) -> Result { + let path = format!( + "/repos/{}/{}/pulls/{}", + context.remote.namespace, context.remote.repo, number + ); + let output = tea_api(&context.login_name, [path.as_str()])?; + if !output.success { + bail!("Gitea PR detail lookup failed: {}", command_detail(&output)); + } + serde_json::from_str::(&output.stdout) + .context("Failed to decode Gitea pull request mergeability") +} + +fn load_checks(context: &GiteaContext, pr: &GiteaPullRequest) -> Result> { + let mut checks = Vec::new(); + if let Some(sha) = pr.head.as_ref().and_then(|head| head.sha.as_deref()) { + let runs_path = format!( + "/repos/{}/{}/actions/runs?head_sha={}&limit=20", + context.remote.namespace, context.remote.repo, sha + ); + let runs_output = tea_api(&context.login_name, [runs_path.as_str()])?; + if runs_output.success { + let runs = serde_json::from_str::(&runs_output.stdout) + .context("Failed to decode Gitea workflow runs")?; + for run in runs.workflow_runs { + checks.push(ForgeActionItem { + id: format!("gitea-run-{}", run.id), + name: run + .display_title + .unwrap_or_else(|| format!("Workflow run #{}", run.id)), + provider: ActionProvider::Gitea, + status: action_status_from_run( + run.status.as_deref(), + run.conclusion.as_deref(), + ), + duration: None, + url: run.html_url, + }); + } + } + + let status_path = format!( + "/repos/{}/{}/commits/{}/status", + context.remote.namespace, context.remote.repo, sha + ); + let status_output = tea_api(&context.login_name, [status_path.as_str()])?; + if status_output.success { + let combined = serde_json::from_str::(&status_output.stdout) + .context("Failed to decode Gitea combined status")?; + if let Some(statuses) = combined.statuses { + for status in statuses { + checks.push(status_item(status)); + } + } + } + } + Ok(checks) +} + +fn status_item(status: GiteaCommitStatus) -> ForgeActionItem { + ForgeActionItem { + id: format!("gitea-status-{}", status.id), + name: status + .context + .or(status.description) + .unwrap_or_else(|| format!("Status {}", status.id)), + provider: ActionProvider::Gitea, + status: match status.status.as_str() { + "success" | "skipped" => ActionStatusKind::Success, + "pending" => ActionStatusKind::Pending, + _ => ActionStatusKind::Failure, + }, + duration: None, + url: status.target_url, + } +} + +fn action_status_from_run(status: Option<&str>, conclusion: Option<&str>) -> ActionStatusKind { + match (status.unwrap_or_default(), conclusion.unwrap_or_default()) { + ("completed", "success") => ActionStatusKind::Success, + ("completed", "skipped") => ActionStatusKind::Success, + ("completed", _) => ActionStatusKind::Failure, + ("queued", _) | ("pending", _) => ActionStatusKind::Pending, + ("in_progress", _) | ("running", _) => ActionStatusKind::Running, + _ => ActionStatusKind::Pending, + } +} + +fn pr_info(pr: GiteaPullRequest) -> ChangeRequestInfo { + ChangeRequestInfo { + url: pr.html_url, + number: pr.number, + state: if pr.merged.unwrap_or(false) { + "MERGED".to_string() + } else if pr.state == "open" { + "OPEN".to_string() + } else { + "CLOSED".to_string() + }, + title: pr.title, + is_merged: pr.merged.unwrap_or(false), + } +} + +fn action_status_label(status: ActionStatusKind) -> &'static str { + match status { + ActionStatusKind::Success => "success", + ActionStatusKind::Pending => "pending", + ActionStatusKind::Running => "running", + ActionStatusKind::Failure => "failure", + } +} diff --git a/src-tauri/src/forge/gitea/types.rs b/src-tauri/src/forge/gitea/types.rs new file mode 100644 index 000000000..2793fa1cc --- /dev/null +++ b/src-tauri/src/forge/gitea/types.rs @@ -0,0 +1,87 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaUser { + pub(super) login: Option, + pub(super) user_name: Option, + pub(super) full_name: Option, + pub(super) avatar_url: Option, + pub(super) email: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaBranchRef { + #[serde(rename = "ref")] + pub(super) branch_ref: Option, + pub(super) sha: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaPullRequest { + pub(super) number: i64, + pub(super) title: String, + pub(super) body: Option, + pub(super) state: String, + pub(super) html_url: String, + pub(super) draft: Option, + pub(super) merged: Option, + pub(super) created_at: Option, + pub(super) updated_at: Option, + pub(super) user: Option, + pub(super) head: Option, + pub(super) base: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaPullRequestMergeability { + pub(super) mergeable: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaLabel { + pub(super) name: String, + pub(super) color: Option, + pub(super) description: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaIssue { + pub(super) number: i64, + pub(super) title: String, + pub(super) body: Option, + pub(super) state: String, + pub(super) html_url: String, + pub(super) created_at: Option, + pub(super) updated_at: Option, + pub(super) closed_at: Option, + pub(super) user: Option, + pub(super) pull_request: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaWorkflowRunsResponse { + pub(super) workflow_runs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaWorkflowRun { + pub(super) id: i64, + pub(super) display_title: Option, + pub(super) html_url: Option, + pub(super) status: Option, + pub(super) conclusion: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaCommitStatus { + pub(super) id: i64, + pub(super) context: Option, + pub(super) description: Option, + pub(super) status: String, + pub(super) target_url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GiteaCombinedStatus { + pub(super) statuses: Option>, +} diff --git a/src-tauri/src/forge/github/actions.rs b/src-tauri/src/forge/github/actions.rs index 00078f3ae..e9c55bd77 100644 --- a/src-tauri/src/forge/github/actions.rs +++ b/src-tauri/src/forge/github/actions.rs @@ -488,6 +488,7 @@ fn action_provider_label(provider: ActionProvider) -> &'static str { match provider { ActionProvider::Github => "GitHub", ActionProvider::Gitlab => "GitLab", + ActionProvider::Gitea => "Gitea", ActionProvider::Vercel => "Vercel", ActionProvider::Unknown => "Unknown", } diff --git a/src-tauri/src/forge/github/inbox.rs b/src-tauri/src/forge/github/inbox.rs index 2a1952145..98e1ee1d6 100644 --- a/src-tauri/src/forge/github/inbox.rs +++ b/src-tauri/src/forge/github/inbox.rs @@ -567,7 +567,10 @@ pub fn get_inbox_item_detail( // Reaching here means the router (`backend_for(provider)`) sent // a GitLab source to the GitHub backend — that's a logic bug. // Loud crash beats silent `Ok(None)` for diagnosing it. - InboxSource::GitlabIssue | InboxSource::GitlabMr => unreachable!( + InboxSource::GitlabIssue + | InboxSource::GitlabMr + | InboxSource::GiteaIssue + | InboxSource::GiteaPr => unreachable!( "GitHub inbox backend received GitLab source: {source:?}. \ This is a router bug — `provider` and the item's `source` got out of sync." ), diff --git a/src-tauri/src/forge/gitlab/inbox.rs b/src-tauri/src/forge/gitlab/inbox.rs index e90f1ab60..0b7fc8f77 100644 --- a/src-tauri/src/forge/gitlab/inbox.rs +++ b/src-tauri/src/forge/gitlab/inbox.rs @@ -182,7 +182,11 @@ pub fn get_inbox_item_detail( // Reaching here means the router (`backend_for(provider)`) sent // a GitHub source to the GitLab backend — that's a logic bug. // Loud crash beats silent `Ok(None)` for diagnosing it. - InboxSource::GithubIssue | InboxSource::GithubPr | InboxSource::GithubDiscussion => { + InboxSource::GithubIssue + | InboxSource::GithubPr + | InboxSource::GithubDiscussion + | InboxSource::GiteaIssue + | InboxSource::GiteaPr => { unreachable!( "GitLab inbox backend received GitHub source: {source:?}. \ This is a router bug — `provider` and the item's `source` got out of sync." diff --git a/src-tauri/src/forge/inbox.rs b/src-tauri/src/forge/inbox.rs index 71d98589b..555a9e899 100644 --- a/src-tauri/src/forge/inbox.rs +++ b/src-tauri/src/forge/inbox.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; +use super::gitea::inbox::detail::{GiteaIssueDetail, GiteaPullRequestDetail}; use super::github::inbox::detail::{ GithubDiscussionDetail, GithubIssueDetail, GithubPullRequestDetail, }; @@ -111,6 +112,8 @@ pub enum InboxSource { GithubDiscussion, GitlabIssue, GitlabMr, + GiteaIssue, + GiteaPr, } #[derive(Debug, Clone, Serialize)] @@ -184,4 +187,6 @@ pub enum InboxItemDetail { GithubDiscussion(Box), GitlabIssue(Box), GitlabMr(Box), + GiteaIssue(Box), + GiteaPr(Box), } diff --git a/src-tauri/src/forge/mod.rs b/src-tauri/src/forge/mod.rs index f1d1873e2..9312db0f3 100644 --- a/src-tauri/src/forge/mod.rs +++ b/src-tauri/src/forge/mod.rs @@ -1,4 +1,4 @@ -//! Forge abstraction — unifies GitHub and GitLab (pull requests / merge +//! Forge abstraction — unifies GitHub, GitLab, and Gitea (pull requests / merge //! requests, CI status, CLI install + auth). //! //! Layout: @@ -18,6 +18,7 @@ //! calls to the right backend once a provider is resolved. //! - [`github`] — GitHub SDK (CLI helpers, GraphQL). //! - [`gitlab`] — GitLab REST client using `glab api`. +//! - [`gitea`] — Gitea REST client using `tea api`. pub(crate) mod accounts; pub(crate) mod avatar_cache; @@ -26,6 +27,7 @@ mod bundled; mod cli_status; mod command; mod detect; +mod gitea; pub mod github; mod gitlab; pub mod inbox; diff --git a/src-tauri/src/forge/provider.rs b/src-tauri/src/forge/provider.rs index cdbe5d9d5..6390420a3 100644 --- a/src-tauri/src/forge/provider.rs +++ b/src-tauri/src/forge/provider.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Result}; -use crate::forge::{github, gitlab}; +use crate::forge::{gitea, github, gitlab}; use super::inbox::{ ForgeLabelOption, InboxFilters, InboxItemDetail, InboxKind, InboxKindLabels, InboxPage, @@ -82,6 +82,7 @@ pub(crate) trait WorkspaceForgeBackend { struct GithubBackend; struct GitlabBackend; +struct GiteaBackend; impl WorkspaceForgeBackend for GithubBackend { fn lookup_change_request(&self, workspace_id: &str) -> Result> { @@ -323,13 +324,122 @@ impl WorkspaceForgeBackend for GitlabBackend { } } +impl WorkspaceForgeBackend for GiteaBackend { + fn lookup_change_request(&self, workspace_id: &str) -> Result> { + gitea::lookup_workspace_pr(workspace_id) + } + + fn action_status(&self, workspace_id: &str) -> Result { + gitea::lookup_workspace_pr_action_status(workspace_id) + } + + fn check_insert_text(&self, workspace_id: &str, item_id: &str) -> Result { + gitea::lookup_workspace_pr_check_insert_text(workspace_id, item_id) + } + + fn merge_change_request(&self, workspace_id: &str) -> Result> { + gitea::merge_workspace_pr(workspace_id) + } + + fn close_change_request(&self, workspace_id: &str) -> Result> { + gitea::close_workspace_pr(workspace_id) + } + + fn inbox_kind_labels(&self) -> Vec { + vec![ + InboxKindLabels { + kind: InboxKind::Issues, + short: "Issues".to_string(), + plural: "Issues".to_string(), + singular: "issue".to_string(), + }, + InboxKindLabels { + kind: InboxKind::Prs, + short: "PRs".to_string(), + plural: "Pull requests".to_string(), + singular: "pull request".to_string(), + }, + ] + } + + fn list_inbox_issues( + &self, + login: &str, + host: Option<&str>, + cursor: Option<&str>, + limit: usize, + repo_filter: Option<&str>, + filters: Option, + ) -> Result { + let toggles = InboxToggles { + issues: true, + prs: false, + discussions: false, + }; + gitea::inbox::list_inbox_items(login, host, toggles, cursor, limit, repo_filter, filters) + } + + fn list_inbox_prs( + &self, + login: &str, + host: Option<&str>, + cursor: Option<&str>, + limit: usize, + repo_filter: Option<&str>, + filters: Option, + ) -> Result { + let toggles = InboxToggles { + issues: false, + prs: true, + discussions: false, + }; + gitea::inbox::list_inbox_items(login, host, toggles, cursor, limit, repo_filter, filters) + } + + fn list_inbox_discussions( + &self, + _login: &str, + _host: Option<&str>, + _cursor: Option<&str>, + _limit: usize, + _repo_filter: Option<&str>, + _filters: Option, + ) -> Result { + bail!("Gitea does not support Discussions; this is a router bug") + } + + fn get_inbox_item_detail( + &self, + login: &str, + host: Option<&str>, + source: InboxSource, + external_id: &str, + ) -> Result> { + gitea::inbox::get_inbox_item_detail(login, host, source, external_id) + } + + fn list_repo_labels( + &self, + login: &str, + host: Option<&str>, + repos: &[String], + ) -> Result> { + let Some(host) = host else { + return Ok(Vec::new()); + }; + gitea::inbox::list_repo_labels(host, login, repos) + } +} + static GITHUB_BACKEND: GithubBackend = GithubBackend; static GITLAB_BACKEND: GitlabBackend = GitlabBackend; +static GITEA_BACKEND: GiteaBackend = GiteaBackend; pub(crate) fn backend_for(provider: ForgeProvider) -> Option<&'static dyn WorkspaceForgeBackend> { match provider { ForgeProvider::Github => Some(&GITHUB_BACKEND), ForgeProvider::Gitlab => Some(&GITLAB_BACKEND), + ForgeProvider::Gitea => Some(&GITEA_BACKEND), ForgeProvider::Unknown => None, } } diff --git a/src-tauri/src/forge/types.rs b/src-tauri/src/forge/types.rs index 4e887cace..0c908ef97 100644 --- a/src-tauri/src/forge/types.rs +++ b/src-tauri/src/forge/types.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; pub enum ForgeProvider { Github, Gitlab, + Gitea, Unknown, } @@ -17,6 +18,7 @@ impl ForgeProvider { match self { ForgeProvider::Github => "github", ForgeProvider::Gitlab => "gitlab", + ForgeProvider::Gitea => "gitea", ForgeProvider::Unknown => "unknown", } } @@ -29,6 +31,7 @@ impl FromStr for ForgeProvider { match value.trim().to_ascii_lowercase().as_str() { "github" => Ok(ForgeProvider::Github), "gitlab" => Ok(ForgeProvider::Gitlab), + "gitea" => Ok(ForgeProvider::Gitea), "unknown" | "" => Ok(ForgeProvider::Unknown), _ => Err(()), } @@ -93,6 +96,7 @@ pub enum ActionStatusKind { pub enum ActionProvider { Github, Gitlab, + Gitea, Vercel, Unknown, } @@ -194,6 +198,7 @@ mod tests { for provider in [ ForgeProvider::Github, ForgeProvider::Gitlab, + ForgeProvider::Gitea, ForgeProvider::Unknown, ] { let encoded = provider.as_storage_str(); diff --git a/src-tauri/src/workspace/helpers.rs b/src-tauri/src/workspace/helpers.rs index 0331097b8..b15869858 100644 --- a/src-tauri/src/workspace/helpers.rs +++ b/src-tauri/src/workspace/helpers.rs @@ -694,9 +694,13 @@ fn resolve_forge_login( match provider { ForgeProvider::Gitlab => resolve_gitlab_login(settings), + ForgeProvider::Gitea => resolve_gitea_login(settings), ForgeProvider::Unknown if remote_url_looks_like_gitlab(settings) => { resolve_gitlab_login(settings) } + ForgeProvider::Unknown if remote_url_looks_like_gitea(settings) => { + resolve_gitea_login(settings) + } ForgeProvider::Github | ForgeProvider::Unknown => Ok(None), } } @@ -709,6 +713,14 @@ fn remote_url_looks_like_gitlab(settings: &crate::settings::EffectiveBranchPrefi .is_some_and(|remote| remote.host.contains("gitlab")) } +fn remote_url_looks_like_gitea(settings: &crate::settings::EffectiveBranchPrefixSettings) -> bool { + settings + .remote_url + .as_deref() + .and_then(parse_remote) + .is_some_and(|remote| remote.host.contains("gitea")) +} + /// Legacy fallback for repo rows that predate `forge_login`: probe /// glab directly. Transient `list_logins` failures collapse to /// `Ok(None)` so the branch-prefix path degrades to "no prefix" @@ -733,6 +745,25 @@ fn resolve_gitlab_login( .and_then(|logins| logins.into_iter().next())) } +fn resolve_gitea_login( + settings: &crate::settings::EffectiveBranchPrefixSettings, +) -> Result> { + let host = settings + .remote_url + .as_deref() + .and_then(parse_remote) + .map(|remote| remote.host) + .unwrap_or_else(|| "gitea.com".to_string()); + + let Some(backend) = forge::accounts::backend_for(ForgeProvider::Gitea) else { + return Ok(None); + }; + Ok(backend + .list_logins(&host) + .ok() + .and_then(|logins| logins.into_iter().next())) +} + pub fn allocate_directory_name_for_repo(repo_id: &str) -> Result { let connection = crate::db::read_conn()?; allocate_directory_name_with_conn(&connection, repo_id) diff --git a/src/components/account-hover-card-content.tsx b/src/components/account-hover-card-content.tsx index 6a430075f..b28e1db87 100644 --- a/src/components/account-hover-card-content.tsx +++ b/src/components/account-hover-card-content.tsx @@ -1,4 +1,8 @@ -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { CachedAvatar } from "@/components/cached-avatar"; import type { ForgeAccount, ForgeProvider } from "@/lib/api"; import { initialsFor } from "@/lib/initials"; @@ -22,6 +26,8 @@ export function AccountHoverCardContent({ account }: { account: AccountInfo }) { const providerBadge = account.provider === "gitlab" ? ( + ) : account.provider === "gitea" ? ( + ) : ( ); @@ -52,7 +58,7 @@ export function AccountHoverCardContent({ account }: { account: AccountInfo }) { {account.email} ) : null} - {account.provider === "gitlab" ? ( + {account.provider !== "github" ? (
{account.host}
diff --git a/src/components/brand-icon.tsx b/src/components/brand-icon.tsx index b560b9e88..6f7355aeb 100644 --- a/src/components/brand-icon.tsx +++ b/src/components/brand-icon.tsx @@ -1,4 +1,10 @@ -import { type SimpleIcon, siGithub, siGitlab, siLinear } from "simple-icons"; +import { + type SimpleIcon, + siGitea, + siGithub, + siGitlab, + siLinear, +} from "simple-icons"; import { cn } from "@/lib/utils"; type BrandIconProps = { @@ -55,6 +61,11 @@ export function GitlabBrandIcon(props: Omit) { return ; } +/** Gitea brand glyph (Simple Icons). Uses `currentColor`. */ +export function GiteaBrandIcon(props: Omit) { + return ; +} + /** Linear brand glyph (Simple Icons). Uses `currentColor`. */ export function LinearBrandIcon(props: Omit) { return ; diff --git a/src/components/forge-connect-dialog.tsx b/src/components/forge-connect-dialog.tsx index 527867297..eacfda427 100644 --- a/src/components/forge-connect-dialog.tsx +++ b/src/components/forge-connect-dialog.tsx @@ -21,7 +21,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { type TerminalHandle, TerminalOutput, @@ -120,6 +124,7 @@ async function detectLoginAfterClose( function providerLabel(provider: ForgeProvider): string { if (provider === "github") return "GitHub"; if (provider === "gitlab") return "GitLab"; + if (provider === "gitea") return "Gitea"; return "Forge"; } @@ -127,6 +132,9 @@ function providerIcon(provider: ForgeProvider) { if (provider === "gitlab") { return ; } + if (provider === "gitea") { + return ; + } return ; } @@ -399,7 +407,7 @@ export function ForgeConnectDialog({
{providerIcon(provider)} Connect {providerLabel(provider)} - {provider === "gitlab" ? ( + {provider !== "github" ? ( · {host} ) : null}
diff --git a/src/features/inbox/index.tsx b/src/features/inbox/index.tsx index 26b1c0a45..08d2be409 100644 --- a/src/features/inbox/index.tsx +++ b/src/features/inbox/index.tsx @@ -8,7 +8,11 @@ import { } from "lucide-react"; import type { ChangeEvent } from "react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { TrafficLightSpacer } from "@/components/chrome/traffic-light-spacer"; import { Button } from "@/components/ui/button"; import { @@ -53,7 +57,7 @@ import { /** Forge providers that have an inbox backend implementation. Used to * narrow `repository.forgeProvider` (which can also be "unknown"). */ -type ForgeFilterId = "github" | "gitlab"; +type ForgeFilterId = "github" | "gitlab" | "gitea"; /** All non-forge providers stay as "Coming Soon" placeholders. */ type ExternalFilterId = "linear" | "slack" | "mobile"; @@ -115,6 +119,11 @@ function forgeUrlToInboxKind(query: string): InboxKind | null { /^(?:https?:\/\/)?[^/\s]+\/[^/\s]+(?:\/[^/\s]+)*\/-\/(issues|merge_requests)\/\d+(?:[/?#].*)?$/i, ); if (gl) return gl[1].toLowerCase() === "issues" ? "issues" : "prs"; + // Gitea: /owner/repo/(issues|pulls)/N + const gitea = trimmed.match( + /^(?:https?:\/\/)?[^/\s]+\/[^/\s]+\/[^/\s]+\/(issues|pulls)\/\d+(?:[/?#].*)?$/i, + ); + if (gitea) return gitea[1].toLowerCase() === "issues" ? "issues" : "prs"; return null; } @@ -177,6 +186,10 @@ const INBOX_KIND_ICON_SOURCE: Record< issues: "gitlab_issue", prs: "gitlab_mr", }, + gitea: { + issues: "gitea_issue", + prs: "gitea_pr", + }, }; function defaultStateForKind( @@ -196,6 +209,7 @@ export function forgeFilterIdForRepo( ): ForgeFilterId { const provider: ForgeProvider | null | undefined = repository?.forgeProvider; if (provider === "gitlab") return "gitlab"; + if (provider === "gitea") return "gitea"; return "github"; } @@ -586,6 +600,8 @@ export const InboxSidebar = memo(function InboxSidebar({ ) : filterId === "gitlab" ? ( + ) : filterId === "gitea" ? ( + ) : filterId === "slack" ? ( {provider === "github" ? ( + ) : provider === "gitea" ? ( + ) : ( )} diff --git a/src/features/inbox/source-card.tsx b/src/features/inbox/source-card.tsx index 94510bf93..a06d9a55d 100644 --- a/src/features/inbox/source-card.tsx +++ b/src/features/inbox/source-card.tsx @@ -149,7 +149,9 @@ function buildCardContextLabel(card: ContextCard) { card.meta.type === "github_pr" || card.meta.type === "github_discussion" || card.meta.type === "gitlab_issue" || - card.meta.type === "gitlab_mr" + card.meta.type === "gitlab_mr" || + card.meta.type === "gitea_issue" || + card.meta.type === "gitea_pr" ? card.meta.number : null; diff --git a/src/features/inbox/source-icon.tsx b/src/features/inbox/source-icon.tsx index ebb86213f..7993d775b 100644 --- a/src/features/inbox/source-icon.tsx +++ b/src/features/inbox/source-icon.tsx @@ -25,8 +25,10 @@ export function SourceIcon({ ); case "gitlab_issue": + case "gitea_issue": return ; case "gitlab_mr": + case "gitea_pr": return ( ); diff --git a/src/features/inbox/use-inbox-items.ts b/src/features/inbox/use-inbox-items.ts index 9e2c8d9ba..1f869531a 100644 --- a/src/features/inbox/use-inbox-items.ts +++ b/src/features/inbox/use-inbox-items.ts @@ -40,7 +40,7 @@ type ActiveInboxToggles = InboxAccountSourceToggles | InboxRepoSourceConfig; /** Forge providers the inbox can talk to. Excludes "unknown" since it * has no backend implementation. */ -type InboxProvider = Extract; +type InboxProvider = Extract; /** Resolves the accounts the inbox should fan out across for a given * forge, with their per-account toggles merged from settings. @@ -148,7 +148,9 @@ export function useInboxItems( // settings/account check so disabling it doesn't render a confusing // "kind disabled" empty state. const providerSupportsKind = - provider === "gitlab" ? kind !== "discussions" : true; + provider === "gitlab" || provider === "gitea" + ? kind !== "discussions" + : true; // Honor the per-account settings toggle for THIS kind — flipping // `Issues` off in Settings → Context disables this tab's fetch. const settingsAllowsKind = activeToggles diff --git a/src/features/inspector/sections/actions.tsx b/src/features/inspector/sections/actions.tsx index 41b4d61c5..c30e2165a 100644 --- a/src/features/inspector/sections/actions.tsx +++ b/src/features/inspector/sections/actions.tsx @@ -14,7 +14,11 @@ import { AppendContextButton, type AppendContextPayloadResult, } from "@/components/append-context-button"; -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { @@ -531,6 +535,9 @@ function ProviderIcon({ provider }: { provider: ActionProvider }) { if (provider === "gitlab") { return ; } + if (provider === "gitea") { + return ; + } return ; } diff --git a/src/features/inspector/sections/forge-cli-onboarding.tsx b/src/features/inspector/sections/forge-cli-onboarding.tsx index bc9f21b7f..98b4e5b38 100644 --- a/src/features/inspector/sections/forge-cli-onboarding.tsx +++ b/src/features/inspector/sections/forge-cli-onboarding.tsx @@ -1,6 +1,10 @@ import { LoaderCircle } from "lucide-react"; import { useState } from "react"; -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { ForgeConnectDialog } from "@/components/forge-connect-dialog"; import { Button } from "@/components/ui/button"; import { @@ -14,6 +18,7 @@ import { FORGE_AUTH_TOOLTIP_LINES } from "@/lib/forge-auth-copy"; const DEFAULT_GITHUB_HOST = "github.com"; const DEFAULT_GITLAB_HOST = "gitlab.com"; +const DEFAULT_GITEA_HOST = "gitea.com"; export function ForgeCliTrigger({ detection, @@ -32,7 +37,9 @@ export function ForgeCliTrigger({ detection.host ?? (detection.provider === "gitlab" ? DEFAULT_GITLAB_HOST - : DEFAULT_GITHUB_HOST); + : detection.provider === "gitea" + ? DEFAULT_GITEA_HOST + : DEFAULT_GITHUB_HOST); return ( <> @@ -59,6 +66,11 @@ export function ForgeCliTrigger({ size={12} className="self-center text-[#FC6D26]" /> + ) : detection.provider === "gitea" ? ( + ) : ( )} diff --git a/src/features/onboarding/steps/repository-cli-step.tsx b/src/features/onboarding/steps/repository-cli-step.tsx index adf663eda..cc492868e 100644 --- a/src/features/onboarding/steps/repository-cli-step.tsx +++ b/src/features/onboarding/steps/repository-cli-step.tsx @@ -14,7 +14,11 @@ import { AccountHoverCardContent, type ForgeAccountInfo, } from "@/components/account-hover-card-content"; -import { GithubBrandIcon, GitlabBrandIcon } from "@/components/brand-icon"; +import { + GiteaBrandIcon, + GithubBrandIcon, + GitlabBrandIcon, +} from "@/components/brand-icon"; import { CachedAvatar } from "@/components/cached-avatar"; import type { TerminalHandle } from "@/components/terminal-output"; import { Button } from "@/components/ui/button"; @@ -45,6 +49,7 @@ import type { OnboardingStep } from "../types"; const CLI_AUTH_POLL_INTERVAL_MS = 2000; const CLI_AUTH_POLL_TIMEOUT_MS = 120_000; const DEFAULT_GITLAB_HOST = "gitlab.com"; +const DEFAULT_GITEA_HOST = "gitea.com"; type RepoCliProvider = Exclude; type GitlabPanel = "host" | null; @@ -91,8 +96,14 @@ export function RepositoryCliStep({ logins: [], checking: true, }); + const [gitea, setGitea] = useState({ + logins: [], + checking: true, + }); const [gitlabHost, setGitlabHost] = useState(DEFAULT_GITLAB_HOST); const [gitlabStatusHost, setGitlabStatusHost] = useState(DEFAULT_GITLAB_HOST); + const [giteaHost, setGiteaHost] = useState(DEFAULT_GITEA_HOST); + const [giteaStatusHost, setGiteaStatusHost] = useState(DEFAULT_GITEA_HOST); const [activeGitlabPanel, setActiveGitlabPanel] = useState(null); const [activeTerminal, setActiveTerminal] = useState( null, @@ -114,8 +125,11 @@ export function RepositoryCliStep({ // freshly-added login never lands in `accountsQuery.data` and the // `useLayoutEffect` below can't clear the loading spinner. const extraGitlabHosts = useMemo( - () => (gitlabStatusHost ? [gitlabStatusHost] : []), - [gitlabStatusHost], + () => + [gitlabStatusHost, giteaStatusHost].filter( + (host): host is string => !!host, + ), + [gitlabStatusHost, giteaStatusHost], ); const accountsQuery = useForgeAccountsAll(extraGitlabHosts); @@ -182,6 +196,23 @@ export function RepositoryCliStep({ }; }, [gitlabStatusHost]); + useEffect(() => { + let cancelled = false; + setGitea((prev) => ({ logins: prev.logins, checking: true })); + listForgeLogins("gitea", giteaStatusHost) + .then((logins) => { + if (!cancelled) setGitea({ logins, checking: false }); + }) + .catch(() => { + if (!cancelled) { + setGitea((prev) => ({ logins: prev.logins, checking: false })); + } + }); + return () => { + cancelled = true; + }; + }, [giteaStatusHost]); + useEffect(() => clearPoll, [clearPoll]); /// Reset the active add-flow tab to its initial sub-stage. For @@ -242,6 +273,8 @@ export function RepositoryCliStep({ setAddingAccount({ provider, host, login: newLogin }); if (provider === "github") { setGithub({ logins, checking: false }); + } else if (provider === "gitea") { + setGitea({ logins, checking: false }); } else { setGitlab({ logins, checking: false }); } @@ -258,7 +291,13 @@ export function RepositoryCliStep({ // the user can retry from the same tab. setAddingAccount(null); toast( - `Finish ${provider === "gitlab" ? "GitLab" : "GitHub"} CLI auth, then click Set up again.`, + `Finish ${ + provider === "gitlab" + ? "GitLab" + : provider === "gitea" + ? "Gitea" + : "GitHub" + } CLI auth, then click Set up again.`, ); return; } @@ -303,7 +342,12 @@ export function RepositoryCliStep({ } }) .catch(() => {}); - const label = pending.provider === "gitlab" ? "GitLab" : "GitHub"; + const label = + pending.provider === "gitlab" + ? "GitLab" + : pending.provider === "gitea" + ? "Gitea" + : "GitHub"; toast.success(`${label} connected as @${pending.login}`); }, [addingAccount, accountsQuery.data, resetFlowTo, queryClient]); @@ -333,7 +377,12 @@ export function RepositoryCliStep({ (code: number | null) => { if (!activeTerminal) return; const baseline = new Set( - (activeTerminal.provider === "github" ? github : gitlab).logins, + (activeTerminal.provider === "github" + ? github + : activeTerminal.provider === "gitea" + ? gitea + : gitlab + ).logins, ); if (code !== 0) { // User cancelled (×, Ctrl+C, terminal kill) or the CLI @@ -355,7 +404,7 @@ export function RepositoryCliStep({ }); pollUntilReady(activeTerminal.provider, activeTerminal.host, baseline); }, - [activeTerminal, github, gitlab, pollUntilReady], + [activeTerminal, github, gitea, gitlab, pollUntilReady], ); const handleTerminalError = useCallback(() => { @@ -389,6 +438,10 @@ export function RepositoryCliStep({ resetFlowTo("gitlab"); }, [resetFlowTo]); + const handleGiteaSetUp = useCallback(() => { + resetFlowTo("gitea"); + }, [resetFlowTo]); + const handleGitlabHostSubmit = useCallback(() => { const host = normalizeGitlabHost(gitlabHost); if (!host) { @@ -406,6 +459,18 @@ export function RepositoryCliStep({ openTerminal("gitlab", host); }, [clearPoll, gitlabHost, openTerminal]); + const handleGiteaHostSubmit = useCallback(() => { + const host = normalizeGitlabHost(giteaHost); + if (!host) { + toast.error("Enter a Gitea domain."); + return; + } + setGiteaHost(host); + setGiteaStatusHost(host); + clearPoll(); + openTerminal("gitea", host); + }, [clearPoll, giteaHost, openTerminal]); + return (

Each repo uses one of your accounts. Add now or skip — existing logins - are picked up automatically. All accounts live in your local gh/glab - CLI. + are picked up automatically. All accounts live in your local + gh/glab/tea CLI.

{/* Sequential animation orchestration: @@ -456,6 +524,7 @@ export function RepositoryCliStep({ activeProvider={addFlowProvider} onAddGithub={handleGithubSetUp} onAddGitlab={handleGitlabSetUp} + onAddGitea={handleGiteaSetUp} /> {/* Terminal sits ABOVE the GitLab host slot so its top @@ -471,12 +540,27 @@ export function RepositoryCliStep({ /> + +
@@ -514,17 +598,22 @@ export function RepositoryCliStep({ function AccountListPanel({ githubLogins, gitlabLogins, + giteaLogins, gitlabStatusHost, + giteaStatusHost, loading, compact, addingAccount, accounts, onAddGithub, onAddGitlab, + onAddGitea, }: { githubLogins: string[]; gitlabLogins: string[]; + giteaLogins: string[]; gitlabStatusHost: string; + giteaStatusHost: string; loading: boolean; /** Switch to a single-row stacked-avatar view while a flow is * open, so the panel can't push the terminal off-screen. */ @@ -535,6 +624,7 @@ function AccountListPanel({ accounts: ForgeAccount[]; onAddGithub: () => void; onAddGitlab: () => void; + onAddGitea: () => void; }) { const accountByLogin = new Map(); for (const account of accounts) { @@ -563,6 +653,14 @@ function AccountListPanel({ account: accountByLogin.get(`gitlab::${login}`) ?? null, }); } + for (const login of giteaLogins) { + rows.push({ + provider: "gitea", + host: giteaStatusHost, + login, + account: accountByLogin.get(`gitea::${login}`) ?? null, + }); + } // Drive panel height off the inner wrapper so list↔stack swaps // animate. `useLayoutEffect` re-measures synchronously after each @@ -572,7 +670,8 @@ function AccountListPanel({ // shrink and the buttons sliding up are the same motion. const innerRef = useRef(null); const [innerHeight, setInnerHeight] = useState(null); - const totalRows = githubLogins.length + gitlabLogins.length; + const totalRows = + githubLogins.length + gitlabLogins.length + giteaLogins.length; useLayoutEffect(() => { if (innerRef.current) { setInnerHeight(innerRef.current.offsetHeight); @@ -616,6 +715,7 @@ function AccountListPanel({ )} @@ -630,9 +730,11 @@ function AccountListPanel({ function PickerHoverReveal({ onAddGithub, onAddGitlab, + onAddGitea, }: { onAddGithub: () => void; onAddGitlab: () => void; + onAddGitea: () => void; }) { return (
@@ -642,7 +744,7 @@ function PickerHoverReveal({ >
-
+
} label="GitLab" /> + } + label="Gitea" + />
); @@ -673,11 +781,13 @@ function TabButtons({ activeProvider, onAddGithub, onAddGitlab, + onAddGitea, }: { inFlow: boolean; activeProvider: RepoCliProvider | null; onAddGithub: () => void; onAddGitlab: () => void; + onAddGitea: () => void; }) { return (
@@ -713,6 +823,12 @@ function TabButtons({ icon={} label="GitLab" /> + } + label="Gitea" + />
); @@ -764,7 +880,13 @@ function CompactAccountStack({ const addingLabel = addingAccount ? addingAccount.login ? `Adding @${addingAccount.login}…` - : `Adding ${addingAccount.provider === "gitlab" ? "GitLab" : "GitHub"} account…` + : `Adding ${ + addingAccount.provider === "gitlab" + ? "GitLab" + : addingAccount.provider === "gitea" + ? "Gitea" + : "GitHub" + } account…` : null; if (rows.length === 0) { @@ -894,6 +1016,8 @@ function AccountRow({ const providerIcon = row.provider === "gitlab" ? ( + ) : row.provider === "gitea" ? ( + ) : ( ); @@ -919,7 +1043,11 @@ function AccountRow({
{providerIcon} - {row.provider === "gitlab" ? `GitLab · ${row.host}` : "GitHub"} + {row.provider === "gitlab" + ? `GitLab · ${row.host}` + : row.provider === "gitea" + ? `Gitea · ${row.host}` + : "GitHub"}
@@ -982,21 +1110,26 @@ function RepositoryCliTerminalSlot({ function GitlabHostSlot({ active, flowSettled, + label, value, onChange, onSubmit, onClose, + placeholder, }: { active: boolean; flowSettled: boolean; + label: string; value: string; onChange: (value: string) => void; onSubmit: () => void; onClose: () => void; + placeholder: string; }) { const openDelay = active && !flowSettled ? "700ms" : "0ms"; return (