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.
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
definitions.
`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.
Generated API modules and client configuration are deliberately private so the
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 |
| --- | ---: | ---: | --- | ---: | ---: | --- |
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |

View File

@@ -1,8 +1,8 @@
# Gotcha
Gotcha is a lightweight Gitea client with a reusable Rust core, a CLI, and a
native iOS application. The iOS interface is UIKit/Swift; UniFFI exposes the
Rust application logic to Swift.
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI
exposes the Rust application logic to Swift.
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
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
typed Gitea 1.25 API and model surface
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
complete typed Gitea 1.25-compatible API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests
- `gotcha-app`: Rust application core and UniFFI API used by iOS
- `ios`: native UIKit application for activity, repositories, favorites,
@@ -41,6 +41,7 @@ Store each server profile in `~/.config/gotcha/config`:
```sh
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 -- 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"}'
```
`auth login` derives `https://gitea.example.com` from the server name and reads
the token from standard input with echo disabled. The resulting plain YAML file
at `~/.config/gotcha/config` has mode `0600` and one entry per server:
`auth login` derives `https://gitea.example.com` from the server name, discovers
Gitea or Forgejo, and reads the token from standard input with echo disabled.
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
servers:
gitea.example.com:
url: https://gitea.example.com
token: your-token
provider: gitea
```
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
`gotcha_gitea` owns Gitea access, authentication, validation, mutations, and
relationships between Gitea objects. The CLI is a terminal presentation layer:
`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
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
results. `gotcha-app` owns application state, preferences, favorites,
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
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
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
its empty state.
- [ ] 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
keyboards; Next advances between fields and Done submits.
- [ ] 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
save prompt.
- [ ] 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
still work without re-entry.
- [ ] 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> {
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" => {
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 user = Client::new(&url, Some(&token))?.current_user().await?;
config.login(name, &token)?;
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")
"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();
}
}

View File

@@ -669,7 +669,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
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
@@ -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
try await uniffiRustCallAsync(
rustFutureFunc: {
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,
@@ -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 {
case `open`
@@ -4943,7 +5009,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 45045) {
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
}
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
#ifndef 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
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME

View File

@@ -21,7 +21,7 @@ final class ServersViewController: UITableViewController {
servers = context.core.servers()
tableView.reloadData()
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
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .add,
@@ -70,6 +70,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
private let nameField = UITextField()
private let urlField = UITextField()
private let tokenField = UITextField()
private let providerButton = UIButton(type: .system)
private var provider = ServerProvider.gitea
private var saveButton: UIBarButtonItem!
init(context: AppContext, completion: @escaping () -> Void) {
@@ -103,13 +105,14 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
tokenField.isSecureTextEntry = true
tokenField.autocapitalizationType = .none
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, titleForHeaderInSection section: Int) -> String? {
["Name", "Server URL", "Access token"][section]
["API provider", "Name", "Server URL", "Access token"][section]
}
override func tableView(
@@ -117,14 +120,16 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
let field = [nameField, urlField, tokenField][indexPath.section]
field.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(field)
let control: UIView = indexPath.section == 0
? providerButton
: [nameField, urlField, tokenField][indexPath.section - 1]
control.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(control)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
field.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
control.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
])
return cell
@@ -148,7 +153,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
let index = try await context.core.addServer(
name: nameField.text ?? "",
url: urlField.text ?? "",
token: tokenField.text ?? ""
token: tokenField.text ?? "",
provider: provider
)
try context.didAddServer(index: index)
completion()
@@ -174,4 +180,24 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
field.adjustsFontForContentSizeCategory = true
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"
},
])
}
}