Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img width="366" height="366" alt="image" src="https://github.com/user-attachments/assets/1691edfc-3b65-4f8d-b959-71ff21ff23e5" />

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

Expand Down Expand Up @@ -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 <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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions src/auth/gitea.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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 <host>`)
/// 2. `GITEA_TOKEN` environment variable
/// 3. `GT_TOKEN` environment variable
pub async fn get_gitea_auth(host: Option<&str>) -> Result<GiteaAuthConfig> {
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<String> {
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<TeaLogin> = 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<String> {
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<String> {
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)
}
6 changes: 5 additions & 1 deletion src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down
43 changes: 42 additions & 1 deletion src/cli/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
);
}
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
14 changes: 13 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -140,6 +140,11 @@ enum AuthPlatform {
#[command(subcommand)]
action: AuthAction,
},
/// Gitea authentication
Gitea {
#[command(subcommand)]
action: AuthAction,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/platform/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ static RE_SSH: LazyLock<Regex> =
static RE_HTTPS: LazyLock<Regex> =
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<Platform> {
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)?;

Expand All @@ -37,6 +38,11 @@ pub fn detect_platform(url: &str) -> Option<Platform> {
return Some(Platform::GitLab);
}

// Check Gitea
if gitea_host.as_ref().is_some_and(|h| hostname == *h) {
return Some(Platform::Gitea);
}

None
}

Expand Down Expand Up @@ -80,6 +86,7 @@ pub fn parse_repo_info(url: &str) -> Result<PlatformConfig> {
None
}
}
Platform::Gitea => hostname,
};

Ok(PlatformConfig {
Expand Down
Loading