From 70c2749afe7c8ae03d9a0bc7ac65449763deecba Mon Sep 17 00:00:00 2001 From: gmackie Date: Mon, 30 Mar 2026 10:37:55 -0700 Subject: [PATCH] feat: add self-hosted gitea support --- README.md | 21 ++- src/auth/gitea.rs | 152 ++++++++++++++++ src/auth/mod.rs | 6 +- src/cli/auth.rs | 43 ++++- src/error.rs | 8 +- src/main.rs | 14 +- src/platform/detection.rs | 9 +- src/platform/factory.rs | 15 +- src/platform/gitea.rs | 314 ++++++++++++++++++++++++++++++++++ src/platform/mod.rs | 6 +- src/submit/execute.rs | 2 +- src/types.rs | 3 + tests/common/fixtures.rs | 10 ++ tests/gitea_platform_tests.rs | 197 +++++++++++++++++++++ tests/integration_tests.rs | 57 +++++- tests/unit_tests.rs | 75 ++++++++ 16 files changed, 916 insertions(+), 16 deletions(-) create mode 100644 src/auth/gitea.rs create mode 100644 src/platform/gitea.rs create mode 100644 tests/gitea_platform_tests.rs diff --git a/README.md b/README.md index 01cc1a6..ba54581 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ image -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> { + debug!(head_branch, "finding existing Gitea PR"); + let url = self.api_url(&self.pulls_path()); + + let prs: Vec = self + .client + .get(&url) + .header("Authorization", self.auth_header()) + .query(&[("state", "open")]) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + Ok(prs + .into_iter() + .find(|pr| pr.head.reference == head_branch) + .map(Into::into)) + } + + async fn create_pr_with_options( + &self, + head: &str, + base: &str, + title: &str, + draft: bool, + ) -> Result { + debug!(head, base, draft, "creating Gitea PR"); + let url = self.api_url(&self.pulls_path()); + let payload = CreatePrPayload { + title: title.to_string(), + head, + base, + draft: if draft { Some(true) } else { None }, + }; + + let pr: GiteaPullRequest = self + .client + .post(&url) + .header("Authorization", self.auth_header()) + .json(&payload) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + Ok(pr.into()) + } + + async fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result { + debug!(pr_number, new_base, "updating Gitea PR base"); + let url = self.api_url(&format!("{}/{}", self.pulls_path(), pr_number)); + + let pr: GiteaPullRequest = self + .client + .patch(&url) + .header("Authorization", self.auth_header()) + .json(&serde_json::json!({ "base": new_base })) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + Ok(pr.into()) + } + + async fn publish_pr(&self, pr_number: u64) -> Result { + debug!(pr_number, "publishing Gitea PR"); + let url = self.api_url(&format!("{}/{}", self.pulls_path(), pr_number)); + + let current: GiteaPullRequest = self + .client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + let published_title = Self::strip_wip_prefix(¤t.title); + if published_title == current.title { + return Ok(current.into()); + } + + let pr: GiteaPullRequest = self + .client + .patch(&url) + .header("Authorization", self.auth_header()) + .json(&serde_json::json!({ "title": published_title })) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + Ok(pr.into()) + } + + async fn list_pr_comments(&self, pr_number: u64) -> Result> { + debug!(pr_number, "listing Gitea PR comments"); + let url = self.api_url(&self.comments_path(pr_number)); + + let comments: Vec = self + .client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))? + .json() + .await?; + + Ok(comments + .into_iter() + .map(|comment| PrComment { + id: comment.id, + body: comment.body, + }) + .collect()) + } + + async fn create_pr_comment(&self, pr_number: u64, body: &str) -> Result<()> { + debug!(pr_number, "creating Gitea PR comment"); + let url = self.api_url(&self.comments_path(pr_number)); + + self.client + .post(&url) + .header("Authorization", self.auth_header()) + .json(&serde_json::json!({ "body": body })) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))?; + + Ok(()) + } + + async fn update_pr_comment(&self, _pr_number: u64, comment_id: u64, body: &str) -> Result<()> { + debug!(comment_id, "updating Gitea PR comment"); + let url = self.api_url(&format!( + "/repos/{}/{}/issues/comments/{}", + self.config.owner, self.config.repo, comment_id + )); + + self.client + .patch(&url) + .header("Authorization", self.auth_header()) + .json(&serde_json::json!({ "body": body })) + .send() + .await? + .error_for_status() + .map_err(|e| Error::GiteaApi(e.to_string()))?; + + Ok(()) + } + + fn config(&self) -> &PlatformConfig { + &self.config + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0c9bbe8..4ee78b1 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,14 +1,16 @@ -//! Platform services for GitHub and GitLab +//! Platform services for GitHub, GitLab, and Gitea //! //! Provides a unified interface for PR/MR operations across platforms. mod detection; mod factory; +mod gitea; mod github; mod gitlab; pub use detection::{detect_platform, parse_repo_info}; pub use factory::create_platform_service; +pub use gitea::GiteaService; pub use github::GitHubService; pub use gitlab::GitLabService; @@ -18,7 +20,7 @@ use async_trait::async_trait; /// Platform service trait for PR/MR operations /// -/// This trait abstracts GitHub and GitLab operations, allowing the same +/// This trait abstracts supported platform operations, allowing the same /// submission logic to work with either platform. #[async_trait] pub trait PlatformService: Send + Sync { diff --git a/src/submit/execute.rs b/src/submit/execute.rs index 5ed2787..b7621e2 100644 --- a/src/submit/execute.rs +++ b/src/submit/execute.rs @@ -404,7 +404,7 @@ fn format_stack_comment_for_platform( for (i, item) in data.stack.iter().rev().enumerate() { let is_current = i == reversed_idx; match platform { - Platform::GitHub => { + Platform::GitHub | Platform::Gitea => { // GitHub: "* PR title #N" - #N auto-links to PRs if is_current { let _ = writeln!( diff --git a/src/types.rs b/src/types.rs index 368e1c6..7e29d49 100644 --- a/src/types.rs +++ b/src/types.rs @@ -129,6 +129,8 @@ pub enum Platform { GitHub, /// GitLab or self-hosted GitLab GitLab, + /// Gitea or self-hosted Gitea + Gitea, } impl std::fmt::Display for Platform { @@ -136,6 +138,7 @@ impl std::fmt::Display for Platform { match self { Self::GitHub => write!(f, "GitHub"), Self::GitLab => write!(f, "GitLab"), + Self::Gitea => write!(f, "Gitea"), } } } diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs index 87f60b7..c9d35e1 100644 --- a/tests/common/fixtures.rs +++ b/tests/common/fixtures.rs @@ -119,6 +119,16 @@ pub fn gitlab_config() -> PlatformConfig { } } +/// Create a Gitea platform config +pub fn gitea_config() -> PlatformConfig { + PlatformConfig { + platform: Platform::Gitea, + owner: "testowner".to_string(), + repo: "testrepo".to_string(), + host: Some("gitea.example.local".to_string()), + } +} + /// Build a linear stack graph: trunk -> bm1 -> bm2 -> bm3 /// /// Returns a `ChangeGraph` with a single stack containing the given bookmarks. diff --git a/tests/gitea_platform_tests.rs b/tests/gitea_platform_tests.rs new file mode 100644 index 0000000..37f8896 --- /dev/null +++ b/tests/gitea_platform_tests.rs @@ -0,0 +1,197 @@ +//! Gitea platform service tests. + +use jj_ryu::platform::{GiteaService, PlatformService}; +use mockito::{Matcher, Server}; +use serde_json::json; + +fn make_service(server: &Server) -> GiteaService { + GiteaService::new( + "test-token".to_string(), + "org".to_string(), + "repo".to_string(), + Some(server.url()), + ) + .expect("create gitea service") +} + +fn pr_response(number: u64, title: &str, head: &str, base: &str, draft: bool) -> serde_json::Value { + json!({ + "number": number, + "html_url": format!("https://gitea.example.local/org/repo/pulls/{number}"), + "title": title, + "draft": draft, + "head": { + "ref": head, + "label": format!("org:{head}") + }, + "base": { + "ref": base + } + }) +} + +#[tokio::test] +async fn test_find_existing_pr_filters_open_pulls_by_head_ref() { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/api/v1/repos/org/repo/pulls") + .match_header("authorization", "token test-token") + .match_query(Matcher::UrlEncoded("state".into(), "open".into())) + .with_status(200) + .with_body( + json!([ + pr_response(11, "Other", "other-branch", "main", false), + pr_response(12, "Feature", "feature-branch", "main", false) + ]) + .to_string(), + ) + .create_async() + .await; + + let service = make_service(&server); + let pr = service + .find_existing_pr("feature-branch") + .await + .expect("find existing pr") + .expect("matching pr"); + + assert_eq!(pr.number, 12); + assert_eq!(pr.head_ref, "feature-branch"); + assert_eq!(pr.base_ref, "main"); +} + +#[tokio::test] +async fn test_create_pr_with_options_posts_gitea_payload() { + let mut server = Server::new_async().await; + let _mock = server + .mock("POST", "/api/v1/repos/org/repo/pulls") + .match_header("authorization", "token test-token") + .match_body(Matcher::Json(json!({ + "title": "Add feature", + "head": "feature-branch", + "base": "main", + "draft": true + }))) + .with_status(200) + .with_body(pr_response(21, "Add feature", "feature-branch", "main", true).to_string()) + .create_async() + .await; + + let service = make_service(&server); + let pr = service + .create_pr_with_options("feature-branch", "main", "Add feature", true) + .await + .expect("create pr"); + + assert_eq!(pr.number, 21); + assert!(pr.is_draft); +} + +#[tokio::test] +async fn test_update_pr_base_patches_pull_request() { + let mut server = Server::new_async().await; + let _mock = server + .mock("PATCH", "/api/v1/repos/org/repo/pulls/21") + .match_header("authorization", "token test-token") + .match_body(Matcher::Json(json!({ + "base": "feat-a" + }))) + .with_status(200) + .with_body(pr_response(21, "Add feature", "feature-branch", "feat-a", false).to_string()) + .create_async() + .await; + + let service = make_service(&server); + let pr = service + .update_pr_base(21, "feat-a") + .await + .expect("update pr base"); + + assert_eq!(pr.base_ref, "feat-a"); +} + +#[tokio::test] +async fn test_list_pr_comments_uses_issue_comments_endpoint() { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/api/v1/repos/org/repo/issues/21/comments") + .match_header("authorization", "token test-token") + .with_status(200) + .with_body(json!([{ "id": 7, "body": "stack comment" }]).to_string()) + .create_async() + .await; + + let service = make_service(&server); + let comments = service.list_pr_comments(21).await.expect("list comments"); + + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].id, 7); + assert_eq!(comments[0].body, "stack comment"); +} + +#[tokio::test] +async fn test_create_pr_comment_posts_to_issue_comments_endpoint() { + let mut server = Server::new_async().await; + let _mock = server + .mock("POST", "/api/v1/repos/org/repo/issues/21/comments") + .match_header("authorization", "token test-token") + .match_body(Matcher::Json(json!({ + "body": "hello from jj-ryu" + }))) + .with_status(201) + .create_async() + .await; + + let service = make_service(&server); + service + .create_pr_comment(21, "hello from jj-ryu") + .await + .expect("create comment"); +} + +#[tokio::test] +async fn test_update_pr_comment_patches_issue_comment() { + let mut server = Server::new_async().await; + let _mock = server + .mock("PATCH", "/api/v1/repos/org/repo/issues/comments/7") + .match_header("authorization", "token test-token") + .match_body(Matcher::Json(json!({ + "body": "updated stack comment" + }))) + .with_status(200) + .create_async() + .await; + + let service = make_service(&server); + service + .update_pr_comment(21, 7, "updated stack comment") + .await + .expect("update comment"); +} + +#[tokio::test] +async fn test_publish_pr_removes_wip_prefix_from_title() { + let mut server = Server::new_async().await; + let _get_mock = server + .mock("GET", "/api/v1/repos/org/repo/pulls/17") + .match_header("authorization", "token test-token") + .with_status(200) + .with_body(pr_response(17, "WIP: Implement auth", "feat-auth", "main", false).to_string()) + .create_async() + .await; + let _patch_mock = server + .mock("PATCH", "/api/v1/repos/org/repo/pulls/17") + .match_header("authorization", "token test-token") + .match_body(Matcher::Json(json!({ + "title": "Implement auth" + }))) + .with_status(200) + .with_body(pr_response(17, "Implement auth", "feat-auth", "main", false).to_string()) + .create_async() + .await; + + let service = make_service(&server); + let pr = service.publish_pr(17).await.expect("publish pr"); + + assert_eq!(pr.title, "Implement auth"); +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index bfd3122..7f07dce 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -5,7 +5,7 @@ mod common; use assert_cmd::Command; -use common::{MockPlatformService, TempJjRepo, github_config, make_pr}; +use common::{MockPlatformService, TempJjRepo, gitea_config, github_config, make_pr}; use jj_ryu::graph::build_change_graph; use jj_ryu::submit::{ExecutionStep, analyze_submission, create_submission_plan}; use predicates::prelude::*; @@ -21,7 +21,8 @@ fn test_cli_help() { cmd.assert() .success() - .stdout(predicate::str::contains("Stacked PRs for Jujutsu")); + .stdout(predicate::str::contains("Stacked PRs for Jujutsu")) + .stdout(predicate::str::contains("Gitea")); } #[test] @@ -62,7 +63,24 @@ fn test_auth_help() { cmd.assert() .success() .stdout(predicate::str::contains("github")) - .stdout(predicate::str::contains("gitlab")); + .stdout(predicate::str::contains("gitlab")) + .stdout(predicate::str::contains("gitea")); +} + +#[test] +fn test_gitea_auth_setup() { + let mut cmd = Command::cargo_bin("ryu").unwrap(); + cmd.args(["auth", "gitea", "setup"]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("Gitea Authentication Setup")) + .stdout(predicate::str::contains("GITEA_HOST")) + .stdout(predicate::str::contains("GITEA_TOKEN")) + .stdout(predicate::str::contains("GITEA_ACCESS_TOKEN")) + .stdout(predicate::str::contains("GITEA_KEY")) + .stdout(predicate::str::contains("GT_TOKEN")) + .stdout(predicate::str::contains("tea login add")); } #[test] @@ -337,6 +355,39 @@ async fn test_plan_pr_numbers_increment() { assert_eq!(creates[1].bookmark.name, "feat-b"); } +#[tokio::test] +async fn test_gitea_config_submission_plan_matches_existing_behavior() { + let repo = TempJjRepo::new(); + repo.build_stack(&[("feat-a", "Add A"), ("feat-b", "Add B")]); + + let workspace = repo.workspace(); + let graph = build_change_graph(&workspace).expect("build graph"); + let analysis = analyze_submission(&graph, Some("feat-b")).expect("analyze"); + + let mock = MockPlatformService::with_config(gitea_config()); + mock.set_find_pr_response("feat-a", Some(make_pr(41, "feat-a", "main"))); + + let plan = create_submission_plan(&analysis, &mock, "origin", "main") + .await + .expect("create plan"); + + assert_eq!(plan.count_creates(), 1); + assert_eq!(plan.count_updates(), 0); + assert!(plan.existing_prs.contains_key("feat-a")); + + let create = plan + .execution_steps + .iter() + .find_map(|step| match step { + ExecutionStep::CreatePr(create) => Some(create), + _ => None, + }) + .expect("expected create step"); + + assert_eq!(create.bookmark.name, "feat-b"); + assert_eq!(create.base_branch, "feat-a"); +} + // ============================================================================= // Git Fetch Tests (Issue #8) // ============================================================================= diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index baddca6..9e07127 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -212,12 +212,26 @@ mod detection_test { assert_eq!(platform, None); } + #[test] + fn test_gitea_dot_com_returns_none() { + let platform = detect_platform("https://gitea.com/owner/repo.git"); + assert_eq!(platform, None); + } + #[test] fn test_parse_unknown_platform_returns_error() { let result = parse_repo_info("https://bitbucket.org/owner/repo.git"); assert!(result.is_err()); } + #[test] + fn test_unsupported_remote_error_mentions_gitea() { + assert_eq!( + Error::NoSupportedRemotes.to_string(), + "no supported remotes found (GitHub/GitLab/Gitea)" + ); + } + #[test] fn test_invalid_url_returns_no_supported_remotes() { // Invalid URLs that can't be parsed return NoSupportedRemotes @@ -260,6 +274,67 @@ mod detection_test { } } +mod auth_test { + use jj_ryu::auth::{pick_gitea_env_token, resolve_gitea_host}; + + #[test] + fn test_resolve_gitea_host_prefers_explicit_arg() { + assert_eq!( + resolve_gitea_host(Some("git.example.local"), Some("gitea.example.com")), + "git.example.local" + ); + } + + #[test] + fn test_resolve_gitea_host_falls_back_to_env() { + assert_eq!( + resolve_gitea_host(None, Some("git.example.local")), + "git.example.local" + ); + } + + #[test] + fn test_resolve_gitea_host_returns_empty_without_sources() { + assert_eq!(resolve_gitea_host(None, None), ""); + } + + #[test] + fn test_pick_gitea_env_token_prefers_gitea_token() { + assert_eq!( + pick_gitea_env_token(Some("gitea-token"), Some("gt-token")), + Some("gitea-token".to_string()) + ); + } + + #[test] + fn test_pick_gitea_env_token_can_chain_access_token_fallback() { + let access_like_token = pick_gitea_env_token(Some("access-token"), Some("gitea-key")); + + assert_eq!( + pick_gitea_env_token(None, access_like_token.as_deref()), + Some("access-token".to_string()) + ); + } + + #[test] + fn test_pick_gitea_env_token_can_chain_gitea_key_fallback() { + let access_like_token = pick_gitea_env_token(None, Some("gitea-key")); + + assert_eq!( + pick_gitea_env_token(None, access_like_token.as_deref()), + Some("gitea-key".to_string()) + ); + } + + #[test] + fn test_pick_gitea_env_token_falls_back_to_gt_token() { + assert_eq!( + pick_gitea_env_token(None, Some("gt-token")), + Some("gt-token".to_string()) + ); + } +} + mod plan_test { use crate::common::{MockPlatformService, github_config, make_linear_stack, make_pr}; use jj_ryu::submit::{ExecutionStep, analyze_submission, create_submission_plan};