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

@@ -1,15 +1,20 @@
# Gitea API coverage # Gitea-compatible API coverage
Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance. Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance.
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
definitions. definitions.
`gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete `gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete
`Client` methods own the supported Gitea workflows and relationships, while `Client` methods own the supported Gitea and Forgejo workflows and
relationships, while
`gotcha_gitea::models` exposes generated response records for presentation. `gotcha_gitea::models` exposes generated response records for presentation.
Generated API modules and client configuration are deliberately private so the Generated API modules and client configuration are deliberately private so the
CLI and app cannot duplicate Gitea behavior. CLI and app cannot duplicate Gitea behavior.
Forgejo uses the common generated client for its documented Gitea-compatible
`/api/v1` surface. Provider discovery and provider-specific endpoints, such as
Forgejo's dedicated version API, are routed inside `gotcha_gitea::Client`.
| Domain | Queries | Other actions | Generated client | CLI queries | CLI writes | Capabilities | | Domain | Queries | Other actions | Generated client | CLI queries | CLI writes | Capabilities |
| --- | ---: | ---: | --- | ---: | ---: | --- | | --- | ---: | ---: | --- | ---: | ---: | --- |
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation | | activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |

View File

@@ -1,8 +1,8 @@
# Gotcha # Gotcha
Gotcha is a lightweight Gitea client with a reusable Rust core, a CLI, and a Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
native iOS application. The iOS interface is UIKit/Swift; UniFFI exposes the CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI
Rust application logic to Swift. exposes the Rust application logic to Swift.
On the icon: git in a tea cup. it is obvious, isn't it? On the icon: git in a tea cup. it is obvious, isn't it?
@@ -17,8 +17,8 @@ not necessarily the app to use when you manage or frequent a large instance.
## Workspace ## Workspace
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete - `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
typed Gitea 1.25 API and model surface complete typed Gitea 1.25-compatible API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests - `gotcha`: CLI for typed common operations and arbitrary API requests
- `gotcha-app`: Rust application core and UniFFI API used by iOS - `gotcha-app`: Rust application core and UniFFI API used by iOS
- `ios`: native UIKit application for activity, repositories, favorites, - `ios`: native UIKit application for activity, repositories, favorites,
@@ -41,6 +41,7 @@ Store each server profile in `~/.config/gotcha/config`:
```sh ```sh
cargo run -p gotcha-cli -- auth login gitea.example.com cargo run -p gotcha-cli -- auth login gitea.example.com
cargo run -p gotcha-cli -- auth login forgejo.example.com --provider forgejo
cargo run -p gotcha-cli -- server version cargo run -p gotcha-cli -- server version
cargo run -p gotcha-cli -- user show cargo run -p gotcha-cli -- user show
@@ -56,15 +57,18 @@ cargo run -p gotcha-cli -- api request GET repos/owner/project/issues
cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}' cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}'
``` ```
`auth login` derives `https://gitea.example.com` from the server name and reads `auth login` derives `https://gitea.example.com` from the server name, discovers
the token from standard input with echo disabled. The resulting plain YAML file Gitea or Forgejo, and reads the token from standard input with echo disabled.
at `~/.config/gotcha/config` has mode `0600` and one entry per server: The optional provider requires the selected API when automatic discovery is
not sufficient. The resulting plain YAML file at `~/.config/gotcha/config` has
mode `0600` and one entry per server:
```yaml ```yaml
servers: servers:
gitea.example.com: gitea.example.com:
url: https://gitea.example.com url: https://gitea.example.com
token: your-token token: your-token
provider: gitea
``` ```
Inside a Git repository, Gotcha matches its remotes to these server URLs and Inside a Git repository, Gotcha matches its remotes to these server URLs and
@@ -74,14 +78,14 @@ accepted as command-line arguments or environment variables.
## Architecture ## Architecture
`gotcha_gitea` owns Gitea access, authentication, validation, mutations, and `gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
relationships between Gitea objects. The CLI is a terminal presentation layer: mutations, and relationships between server objects. The CLI is a terminal presentation layer:
it parses arguments and YAML, invokes shared client operations, and formats the it parses arguments and YAML, invokes shared client operations, and formats the
results. `gotcha-app` owns application state, preferences, favorites, results. `gotcha-app` owns application state, preferences, favorites,
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
navigation, controls, layout, and other platform presentation behavior. navigation, controls, layout, and other platform presentation behavior.
New Gitea workflows belong in `gotcha_gitea::Client` first, then receive CLI or New server workflows belong in `gotcha_gitea::Client` first, then receive CLI or
app presentation as needed. Future work includes iOS integrations such as app presentation as needed. Future work includes iOS integrations such as
sharing, notifications, and background refresh. sharing, notifications, and background refresh.

View File

@@ -94,6 +94,9 @@ answer before uploading it to App Store Connect.
- [ ] With no configured server, Issues and Repos show the Servers screen and - [ ] With no configured server, Issues and Repos show the Servers screen and
its empty state. its empty state.
- [ ] The add button presents Add Server; Cancel dismisses it. - [ ] The add button presents Add Server; Cancel dismisses it.
- [ ] API provider is a native single-selection menu with Gitea selected by
default and Forgejo available; selecting either updates the displayed
value, VoiceOver value, and URL example.
- [ ] Name, Server URL, and Access token use native text fields and suitable - [ ] Name, Server URL, and Access token use native text fields and suitable
keyboards; Next advances between fields and Done submits. keyboards; Next advances between fields and Done submits.
- [ ] Long-press in every field shows the native loupe, insertion point, and - [ ] Long-press in every field shows the native loupe, insertion point, and
@@ -104,7 +107,12 @@ answer before uploading it to App Store Connect.
- [ ] The token is obscured, remains editable, and does not trigger a password - [ ] The token is obscured, remains editable, and does not trigger a password
save prompt. save prompt.
- [ ] Empty or invalid values show an error without adding a server. - [ ] Empty or invalid values show an error without adding a server.
- [ ] Valid credentials add and select the server; all data tabs load. - [ ] Valid Gitea credentials add and select the server; all data tabs load.
- [ ] Valid Forgejo credentials add and select the server; repositories,
issues, milestones, pulls, activity, commits, files, and mutations use
the same native screens and load successfully.
- [ ] Leave Gitea selected while adding a modern Forgejo server; provider
discovery identifies Forgejo and the server works after relaunch.
- [ ] Terminate and relaunch the app; the selected server and Keychain token - [ ] Terminate and relaunch the app; the selected server and Keychain token
still work without re-entry. still work without re-entry.
- [ ] Open the server picker from a repository list and switch between at least - [ ] Open the server picker from a repository list and switch between at least

View File

@@ -424,7 +424,7 @@ pub async fn load_home(
} }
fn client(server: &Server) -> Result<Client, String> { 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> { fn scope(owner: &str, repository: &str) -> Result<RepositoryId, String> {

View File

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

View File

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

View File

@@ -21,6 +21,21 @@ pub enum RepositoryPane {
Milestones, 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 { impl RepositoryPane {
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones]; const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];

View File

@@ -1,6 +1,6 @@
use std::{env, fs, path::PathBuf}; 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 security_framework::passwords::{get_generic_password, set_generic_password};
use crate::{ use crate::{
@@ -20,7 +20,12 @@ pub fn repository_key(server: &str, owner: &str, repository: &str) -> String {
format!("{server}|{owner}/{repository}") 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 name = name.trim();
let url = url.trim().trim_end_matches('/'); let url = url.trim().trim_end_matches('/');
let token = token.trim(); 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() { if token.is_empty() {
return Err("Enter an access token.".into()); 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 { Ok(Server {
name: name.into(), name: name.into(),
url: url.into(), url: url.into(),
provider,
token: token.into(), token: token.into(),
}) })
} }
@@ -120,9 +126,19 @@ mod tests {
#[test] #[test]
fn validates_servers_and_builds_stable_favorite_keys() { fn validates_servers_and_builds_stable_favorite_keys() {
assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok()); assert!(
assert!(validate_server("", "https://gitea.example.com", "secret").is_err()); validate_server(
assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err()); "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!( assert_eq!(
favorite_key( favorite_key(
RepositoryPane::Issues, RepositoryPane::Issues,
@@ -163,6 +179,9 @@ mod tests {
.appearance, .appearance,
crate::domain::AppearanceMode::Auto 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!( assert_eq!(
crate::domain::AppearanceMode::from_index(1), crate::domain::AppearanceMode::from_index(1),
Some(crate::domain::AppearanceMode::Light) Some(crate::domain::AppearanceMode::Light)

View File

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

View File

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

View File

@@ -23,6 +23,26 @@ fn parses_server_selection_and_repository_scope() {
.unwrap() .unwrap()
.starts_with("Usage: gotcha repo show") .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] #[test]

View File

@@ -1,11 +1,11 @@
use std::{error, fmt}; use std::{error, fmt, str::FromStr};
use reqwest::{ use reqwest::{
Client as HttpClient, Client as HttpClient,
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}, header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
}; };
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url}; pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use gitea_openapi::apis; use gitea_openapi::apis;
pub use gitea_openapi::models; 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>; 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<()> { pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 { if page < 1 || limit < 1 {
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
@@ -67,7 +98,7 @@ impl fmt::Display for Error {
Self::Transport(error) => error.fmt(formatter), Self::Transport(error) => error.fmt(formatter),
Self::Generated(message) => formatter.write_str(message), Self::Generated(message) => formatter.write_str(message),
Self::Api { status, 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)] #[derive(Clone, Debug)]
pub struct Client { pub struct Client {
api_url: Url, api_url: Url,
forgejo_api_url: Url,
http: HttpClient, http: HttpClient,
provider: Provider,
} }
impl Client { impl Client {
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> { 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) 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") { if !matches!(instance_url.scheme(), "http" | "https") {
return Err(Error::Configuration( 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() { if !instance_url.username().is_empty() || instance_url.password().is_some() {
return Err(Error::Configuration( return Err(Error::Configuration(
"Gitea URL must not contain credentials".into(), "Server URL must not contain credentials".into(),
)); ));
} }
if !instance_url.path().ends_with('/') { if !instance_url.path().ends_with('/') {
@@ -121,25 +162,61 @@ impl Client {
let api_url = instance_url let api_url = instance_url
.join("api/v1/") .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(); let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json")); headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(token) = token { if let Some(token) = token {
let value = HeaderValue::from_str(&format!("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); headers.insert(AUTHORIZATION, value);
} }
Ok(Self { Ok(Self {
api_url, api_url,
forgejo_api_url,
http: HttpClient::builder().default_headers(headers).build()?, 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 { pub fn api_url(&self) -> &Url {
&self.api_url &self.api_url
} }
pub fn provider(&self) -> Provider {
self.provider
}
/// Returns an authenticated configuration for every generated typed API. /// Returns an authenticated configuration for every generated typed API.
pub(crate) fn configuration(&self) -> apis::configuration::Configuration { pub(crate) fn configuration(&self) -> apis::configuration::Configuration {
apis::configuration::Configuration { apis::configuration::Configuration {
@@ -188,11 +265,27 @@ impl Client {
} }
pub async fn version(&self) -> Result<models::ServerVersion> { 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()) apis::miscellaneous_api::get_version(&self.configuration())
.await .await
.map_err(|error| Error::Generated(error.to_string())) .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> { pub async fn current_user(&self) -> Result<models::User> {
apis::user_api::user_get_current(&self.configuration()) apis::user_api::user_get_current(&self.configuration())
.await .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)] #[derive(Debug, Deserialize)]
struct ApiError { struct ApiError {
#[serde(default)] #[serde(default)]
@@ -230,7 +330,12 @@ mod tests {
#[test] #[test]
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() { 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!( assert_eq!(
client.api_url().as_str(), client.api_url().as_str(),
@@ -247,6 +352,20 @@ mod tests {
client.configuration().base_path, client.configuration().base_path,
"https://example.com/gitea/api/v1" "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(); let _typed_model = models::Issue::default();
} }
} }

View File

@@ -669,7 +669,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func activeServerName() -> String? func activeServerName() -> String?
func addServer(name: String, url: String, token: String) async throws -> UInt32 func addServer(name: String, url: String, token: String, provider: ServerProvider) async throws -> UInt32
func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage
@@ -1224,12 +1224,12 @@ open func activeServerName() -> String? {
}) })
} }
open func addServer(name: String, url: String, token: String)async throws -> UInt32 { open func addServer(name: String, url: String, token: String, provider: ServerProvider)async throws -> UInt32 {
return return
try await uniffiRustCallAsync( try await uniffiRustCallAsync(
rustFutureFunc: { rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_add_server( uniffi_gotcha_core_fn_method_gotchacore_add_server(
self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token) self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token),FfiConverterTypeServerProvider_lower(provider)
) )
}, },
pollFunc: ffi_gotcha_core_rust_future_poll_u32, pollFunc: ffi_gotcha_core_rust_future_poll_u32,
@@ -4163,6 +4163,72 @@ public func FfiConverterTypeRepositoryPane_lower(_ value: RepositoryPane) -> Rus
public enum ServerProvider: Equatable, Hashable {
case gitea
case forgejo
}
#if compiler(>=6)
extension ServerProvider: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeServerProvider: FfiConverterRustBuffer {
typealias SwiftType = ServerProvider
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServerProvider {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .gitea
case 2: return .forgejo
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: ServerProvider, into buf: inout [UInt8]) {
switch value {
case .gitea:
writeInt(&buf, Int32(1))
case .forgejo:
writeInt(&buf, Int32(2))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeServerProvider_lift(_ buf: RustBuffer) throws -> ServerProvider {
return try FfiConverterTypeServerProvider.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeServerProvider_lower(_ value: ServerProvider) -> RustBuffer {
return FfiConverterTypeServerProvider.lower(value)
}
public enum WorkItemState: Equatable, Hashable { public enum WorkItemState: Equatable, Hashable {
case `open` case `open`
@@ -4943,7 +5009,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 45045) { if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 45045) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 8514) { if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 51168) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) { if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) {

View File

@@ -426,7 +426,7 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t p
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token, RustBuffer provider
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME

View File

@@ -21,7 +21,7 @@ final class ServersViewController: UITableViewController {
servers = context.core.servers() servers = context.core.servers()
tableView.reloadData() tableView.reloadData()
tableView.backgroundView = servers.isEmpty tableView.backgroundView = servers.isEmpty
? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.") ? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.")
: nil : nil
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .add, systemItem: .add,
@@ -70,6 +70,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
private let nameField = UITextField() private let nameField = UITextField()
private let urlField = UITextField() private let urlField = UITextField()
private let tokenField = UITextField() private let tokenField = UITextField()
private let providerButton = UIButton(type: .system)
private var provider = ServerProvider.gitea
private var saveButton: UIBarButtonItem! private var saveButton: UIBarButtonItem!
init(context: AppContext, completion: @escaping () -> Void) { init(context: AppContext, completion: @escaping () -> Void) {
@@ -103,13 +105,14 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
tokenField.isSecureTextEntry = true tokenField.isSecureTextEntry = true
tokenField.autocapitalizationType = .none tokenField.autocapitalizationType = .none
tokenField.returnKeyType = .done tokenField.returnKeyType = .done
configureProviderButton()
} }
override func numberOfSections(in tableView: UITableView) -> Int { 3 } override func numberOfSections(in tableView: UITableView) -> Int { 4 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
["Name", "Server URL", "Access token"][section] ["API provider", "Name", "Server URL", "Access token"][section]
} }
override func tableView( override func tableView(
@@ -117,14 +120,16 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
cellForRowAt indexPath: IndexPath cellForRowAt indexPath: IndexPath
) -> UITableViewCell { ) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil) let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
let field = [nameField, urlField, tokenField][indexPath.section] let control: UIView = indexPath.section == 0
field.translatesAutoresizingMaskIntoConstraints = false ? providerButton
cell.contentView.addSubview(field) : [nameField, urlField, tokenField][indexPath.section - 1]
control.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(control)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
field.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor), control.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48), cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
]) ])
return cell return cell
@@ -148,7 +153,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
let index = try await context.core.addServer( let index = try await context.core.addServer(
name: nameField.text ?? "", name: nameField.text ?? "",
url: urlField.text ?? "", url: urlField.text ?? "",
token: tokenField.text ?? "" token: tokenField.text ?? "",
provider: provider
) )
try context.didAddServer(index: index) try context.didAddServer(index: index)
completion() completion()
@@ -174,4 +180,24 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
field.adjustsFontForContentSizeCategory = true field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body) field.font = .preferredFont(forTextStyle: .body)
} }
private func configureProviderButton() {
providerButton.contentHorizontalAlignment = .leading
providerButton.showsMenuAsPrimaryAction = true
providerButton.changesSelectionAsPrimaryAction = true
providerButton.accessibilityLabel = "API provider"
providerButton.accessibilityValue = "Gitea"
providerButton.menu = UIMenu(options: .singleSelection, children: [
UIAction(title: "Gitea", state: .on) { [weak self] _ in
self?.provider = .gitea
self?.urlField.placeholder = "https://gitea.example.com"
self?.providerButton.accessibilityValue = "Gitea"
},
UIAction(title: "Forgejo") { [weak self] _ in
self?.provider = .forgejo
self?.urlField.placeholder = "https://forgejo.example.com"
self?.providerButton.accessibilityValue = "Forgejo"
},
])
}
} }