Support Forgejo servers

This commit is contained in:
Georg Bauer
2026-08-03 18:14:21 +02:00
parent 46cca8fdd2
commit d47b21d6a6
15 changed files with 405 additions and 78 deletions

View File

@@ -424,7 +424,7 @@ pub async fn load_home(
}
fn client(server: &Server) -> Result<Client, String> {
Client::new(&server.url, Some(&server.token)).map_err(message)
Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message)
}
fn scope(owner: &str, repository: &str) -> Result<RepositoryId, String> {

View File

@@ -77,13 +77,17 @@ impl GotchaCore {
name: String,
url: String,
token: String,
provider: ServerProvider,
) -> Result<u32, GotchaError> {
let server = validate_server(&name, &url, &token)?;
Client::new(&server.url, Some(&server.token))
.map_err(|error| error.to_string())?
let mut server = validate_server(&name, &url, &token, provider.into())?;
let client = Client::discover(&server.url, Some(&server.token), server.provider)
.await
.map_err(|error| error.to_string())?;
client
.current_user()
.await
.map_err(|error| error.to_string())?;
server.provider = client.provider();
save_server_token(&server)?;
let mut state = self.state.lock().unwrap();
state.preferences.servers.push(server);

View File

@@ -39,6 +39,8 @@ impl AppearanceMode {
pub struct Server {
pub name: String,
pub url: String,
#[serde(default)]
pub provider: gotcha_gitea::Provider,
#[serde(skip)]
pub token: String,
}

View File

@@ -21,6 +21,21 @@ pub enum RepositoryPane {
Milestones,
}
#[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum ServerProvider {
Gitea,
Forgejo,
}
impl From<ServerProvider> for gotcha_gitea::Provider {
fn from(provider: ServerProvider) -> Self {
match provider {
ServerProvider::Gitea => Self::Gitea,
ServerProvider::Forgejo => Self::Forgejo,
}
}
}
impl RepositoryPane {
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];

View File

@@ -1,6 +1,6 @@
use std::{env, fs, path::PathBuf};
use gotcha_gitea::Client;
use gotcha_gitea::{Client, Provider};
use security_framework::passwords::{get_generic_password, set_generic_password};
use crate::{
@@ -20,7 +20,12 @@ pub fn repository_key(server: &str, owner: &str, repository: &str) -> String {
format!("{server}|{owner}/{repository}")
}
pub fn validate_server(name: &str, url: &str, token: &str) -> Result<Server, String> {
pub fn validate_server(
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<Server, String> {
let name = name.trim();
let url = url.trim().trim_end_matches('/');
let token = token.trim();
@@ -30,10 +35,11 @@ pub fn validate_server(name: &str, url: &str, token: &str) -> Result<Server, Str
if token.is_empty() {
return Err("Enter an access token.".into());
}
Client::new(url, Some(token)).map_err(|error| error.to_string())?;
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
Ok(Server {
name: name.into(),
url: url.into(),
provider,
token: token.into(),
})
}
@@ -120,9 +126,19 @@ mod tests {
#[test]
fn validates_servers_and_builds_stable_favorite_keys() {
assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok());
assert!(validate_server("", "https://gitea.example.com", "secret").is_err());
assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err());
assert!(
validate_server(
"Work",
"https://gitea.example.com/",
"secret",
Provider::Gitea
)
.is_ok()
);
assert!(
validate_server("", "https://gitea.example.com", "secret", Provider::Gitea).is_err()
);
assert!(validate_server("Work", "file:///tmp/gitea", "secret", Provider::Forgejo).is_err());
assert_eq!(
favorite_key(
RepositoryPane::Issues,
@@ -163,6 +179,9 @@ mod tests {
.appearance,
crate::domain::AppearanceMode::Auto
);
let legacy_server: Server =
serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap();
assert_eq!(legacy_server.provider, Provider::Gitea);
assert_eq!(
crate::domain::AppearanceMode::from_index(1),
Some(crate::domain::AppearanceMode::Light)

View File

@@ -10,7 +10,7 @@ use std::{
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use gotcha_gitea::{Client, RepositoryId, Url};
use gotcha_gitea::{Client, Provider, RepositoryId, Url};
use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, String>;
@@ -19,6 +19,8 @@ type Result<T> = std::result::Result<T, String>;
pub struct Server {
pub url: String,
pub token: String,
#[serde(default)]
pub provider: Provider,
}
#[derive(Default, Deserialize, Serialize)]
@@ -33,6 +35,7 @@ pub struct Selection {
pub name: Option<String>,
pub url: String,
pub token: Option<String>,
pub provider: Provider,
pub repository: Option<RepositoryScope>,
}
@@ -59,20 +62,21 @@ impl Config {
config.path = path;
for (name, server) in &config.servers {
validate_name(name)?;
Client::new(&server.url, Some(&server.token))
Client::with_provider(&server.url, Some(&server.token), server.provider)
.map_err(|error| format!("invalid server {name}: {error}"))?;
}
Ok(config)
}
pub fn login(&mut self, name: &str, token: &str) -> Result<()> {
pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
let url = server_url(name)?;
Client::new(&url, Some(token)).map_err(|error| error.to_string())?;
Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?;
self.servers.insert(
name.into(),
Server {
url,
token: token.into(),
provider,
},
);
self.save()
@@ -111,6 +115,7 @@ impl Config {
&Server {
url: url.into(),
token: String::new(),
provider: Provider::Gitea,
},
&remotes,
)),
@@ -138,6 +143,7 @@ impl Config {
name: Some((*name).clone()),
url: server.url.clone(),
token: Some(server.token.clone()),
provider: server.provider,
repository: Some(scope.clone()),
}),
_ => Err("multiple server profiles match this Git repository; use --server".into()),
@@ -183,6 +189,7 @@ fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selecti
name: name.map(str::to_owned),
url: server.url.clone(),
token: (!server.token.is_empty()).then(|| server.token.clone()),
provider: server.provider,
repository: remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote)),
@@ -300,15 +307,25 @@ mod tests {
config.select(None, None).err().unwrap(),
"no servers configured; run `gotcha auth login SERVER`"
);
config.login("code.example", "secret").unwrap();
config
.login("code.example", "secret", Provider::Forgejo)
.unwrap();
let loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.servers["code.example"].token, "secret");
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
assert_eq!(
loaded.select(Some("code.example"), None).unwrap().provider,
Provider::Forgejo
);
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
.contains("token: secret")
.contains("provider: forgejo")
);
let legacy: Server =
serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
assert_eq!(legacy.provider, Provider::Gitea);
let scope = repository_scope(
"https://code.example/gitea",
"https://code.example/gitea/alice/project.git",

View File

@@ -4,7 +4,7 @@ mod work_items;
use std::{env, error::Error, process};
use config::{Config, RepositoryScope, Selection, server_url};
use gotcha_gitea::{Client, Method, Repository, ServerVersion, User};
use gotcha_gitea::{Client, Method, Provider, Repository, ServerVersion, User};
use serde_json::Value;
const ROOT_HELP: &str = "\
@@ -15,13 +15,13 @@ Usage:
Commands:
auth Manage server authentication
server Inspect the selected Gitea server
server Inspect the selected Gitea or Forgejo server
user Work with the authenticated user
repo Work with repositories
issue Work with issues and comments
milestone Work with repository milestones
pull Work with pull requests
api Send a raw Gitea API request
api Send a raw server API request
Run `gotcha COMMAND` to list that command's subcommands.
@@ -33,7 +33,8 @@ const AUTH_HELP: &str = "\
Usage: gotcha auth SUBCOMMAND
Subcommands:
login SERVER Verify and store a token read from standard input
login SERVER [--provider gitea|forgejo]
Verify and store a token read from standard input
list List configured servers without showing tokens
status Show the selected server and repository scope
logout SERVER Remove a stored server and token";
@@ -42,7 +43,7 @@ const SERVER_HELP: &str = "\
Usage: gotcha server SUBCOMMAND
Subcommands:
version Show the selected Gitea server version";
version Show the selected server version";
const USER_HELP: &str = "\
Usage: gotcha user SUBCOMMAND
@@ -82,24 +83,26 @@ async fn run() -> Result<(), Box<dyn Error>> {
}
let mut config = Config::load()?;
match args.command.as_slice() {
[domain, command, name] if domain == "auth" && command == "login" => {
let url = server_url(name)?;
let token = rpassword::prompt_password("Token: ")?;
if token.is_empty() {
return Err("token must not be empty".into());
}
let user = Client::new(&url, Some(&token))?.current_user().await?;
config.login(name, &token)?;
println!(
"Logged in to {url} as {} ({name}).",
user.login.as_deref().unwrap_or("unknown")
);
return Ok(());
if let Some((name, fallback)) = login_command(&args.command)? {
let url = server_url(name)?;
let token = rpassword::prompt_password("Token: ")?;
if token.is_empty() {
return Err("token must not be empty".into());
}
let client = Client::discover(&url, Some(&token), fallback).await?;
let user = client.current_user().await?;
config.login(name, &token, client.provider())?;
println!(
"Logged in to {url} as {} ({name}, {}).",
user.login.as_deref().unwrap_or("unknown"),
client.provider()
);
return Ok(());
}
match args.command.as_slice() {
[domain, command] if domain == "auth" && command == "list" => {
for (name, server) in &config.servers {
println!("{name}\t{}", server.url);
println!("{name}\t{}\t{}", server.provider, server.url);
}
return Ok(());
}
@@ -118,7 +121,11 @@ async fn run() -> Result<(), Box<dyn Error>> {
return Ok(());
}
let client = Client::new(&selection.url, selection.token.as_deref())?;
let client = Client::with_provider(
&selection.url,
selection.token.as_deref(),
selection.provider,
)?;
if work_items::run(&args.command, &selection, &client).await? {
return Ok(());
}
@@ -160,6 +167,20 @@ fn print_version(version: &ServerVersion) {
println!("{}", version.version.as_deref().unwrap_or("unknown"));
}
fn login_command(command: &[String]) -> Result<Option<(&str, Provider)>, Box<dyn Error>> {
match command {
[domain, action, name] if domain == "auth" && action == "login" => {
Ok(Some((name, Provider::Gitea)))
}
[domain, action, name, option, provider]
if domain == "auth" && action == "login" && option == "--provider" =>
{
Ok(Some((name, provider.parse()?)))
}
_ => Ok(None),
}
}
fn print_user(user: &User) {
field("login", user.login.as_deref());
field("name", user.full_name.as_deref());
@@ -354,7 +375,7 @@ fn domain_help(domain: &str) -> Option<&'static str> {
fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
match (domain, subcommand) {
("auth", "login") => Some(
"Usage: gotcha auth login SERVER\n\nPrompts for a token, verifies it, and stores the server in the YAML config.",
"Usage: gotcha auth login SERVER [--provider gitea|forgejo]\n\nPrompts for a token, discovers and verifies the provider, and stores the server in the YAML config.",
),
("auth", "list") => {
Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
@@ -365,7 +386,7 @@ fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
("auth", "logout") => Some(
"Usage: gotcha auth logout SERVER\n\nRemoves the server and its token from the YAML config.",
),
("server", "version") => Some("Usage: gotcha server version\n\nShows the Gitea version."),
("server", "version") => Some("Usage: gotcha server version\n\nShows the server version."),
("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."),
("repo", "list") => Some(
"Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.",
@@ -402,6 +423,7 @@ fn print_status(selection: &Selection) {
"no"
}
);
println!("provider: {}", selection.provider);
if let Some(scope) = &selection.repository {
println!("repository: {}/{}", scope.owner, scope.repository);
}

View File

@@ -23,6 +23,26 @@ fn parses_server_selection_and_repository_scope() {
.unwrap()
.starts_with("Usage: gotcha repo show")
);
assert_eq!(
login_command(&["auth".into(), "login".into(), "code.example".into()])
.unwrap()
.unwrap()
.1,
Provider::Gitea
);
assert_eq!(
login_command(&[
"auth".into(),
"login".into(),
"code.example".into(),
"--provider".into(),
"forgejo".into(),
])
.unwrap()
.unwrap()
.1,
Provider::Forgejo
);
}
#[test]

View File

@@ -1,11 +1,11 @@
use std::{error, fmt};
use std::{error, fmt, str::FromStr};
use reqwest::{
Client as HttpClient,
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
};
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use gitea_openapi::apis;
pub use gitea_openapi::models;
@@ -30,6 +30,37 @@ pub use pulls::{PullFileSource, pull_file_source, pull_state};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
#[default]
Gitea,
Forgejo,
}
impl fmt::Display for Provider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Gitea => "gitea",
Self::Forgejo => "forgejo",
})
}
}
impl FromStr for Provider {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"gitea" => Ok(Self::Gitea),
"forgejo" => Ok(Self::Forgejo),
_ => Err(Error::InvalidInput(
"provider must be gitea or forgejo".into(),
)),
}
}
}
pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
@@ -67,7 +98,7 @@ impl fmt::Display for Error {
Self::Transport(error) => error.fmt(formatter),
Self::Generated(message) => formatter.write_str(message),
Self::Api { status, message } => {
write!(formatter, "Gitea returned {status}: {message}")
write!(formatter, "Server returned {status}: {message}")
}
}
}
@@ -97,22 +128,32 @@ impl Error {
#[derive(Clone, Debug)]
pub struct Client {
api_url: Url,
forgejo_api_url: Url,
http: HttpClient,
provider: Provider,
}
impl Client {
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> {
Self::with_provider(instance_url, token, Provider::Gitea)
}
pub fn with_provider(
instance_url: &str,
token: Option<&str>,
provider: Provider,
) -> Result<Self> {
let mut instance_url = Url::parse(instance_url)
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
if !matches!(instance_url.scheme(), "http" | "https") {
return Err(Error::Configuration(
"Gitea URL must use http or https".into(),
"Server URL must use http or https".into(),
));
}
if !instance_url.username().is_empty() || instance_url.password().is_some() {
return Err(Error::Configuration(
"Gitea URL must not contain credentials".into(),
"Server URL must not contain credentials".into(),
));
}
if !instance_url.path().ends_with('/') {
@@ -121,25 +162,61 @@ impl Client {
let api_url = instance_url
.join("api/v1/")
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
let forgejo_api_url = instance_url
.join("api/forgejo/v1/")
.map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?;
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(token) = token {
let value = HeaderValue::from_str(&format!("token {token}"))
.map_err(|_| Error::Configuration("invalid Gitea token".into()))?;
.map_err(|_| Error::Configuration("invalid server token".into()))?;
headers.insert(AUTHORIZATION, value);
}
Ok(Self {
api_url,
forgejo_api_url,
http: HttpClient::builder().default_headers(headers).build()?,
provider,
})
}
pub async fn discover(
instance_url: &str,
token: Option<&str>,
fallback: Provider,
) -> Result<Self> {
let mut client = Self::with_provider(instance_url, token, fallback)?;
let version = client.gitea_version().await?;
if is_forgejo_version(&version) {
client.provider = Provider::Forgejo;
return Ok(client);
}
match client.forgejo_version().await {
Ok(_) => client.provider = Provider::Forgejo,
Err(Error::Api { status, .. }) if status == StatusCode::NOT_FOUND => {
if fallback == Provider::Forgejo {
return Err(Error::Configuration(
"Server does not expose the Forgejo API".into(),
));
}
client.provider = Provider::Gitea;
}
Err(error) => return Err(error),
}
Ok(client)
}
pub fn api_url(&self) -> &Url {
&self.api_url
}
pub fn provider(&self) -> Provider {
self.provider
}
/// Returns an authenticated configuration for every generated typed API.
pub(crate) fn configuration(&self) -> apis::configuration::Configuration {
apis::configuration::Configuration {
@@ -188,11 +265,27 @@ impl Client {
}
pub async fn version(&self) -> Result<models::ServerVersion> {
match self.provider {
Provider::Gitea => self.gitea_version().await,
Provider::Forgejo => self.forgejo_version().await,
}
}
async fn gitea_version(&self) -> Result<models::ServerVersion> {
apis::miscellaneous_api::get_version(&self.configuration())
.await
.map_err(|error| Error::Generated(error.to_string()))
}
async fn forgejo_version(&self) -> Result<models::ServerVersion> {
let request =
self.http
.get(self.forgejo_api_url.join("version").map_err(|error| {
Error::Configuration(format!("invalid Forgejo endpoint: {error}"))
})?);
Ok(self.execute(request).await?.json().await?)
}
pub async fn current_user(&self) -> Result<models::User> {
apis::user_api::user_get_current(&self.configuration())
.await
@@ -218,6 +311,13 @@ impl Client {
}
}
fn is_forgejo_version(version: &models::ServerVersion) -> bool {
version
.version
.as_deref()
.is_some_and(|version| version.contains("+gitea-"))
}
#[derive(Debug, Deserialize)]
struct ApiError {
#[serde(default)]
@@ -230,7 +330,12 @@ mod tests {
#[test]
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() {
let client = Client::new("https://example.com/gitea", Some("secret")).unwrap();
let client = Client::with_provider(
"https://example.com/gitea",
Some("secret"),
Provider::Forgejo,
)
.unwrap();
assert_eq!(
client.api_url().as_str(),
@@ -247,6 +352,20 @@ mod tests {
client.configuration().base_path,
"https://example.com/gitea/api/v1"
);
assert_eq!(client.provider(), Provider::Forgejo);
assert_eq!(
client.forgejo_api_url.as_str(),
"https://example.com/gitea/api/forgejo/v1/"
);
assert_eq!("GITEA".parse::<Provider>().unwrap(), Provider::Gitea);
assert_eq!(Provider::Forgejo.to_string(), "forgejo");
assert!("github".parse::<Provider>().is_err());
assert!(is_forgejo_version(&models::ServerVersion {
version: Some("16.0.1+gitea-1.24.6".into()),
}));
assert!(!is_forgejo_version(&models::ServerVersion {
version: Some("1.25.2".into()),
}));
let _typed_model = models::Issue::default();
}
}