diff --git a/README.md b/README.md
index 01cc1a6..ba54581 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-Stacked PRs for [Jujutsu](https://jj-vcs.github.io/jj/latest/). Push bookmark stacks to GitHub and GitLab as chained pull requests.
+Stacked PRs for [Jujutsu](https://jj-vcs.github.io/jj/latest/). Push bookmark stacks to GitHub, GitLab, and Gitea as chained pull requests.
## What it does
@@ -69,11 +69,23 @@ Uses (in order):
For self-hosted: `export GITLAB_HOST=gitlab.mycompany.com`
+### Gitea
+
+Uses (in order):
+1. `tea auth token --host ` (tea CLI)
+2. `GITEA_TOKEN` env var
+3. `GITEA_ACCESS_TOKEN` env var
+4. `GITEA_KEY` env var
+5. `GT_TOKEN` env var
+
+For self-hosted: `export GITEA_HOST=git.gmac.io`
+
### Test authentication
```sh
ryu auth github test
ryu auth gitlab test
+ryu auth gitea test
```
## Usage
@@ -142,6 +154,8 @@ Each PR gets a comment showing the full stack:
This stack of pull requests is managed by jj-ryu.
```
+Same-repo PR stacks are supported first for Gitea. Depending on instance API behavior, draft PRs may use a `WIP:` title fallback until published.
+
### Syncing
```sh
@@ -173,6 +187,11 @@ ryu track --all
# Submit both as PRs (feat-session -> feat-auth -> main)
ryu submit
+# Self-hosted Gitea example
+export GITEA_HOST=git.gmac.io
+ryu auth gitea test
+ryu submit
+
# Make changes, then update PRs
jj commit -m "Address review feedback"
ryu submit
diff --git a/src/auth/gitea.rs b/src/auth/gitea.rs
new file mode 100644
index 0000000..e4538ba
--- /dev/null
+++ b/src/auth/gitea.rs
@@ -0,0 +1,152 @@
+//! Gitea authentication
+
+use crate::auth::AuthSource;
+use crate::error::{Error, Result};
+use reqwest::Client;
+use serde::Deserialize;
+use std::env;
+use tokio::process::Command;
+use tracing::debug;
+
+/// Gitea authentication configuration
+#[derive(Debug, Clone)]
+pub struct GiteaAuthConfig {
+ /// Authentication token
+ pub token: String,
+ /// Where the token was obtained from
+ pub source: AuthSource,
+ /// Gitea host
+ pub host: String,
+}
+
+/// Resolve the target Gitea host from explicit args, then env.
+pub fn resolve_gitea_host(host: Option<&str>, env_host: Option<&str>) -> String {
+ host.map(str::to_string)
+ .or_else(|| env_host.map(str::to_string))
+ .unwrap_or_default()
+}
+
+/// Choose the highest-priority Gitea token from environment-sourced values.
+pub fn pick_gitea_env_token(gitea_token: Option<&str>, gt_token: Option<&str>) -> Option {
+ gitea_token
+ .map(str::to_string)
+ .or_else(|| gt_token.map(str::to_string))
+}
+
+/// Get Gitea authentication
+///
+/// Priority:
+/// 1. tea CLI (`tea auth token --host `)
+/// 2. `GITEA_TOKEN` environment variable
+/// 3. `GT_TOKEN` environment variable
+pub async fn get_gitea_auth(host: Option<&str>) -> Result {
+ let configured_host = resolve_gitea_host(host, env::var("GITEA_HOST").ok().as_deref());
+ let host = if configured_host.is_empty() {
+ get_tea_default_host().await.ok_or_else(|| {
+ Error::Auth(
+ "No Gitea host configured. Pass a host, set GITEA_HOST, or configure a default `tea` login".to_string(),
+ )
+ })?
+ } else {
+ configured_host
+ };
+
+ debug!(host = %host, "attempting to get Gitea token");
+ if let Some(token) = get_tea_cli_token(&host).await {
+ return Ok(GiteaAuthConfig {
+ token,
+ source: AuthSource::Cli,
+ host,
+ });
+ }
+
+ let access_like_token = pick_gitea_env_token(
+ env::var("GITEA_ACCESS_TOKEN").ok().as_deref(),
+ env::var("GITEA_KEY").ok().as_deref(),
+ );
+
+ if let Some(token) = pick_gitea_env_token(
+ env::var("GITEA_TOKEN").ok().as_deref(),
+ access_like_token.as_deref(),
+ )
+ .or_else(|| env::var("GT_TOKEN").ok())
+ {
+ return Ok(GiteaAuthConfig {
+ token,
+ source: AuthSource::EnvVar,
+ host,
+ });
+ }
+
+ Err(Error::Auth(
+ "No Gitea authentication found. Run `tea login add` or set GITEA_TOKEN".to_string(),
+ ))
+}
+
+#[derive(Deserialize)]
+struct TeaLogin {
+ url: String,
+ default: String,
+}
+
+async fn get_tea_default_host() -> Option {
+ Command::new("tea").arg("--version").output().await.ok()?;
+
+ let output = Command::new("tea")
+ .args(["login", "list", "-o", "json"])
+ .output()
+ .await
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let logins: Vec = serde_json::from_slice(&output.stdout).ok()?;
+ let default_login = logins.into_iter().find(|login| login.default == "true")?;
+ let url = url::Url::parse(&default_login.url).ok()?;
+ url.host_str().map(ToString::to_string)
+}
+
+async fn get_tea_cli_token(host: &str) -> Option {
+ Command::new("tea").arg("--version").output().await.ok()?;
+
+ let output = Command::new("tea")
+ .args(["auth", "token", "--host", host])
+ .output()
+ .await
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if token.is_empty() { None } else { Some(token) }
+}
+
+#[derive(Deserialize)]
+struct GiteaUser {
+ login: String,
+}
+
+/// Test Gitea authentication
+pub async fn test_gitea_auth(config: &GiteaAuthConfig) -> Result {
+ let url = format!("https://{}/api/v1/user", config.host);
+ let client = Client::builder()
+ .timeout(std::time::Duration::from_secs(30))
+ .build()
+ .map_err(|e| Error::GiteaApi(format!("failed to create HTTP client: {e}")))?;
+
+ let user: GiteaUser = client
+ .get(&url)
+ .header("Authorization", format!("token {}", config.token))
+ .send()
+ .await?
+ .error_for_status()
+ .map_err(|e| Error::Auth(format!("Invalid token: {e}")))?
+ .json()
+ .await?;
+
+ Ok(user.login)
+}
diff --git a/src/auth/mod.rs b/src/auth/mod.rs
index 7a7377a..a34a6e1 100644
--- a/src/auth/mod.rs
+++ b/src/auth/mod.rs
@@ -1,10 +1,14 @@
-//! Authentication for GitHub and GitLab
+//! Authentication for GitHub, GitLab, and Gitea
//!
//! Supports CLI-based auth (gh, glab) and environment variables.
+mod gitea;
mod github;
mod gitlab;
+pub use gitea::{
+ GiteaAuthConfig, get_gitea_auth, pick_gitea_env_token, resolve_gitea_host, test_gitea_auth,
+};
pub use github::{GitHubAuthConfig, get_github_auth, test_github_auth};
pub use gitlab::{GitLabAuthConfig, get_gitlab_auth, test_gitlab_auth};
diff --git a/src/cli/auth.rs b/src/cli/auth.rs
index 9e8b193..d8500a4 100644
--- a/src/cli/auth.rs
+++ b/src/cli/auth.rs
@@ -3,7 +3,10 @@
use crate::cli::style::{Stylize, check, spinner_style};
use anstream::println;
use indicatif::ProgressBar;
-use jj_ryu::auth::{get_github_auth, get_gitlab_auth, test_github_auth, test_gitlab_auth};
+use jj_ryu::auth::{
+ get_gitea_auth, get_github_auth, get_gitlab_auth, test_gitea_auth, test_github_auth,
+ test_gitlab_auth,
+};
use jj_ryu::error::Result;
use jj_ryu::types::Platform;
use std::time::Duration;
@@ -33,6 +36,20 @@ pub async fn run_auth_test(platform: Platform) -> Result<()> {
let config = get_gitlab_auth(None).await?;
let username = test_gitlab_auth(&config).await?;
+ spinner.finish_and_clear();
+ println!("{} Authenticated as: {}", check(), username.accent());
+ println!(" {} {:?}", "Token source:".muted(), config.source);
+ println!(" {} {}", "Host:".muted(), config.host);
+ }
+ Platform::Gitea => {
+ let spinner = ProgressBar::new_spinner();
+ spinner.set_style(spinner_style());
+ spinner.set_message("Testing Gitea authentication...");
+ spinner.enable_steady_tick(Duration::from_millis(80));
+
+ let config = get_gitea_auth(None).await?;
+ let username = test_gitea_auth(&config).await?;
+
spinner.finish_and_clear();
println!("{} Authenticated as: {}", check(), username.accent());
println!(" {} {:?}", "Token source:".muted(), config.source);
@@ -82,6 +99,30 @@ pub fn run_auth_setup(platform: Platform) {
println!("{}", "For self-hosted GitLab:".muted());
println!(" {}", "Set GITLAB_HOST to your instance hostname".muted());
}
+ Platform::Gitea => {
+ println!("{}", "Gitea Authentication Setup".emphasis());
+ println!();
+ println!("{}", "Option 1: tea CLI".emphasis());
+ println!(" Install: {}", "https://gitea.com/gitea/tea".accent());
+ println!(" Run: {}", "tea login add".accent());
+ println!(
+ " {}",
+ "ryu can read the host from your default tea login".muted()
+ );
+ println!();
+ println!("{}", "Option 2: Environment variable".emphasis());
+ println!(" Set one of:");
+ println!(" {}", "GITEA_TOKEN".accent());
+ println!(" {}", "GITEA_ACCESS_TOKEN".accent());
+ println!(" {}", "GITEA_KEY".accent());
+ println!(" {}", "GT_TOKEN".accent());
+ println!();
+ println!("{}", "Gitea requires your instance hostname.".muted());
+ println!(
+ " {}",
+ "Set GITEA_HOST, or configure a default tea login".muted()
+ );
+ }
}
}
diff --git a/src/error.rs b/src/error.rs
index 82b5f84..d4e4a09 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -24,8 +24,8 @@ pub enum Error {
#[error("{0}")]
NoStack(String),
- /// No supported remotes (GitHub/GitLab) found
- #[error("no supported remotes found (GitHub/GitLab)")]
+ /// No supported remotes (GitHub/GitLab/Gitea) found
+ #[error("no supported remotes found (GitHub/GitLab/Gitea)")]
NoSupportedRemotes,
/// Specified remote not found
@@ -44,6 +44,10 @@ pub enum Error {
#[error("GitLab API error: {0}")]
GitLabApi(String),
+ /// Gitea API error
+ #[error("Gitea API error: {0}")]
+ GiteaApi(String),
+
/// Merge commit detected (cannot stack)
#[error("merge commit detected in bookmark '{0}' history - rebasing required")]
MergeCommitDetected(String),
diff --git a/src/main.rs b/src/main.rs
index 51fca01..851ab08 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,7 +11,7 @@ mod cli;
#[derive(Parser)]
#[command(name = "ryu")]
-#[command(about = "Stacked PRs for Jujutsu - GitHub & GitLab")]
+#[command(about = "Stacked PRs for Jujutsu - GitHub, GitLab, & Gitea")]
#[command(version)]
struct Cli {
/// Path to jj repository (defaults to current directory)
@@ -140,6 +140,11 @@ enum AuthPlatform {
#[command(subcommand)]
action: AuthAction,
},
+ /// Gitea authentication
+ Gitea {
+ #[command(subcommand)]
+ action: AuthAction,
+ },
}
#[derive(Subcommand)]
@@ -236,6 +241,13 @@ async fn main() -> Result<()> {
};
cli::run_auth(Platform::GitLab, action_str).await?;
}
+ AuthPlatform::Gitea { action } => {
+ let action_str = match action {
+ AuthAction::Test => "test",
+ AuthAction::Setup => "setup",
+ };
+ cli::run_auth(Platform::Gitea, action_str).await?;
+ }
},
Some(Commands::Track {
bookmarks,
diff --git a/src/platform/detection.rs b/src/platform/detection.rs
index d26864f..7ba0f8c 100644
--- a/src/platform/detection.rs
+++ b/src/platform/detection.rs
@@ -14,10 +14,11 @@ static RE_SSH: LazyLock =
static RE_HTTPS: LazyLock =
LazyLock::new(|| Regex::new(r"https?://[^/]+/(.+?)(?:\.git)?$").unwrap());
-/// Detect platform (GitHub or GitLab) from a remote URL
+/// Detect platform (GitHub, GitLab, or Gitea) from a remote URL
pub fn detect_platform(url: &str) -> Option {
let gh_host = env::var("GH_HOST").ok();
let gitlab_host = env::var("GITLAB_HOST").ok();
+ let gitea_host = env::var("GITEA_HOST").ok();
let hostname = extract_hostname(url)?;
@@ -37,6 +38,11 @@ pub fn detect_platform(url: &str) -> Option {
return Some(Platform::GitLab);
}
+ // Check Gitea
+ if gitea_host.as_ref().is_some_and(|h| hostname == *h) {
+ return Some(Platform::Gitea);
+ }
+
None
}
@@ -80,6 +86,7 @@ pub fn parse_repo_info(url: &str) -> Result {
None
}
}
+ Platform::Gitea => hostname,
};
Ok(PlatformConfig {
diff --git a/src/platform/factory.rs b/src/platform/factory.rs
index 47b39a2..3f2d507 100644
--- a/src/platform/factory.rs
+++ b/src/platform/factory.rs
@@ -2,14 +2,14 @@
//!
//! Creates platform services based on configuration.
-use crate::auth::{get_github_auth, get_gitlab_auth};
+use crate::auth::{get_gitea_auth, get_github_auth, get_gitlab_auth};
use crate::error::Result;
-use crate::platform::{GitHubService, GitLabService, PlatformService};
+use crate::platform::{GitHubService, GitLabService, GiteaService, PlatformService};
use crate::types::{Platform, PlatformConfig};
/// Create a platform service from configuration
///
-/// Handles authentication and client construction for both GitHub and GitLab.
+/// Handles authentication and client construction for supported platforms.
pub async fn create_platform_service(config: &PlatformConfig) -> Result> {
match config.platform {
Platform::GitHub => {
@@ -30,5 +30,14 @@ pub async fn create_platform_service(config: &PlatformConfig) -> Result {
+ let auth = get_gitea_auth(config.host.as_deref()).await?;
+ Ok(Box::new(GiteaService::new(
+ auth.token.clone(),
+ config.owner.clone(),
+ config.repo.clone(),
+ Some(auth.host),
+ )?))
+ }
}
}
diff --git a/src/platform/gitea.rs b/src/platform/gitea.rs
new file mode 100644
index 0000000..29e3765
--- /dev/null
+++ b/src/platform/gitea.rs
@@ -0,0 +1,314 @@
+//! Gitea platform service implementation
+
+use crate::error::{Error, Result};
+use crate::platform::PlatformService;
+use crate::types::{Platform, PlatformConfig, PrComment, PullRequest};
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::{Deserialize, Serialize};
+use tracing::debug;
+
+/// Gitea service using reqwest
+pub struct GiteaService {
+ client: Client,
+ token: String,
+ base_url: String,
+ config: PlatformConfig,
+}
+
+#[derive(Deserialize)]
+struct GiteaBranchRef {
+ #[serde(rename = "ref")]
+ reference: String,
+}
+
+#[derive(Deserialize)]
+struct GiteaHeadRef {
+ #[serde(rename = "ref")]
+ reference: String,
+ #[allow(dead_code)]
+ label: Option,
+}
+
+#[derive(Deserialize)]
+struct GiteaPullRequest {
+ number: u64,
+ html_url: String,
+ title: String,
+ #[serde(default)]
+ draft: bool,
+ base: GiteaBranchRef,
+ head: GiteaHeadRef,
+}
+
+#[derive(Deserialize)]
+struct GiteaIssueComment {
+ id: u64,
+ body: String,
+}
+
+#[derive(Serialize)]
+struct CreatePrPayload<'a> {
+ title: String,
+ head: &'a str,
+ base: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ draft: Option,
+}
+
+impl From for PullRequest {
+ fn from(pr: GiteaPullRequest) -> Self {
+ Self {
+ number: pr.number,
+ html_url: pr.html_url,
+ base_ref: pr.base.reference,
+ head_ref: pr.head.reference,
+ title: pr.title,
+ node_id: None,
+ is_draft: pr.draft,
+ }
+ }
+}
+
+const DEFAULT_TIMEOUT_SECS: u64 = 30;
+
+impl GiteaService {
+ /// Create a new Gitea service
+ pub fn new(token: String, owner: String, repo: String, host: Option) -> Result {
+ let raw_host = host.unwrap_or_else(|| "gitea.com".to_string());
+ let base_url = if raw_host.starts_with("http://") || raw_host.starts_with("https://") {
+ raw_host.clone()
+ } else {
+ format!("https://{raw_host}")
+ };
+
+ let client = Client::builder()
+ .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
+ .build()
+ .map_err(|e| Error::GiteaApi(format!("failed to create HTTP client: {e}")))?;
+
+ let config_host = if raw_host == "gitea.com" {
+ None
+ } else if raw_host.starts_with("http://") || raw_host.starts_with("https://") {
+ Some(
+ raw_host
+ .trim_start_matches("https://")
+ .trim_start_matches("http://")
+ .to_string(),
+ )
+ } else {
+ Some(raw_host.clone())
+ };
+
+ Ok(Self {
+ client,
+ token,
+ base_url,
+ config: PlatformConfig {
+ platform: Platform::Gitea,
+ owner,
+ repo,
+ host: config_host,
+ },
+ })
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ format!("{}/api/v1{}", self.base_url, path)
+ }
+
+ fn auth_header(&self) -> String {
+ format!("token {}", self.token)
+ }
+
+ fn pulls_path(&self) -> String {
+ format!("/repos/{}/{}/pulls", self.config.owner, self.config.repo)
+ }
+
+ fn comments_path(&self, pr_number: u64) -> String {
+ format!(
+ "/repos/{}/{}/issues/{pr_number}/comments",
+ self.config.owner, self.config.repo
+ )
+ }
+
+ fn strip_wip_prefix(title: &str) -> String {
+ title
+ .trim_start_matches("WIP: ")
+ .trim_start_matches("[WIP] ")
+ .to_string()
+ }
+}
+
+#[async_trait]
+impl PlatformService for GiteaService {
+ async fn find_existing_pr(&self, head_branch: &str) -> Result