initial commit

This commit is contained in:
Georg Bauer
2026-07-30 11:41:19 +02:00
commit f21292978c
12 changed files with 3715 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
.env

41
API_COVERAGE.md Normal file
View File

@@ -0,0 +1,41 @@
# Gitea 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` exposes the complete typed Gitea 1.25 client through
`gotcha_gitea::apis` and every generated request/response model through
`gotcha_gitea::models`. `Client::configuration()` supplies the selected base URL
and token to those generated functions.
| Domain | Queries | Other actions | API crate | CLI queries | CLI writes | Capabilities |
| --- | ---: | ---: | --- | ---: | ---: | --- |
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |
| admin | 14 | 18 | Complete | 0 | 0 | Users, organizations, email, hooks, cron, unadopted repositories, Actions runners/jobs/runs |
| issue | 23 | 46 | Complete | 5 | 7 | Issues, comments, attachments, reactions, labels, milestones, dependencies, subscriptions, time tracking, pins |
| miscellaneous | 10 | 3 | Complete | 1 | 0 | Version, templates, licenses, node info, signing keys, Markdown/markup rendering |
| notification | 4 | 3 | Complete | 0 | 0 | Notification threads, unread checks, read state |
| organization | 33 | 33 | Complete | 0 | 0 | Organizations, members, teams, repositories, labels, hooks, blocks, activities, Actions |
| package | 5 | 3 | Complete | 0 | 0 | Packages, versions, files, linking and deletion |
| repository | 105 | 86 | Complete | 6 | 3 | Search, repositories, branches, protections, commits, content, refs, tags, releases, pull requests, reviews, collaborators, hooks, wiki, mirrors, Actions |
| settings | 4 | 0 | Complete | 0 | 0 | Instance API, attachment, repository, and UI settings |
| user | 44 | 31 | Complete | 2 | 0 | Users, profile, following, stars, watches, keys, GPG, email, hooks, OAuth, tokens, blocks, Actions, tracked time |
| **Total** | **243** | **224** | **467/467** | **14/243** | **10/224** | |
## CLI status
Typed text commands currently implemented include:
- `server version``getVersion`
- `user show``userGetCurrent`
- `repo list``userCurrentListRepos`
- `repo show``repoGet`
- issue list/show/create/edit/delete/comments/comment
- milestone list/show/create/edit/delete
- pull list/show/create/edit/merge/commits/files/reviews
The remaining 229 query operations and 214 mutation operations are available to
Rust callers through the typed API modules but do not yet have purpose-built CLI
commands or text/input views. `api request` remains an explicit JSON escape hatch;
it is not counted as typed CLI coverage.

1736
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[workspace]
members = ["crates/gitea", "crates/cli"]
resolver = "3"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
[workspace.dependencies]
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
rpassword = "7"
serde_yaml = "0.9"

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Gotcha contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# Gotcha
Gotcha is a lightweight Gitea client built in Rust. Development starts with a
reusable API crate and a CLI test bed; the same crate will back the Slint iOS
application.
## Workspace
- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete
typed Gitea 1.25 API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests
- iOS/Slint app: next crate, bundle identifier `de.rfc1437.gotcha`
The generated API modules and models cover every Gitea 1.25 operation. The
low-level request API remains available for newer instance-specific endpoints;
purpose-built CLI and app views are added feature-by-feature.
See [API_COVERAGE.md](API_COVERAGE.md) for the live contract inventory and the
separate API-crate and CLI coverage status.
## CLI
Store each server profile in `~/.config/gotcha/config`:
```sh
cargo run -p gotcha-cli -- auth login gitea.example.com
cargo run -p gotcha-cli -- server version
cargo run -p gotcha-cli -- user show
cargo run -p gotcha-cli -- repo list
cargo run -p gotcha-cli -- repo show
cargo run -p gotcha-cli -- issue list
cargo run -p gotcha-cli -- milestone list
cargo run -p gotcha-cli -- pull list
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:
```yaml
servers:
gitea.example.com:
url: https://gitea.example.com
token: your-token
```
Inside a Git repository, Gotcha matches its remotes to these server URLs and
derives the `owner/repository` scope. Use `--server gitea.example.com` when selection is
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
accepted as command-line arguments or environment variables.
## Direction
1. Exercise and type API areas in the CLI: repositories, issues and pull
requests, Actions, notifications, organizations, packages, administration.
2. Add the Slint shell and move proven read workflows into touch-first screens.
3. Add write workflows and iOS integrations such as secure token storage,
sharing, notifications, and background refresh.

19
crates/cli/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "gotcha-cli"
version = "0.1.0"
description = "CLI test bed for the Gotcha Gitea client"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "gotcha"
path = "src/main.rs"
[dependencies]
gotcha_gitea = { path = "../gitea" }
serde_json.workspace = true
tokio.workspace = true
rpassword.workspace = true
serde.workspace = true
serde_yaml.workspace = true

348
crates/cli/src/config.rs Normal file
View File

@@ -0,0 +1,348 @@
use std::{
collections::BTreeMap,
env, fs,
io::Write,
path::PathBuf,
process::{self, Command},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use gotcha_gitea::{Client, Url};
use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, String>;
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Server {
pub url: String,
pub token: String,
}
#[derive(Default, Deserialize, Serialize)]
pub struct Config {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
pub servers: BTreeMap<String, Server>,
}
pub struct Selection {
pub name: Option<String>,
pub url: String,
pub token: Option<String>,
pub repository: Option<RepositoryScope>,
}
#[derive(Clone)]
pub struct RepositoryScope {
pub owner: String,
pub repository: String,
}
impl RepositoryScope {
pub fn parse(value: &str) -> Result<Self> {
let (owner, repository) = value
.split_once('/')
.ok_or("repository must be OWNER/REPOSITORY")?;
if owner.is_empty() || repository.is_empty() || repository.contains('/') {
return Err("repository must be OWNER/REPOSITORY".into());
}
Ok(Self {
owner: owner.into(),
repository: repository.into(),
})
}
}
impl Config {
pub fn load() -> Result<Self> {
let home = env::var_os("HOME").ok_or("HOME is not set")?;
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
}
fn load_from(path: PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self {
path,
..Self::default()
});
}
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let mut config: Self = serde_yaml::from_str(&text)
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
config.path = path;
for (name, server) in &config.servers {
validate_name(name)?;
Client::new(&server.url, Some(&server.token))
.map_err(|error| format!("invalid server {name}: {error}"))?;
}
Ok(config)
}
pub fn login(&mut self, name: &str, token: &str) -> Result<()> {
let url = server_url(name)?;
Client::new(&url, Some(token)).map_err(|error| error.to_string())?;
self.servers.insert(
name.into(),
Server {
url,
token: token.into(),
},
);
self.save()
}
pub fn logout(&mut self, name: &str) -> Result<()> {
if self.servers.remove(name).is_none() {
return Err(format!("server profile {name:?} does not exist"));
}
self.save()
}
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
if name.is_some() && url.is_some() {
return Err("use either --server or --url, not both".into());
}
let remotes = git_remotes().unwrap_or_default();
if let Some(name) = name {
let server = self
.servers
.get(name)
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
return Ok(selection(Some(name), server, &remotes));
}
if let Some(url) = url {
let matches: Vec<_> = self
.servers
.iter()
.filter(|(_, server)| same_instance(&server.url, url))
.collect();
return match matches.as_slice() {
[] => Ok(selection(
None,
&Server {
url: url.into(),
token: String::new(),
},
&remotes,
)),
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
_ => Err("multiple profiles use that URL; select one with --server".into()),
};
}
let mut matches = BTreeMap::new();
for (name, server) in &self.servers {
if let Some(scope) = remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote))
{
matches.insert(name, (server, scope));
}
}
match matches.into_iter().collect::<Vec<_>>().as_slice() {
[] if self.servers.is_empty() => {
Err("no servers configured; run `gotcha auth login SERVER`".into())
}
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
[(name, (server, scope))] => Ok(Selection {
name: Some((*name).clone()),
url: server.url.clone(),
token: Some(server.token.clone()),
repository: Some(scope.clone()),
}),
_ => Err("multiple server profiles match this Git repository; use --server".into()),
}
}
fn save(&self) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
let result = (|| -> std::io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
fs::rename(&temporary, &self.path)?;
#[cfg(unix)]
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
}
}
fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection {
Selection {
name: name.map(str::to_owned),
url: server.url.clone(),
token: (!server.token.is_empty()).then(|| server.token.clone()),
repository: remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote)),
}
}
fn validate_name(name: &str) -> Result<()> {
server_url(name)?;
Ok(())
}
pub fn server_url(name: &str) -> Result<String> {
if name.is_empty()
|| name.contains('/')
|| name.contains('@')
|| name.chars().any(char::is_whitespace)
{
return Err("server name must be a hostname, optionally followed by a port".into());
}
let url = format!("https://{name}");
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
if parsed.host_str().is_none() || parsed.path() != "/" {
return Err("server name must be a hostname, optionally followed by a port".into());
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("server name must be a hostname, optionally followed by a port".into());
}
Ok(url)
}
fn same_instance(left: &str, right: &str) -> bool {
let api_url = |url| {
Client::new(url, None)
.ok()
.map(|client| client.api_url().clone())
};
api_url(left) == api_url(right)
}
fn git_remotes() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
.output()
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(char::is_whitespace))
.map(|(_, url)| url.trim().to_owned())
.collect())
}
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryScope> {
let server = Url::parse(server_url).ok()?;
let server_host = server.host_str()?;
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
(
url.host_str()?.to_owned(),
url.path().trim_matches('/').to_owned(),
matches!(url.scheme(), "http" | "https"),
)
} else {
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
let (host, path) = remote.split_once(':')?;
(host.into(), path.trim_matches('/').into(), false)
};
if !server_host.eq_ignore_ascii_case(&remote_host) {
return None;
}
let prefix = server.path().trim_matches('/');
if is_http && !prefix.is_empty() {
remote_path = remote_path
.strip_prefix(prefix)?
.strip_prefix('/')?
.to_owned();
}
let parts: Vec<_> = remote_path
.split('/')
.filter(|part| !part.is_empty())
.collect();
let [.., owner, repository] = parts.as_slice() else {
return None;
};
let repository = repository.strip_suffix(".git").unwrap_or(repository);
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope {
owner: (*owner).into(),
repository: repository.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip_and_remote_scope() {
let directory = env::temp_dir().join(format!(
"gotcha-config-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = directory.join("config");
let mut config = Config {
path: path.clone(),
..Config::default()
};
assert_eq!(
config.select(None, None).err().unwrap(),
"no servers configured; run `gotcha auth login SERVER`"
);
config.login("code.example", "secret").unwrap();
let loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.servers["code.example"].token, "secret");
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
.contains("token: secret")
);
let scope = repository_scope(
"https://code.example/gitea",
"https://code.example/gitea/alice/project.git",
)
.unwrap();
assert_eq!(
(scope.owner.as_str(), scope.repository.as_str()),
("alice", "project")
);
#[cfg(unix)]
assert_eq!(
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(directory).unwrap();
}
}

396
crates/cli/src/main.rs Normal file
View File

@@ -0,0 +1,396 @@
mod config;
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 serde_json::Value;
const ROOT_HELP: &str = "\
Gotcha CLI
Usage:
gotcha [--server SERVER | --url URL] COMMAND
Commands:
auth Manage server authentication
server Inspect the selected Gitea 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
Run `gotcha COMMAND` to list that command's subcommands.
Without --server or --url, Gotcha selects a profile from the current Git
repository's remotes. SERVER is a hostname, optionally followed by a port.
GITEA_URL is the environment equivalent of --url.";
const AUTH_HELP: &str = "\
Usage: gotcha auth SUBCOMMAND
Subcommands:
login SERVER 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";
const SERVER_HELP: &str = "\
Usage: gotcha server SUBCOMMAND
Subcommands:
version Show the selected Gitea server version";
const USER_HELP: &str = "\
Usage: gotcha user SUBCOMMAND
Subcommands:
show Show the authenticated user";
const REPO_HELP: &str = "\
Usage: gotcha repo SUBCOMMAND
Subcommands:
list List repositories belonging to the authenticated user
show [OWNER/REPOSITORY]
Show a repository; defaults to the current Git repository";
const API_HELP: &str = "\
Usage: gotcha api SUBCOMMAND
Subcommands:
request METHOD ENDPOINT [JSON]
Send an API-relative request";
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("gotcha: {error}");
process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn Error>> {
let arguments: Vec<_> = env::args().skip(1).collect();
let args = Args::parse(arguments, env::var("GITEA_URL").ok())?;
if let Some(help) = requested_help(&args.command)? {
println!("{help}");
return Ok(());
}
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(());
}
[domain, command] if domain == "auth" && command == "list" => {
for (name, server) in &config.servers {
println!("{name}\t{}", server.url);
}
return Ok(());
}
[domain, command, name] if domain == "auth" && command == "logout" => {
config.logout(name)?;
println!("Removed server profile {name}.");
return Ok(());
}
_ => {}
}
let selection = config.select(args.server.as_deref(), args.url.as_deref())?;
if matches!(args.command.as_slice(), [domain, command] if domain == "auth" && command == "status")
{
print_status(&selection);
return Ok(());
}
let client = Client::new(&selection.url, selection.token.as_deref())?;
if work_items::run(&args.command, &selection, &client).await? {
return Ok(());
}
match args.command.as_slice() {
[domain, command] if domain == "server" && command == "version" => {
print_version(&client.version().await?);
}
[domain, command] if domain == "user" && command == "show" => {
print_user(&client.current_user().await?);
}
[domain, command] if domain == "repo" && command == "list" => {
print_repositories(&client.current_user_repositories().await?);
}
[domain, command] if domain == "repo" && command == "show" => {
let scope = selection
.repository
.as_ref()
.ok_or("cannot infer a repository; run inside one or pass OWNER/REPOSITORY")?;
print_repository(&client.repository(&scope.owner, &scope.repository).await?);
}
[domain, command, repository_name] if domain == "repo" && command == "show" => {
let scope = RepositoryScope::parse(repository_name)?;
print_repository(&client.repository(&scope.owner, &scope.repository).await?);
}
[domain, command, method, endpoint] if domain == "api" && command == "request" => {
print_json(&request(&client, method, endpoint, None).await?)?;
}
[domain, command, method, endpoint, body] if domain == "api" && command == "request" => {
print_json(
&request(&client, method, endpoint, Some(serde_json::from_str(body)?)).await?,
)?;
}
_ => return Err(invalid_subcommand(&args.command).into()),
}
Ok(())
}
fn print_version(version: &ServerVersion) {
println!("{}", version.version.as_deref().unwrap_or("unknown"));
}
fn print_user(user: &User) {
field("login", user.login.as_deref());
field("name", user.full_name.as_deref());
field("email", user.email.as_deref());
field("profile", user.html_url.as_deref());
field("visibility", user.visibility.as_deref());
}
fn print_repositories(repositories: &[Repository]) {
println!("REPOSITORY\tVISIBILITY\tUPDATED\tDESCRIPTION");
for repository in repositories {
println!(
"{}\t{}\t{}\t{}",
repository.full_name.as_deref().unwrap_or("unknown"),
if repository.private.unwrap_or(false) {
"private"
} else {
"public"
},
repository.updated_at.as_deref().unwrap_or("-"),
repository.description.as_deref().unwrap_or("")
);
}
}
fn print_repository(repository: &Repository) {
field("repository", repository.full_name.as_deref());
field("description", repository.description.as_deref());
field("url", repository.html_url.as_deref());
field("default branch", repository.default_branch.as_deref());
println!(
"visibility: {}",
if repository.private.unwrap_or(false) {
"private"
} else {
"public"
}
);
println!("stars: {}", repository.stars_count.unwrap_or_default());
println!(
"open issues: {}",
repository.open_issues_count.unwrap_or_default()
);
}
fn field(name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("{name}: {value}");
}
}
fn print_json(value: &Value) -> Result<(), serde_json::Error> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn requested_help(command: &[String]) -> Result<Option<&'static str>, String> {
match command {
[] => Ok(Some(ROOT_HELP)),
[help] if matches!(help.as_str(), "help" | "-h" | "--help") => Ok(Some(ROOT_HELP)),
[domain] => domain_help(domain)
.map(Some)
.ok_or_else(|| format!("unknown command {domain:?}\n\n{ROOT_HELP}")),
[domain, help] if matches!(help.as_str(), "-h" | "--help") => domain_help(domain)
.map(Some)
.ok_or_else(|| format!("unknown command {domain:?}\n\n{ROOT_HELP}")),
[domain, subcommand, help] if matches!(help.as_str(), "-h" | "--help") => {
subcommand_help(domain, subcommand)
.map(Some)
.ok_or_else(|| {
format!(
"unknown subcommand {domain} {subcommand}\n\n{}",
domain_help(domain).unwrap_or(ROOT_HELP)
)
})
}
[domain, ..] if domain_help(domain).is_none() => {
Err(format!("unknown command {domain:?}\n\n{ROOT_HELP}"))
}
_ => Ok(None),
}
}
fn domain_help(domain: &str) -> Option<&'static str> {
match domain {
"auth" => Some(AUTH_HELP),
"server" => Some(SERVER_HELP),
"user" => Some(USER_HELP),
"repo" => Some(REPO_HELP),
"api" => Some(API_HELP),
_ => work_items::domain_help(domain),
}
}
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.",
),
("auth", "list") => {
Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
}
("auth", "status") => Some(
"Usage: gotcha [--server SERVER] auth status\n\nShows the selected server and inferred repository scope.",
),
("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."),
("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.",
),
("repo", "show") => Some(
"Usage: gotcha repo show [OWNER/REPOSITORY]\n\nShows a repository, inferred from Git when omitted.",
),
("api", "request") => Some(
"Usage: gotcha api request METHOD ENDPOINT [JSON]\n\nSends an API-relative request using the selected server and token.",
),
_ => work_items::subcommand_help(domain, subcommand),
}
}
fn invalid_subcommand(command: &[String]) -> String {
let domain = command.first().map(String::as_str).unwrap_or_default();
format!(
"unknown or invalid {domain} subcommand\n\n{}",
domain_help(domain).unwrap_or(ROOT_HELP)
)
}
fn print_status(selection: &Selection) {
println!(
"server: {} ({})",
selection.name.as_deref().unwrap_or("custom"),
selection.url
);
println!(
"authenticated: {}",
if selection.token.is_some() {
"yes"
} else {
"no"
}
);
if let Some(scope) = &selection.repository {
println!("repository: {}/{}", scope.owner, scope.repository);
}
}
async fn request(
client: &Client,
method: &str,
endpoint: &str,
body: Option<Value>,
) -> Result<Value, Box<dyn Error>> {
let mut request = client.request(method.parse::<Method>()?, endpoint)?;
if let Some(body) = body {
request = request.json(&body);
}
let response = client.execute(request).await?;
if response.status() == 204 {
return Ok(Value::Null);
}
Ok(response.json().await?)
}
#[derive(Debug)]
struct Args {
url: Option<String>,
server: Option<String>,
command: Vec<String>,
}
impl Args {
fn parse(
args: impl IntoIterator<Item = String>,
environment_url: Option<String>,
) -> Result<Self, String> {
let mut args = args.into_iter();
let mut url = environment_url;
let mut server = None;
let mut command = Vec::new();
while let Some(argument) = args.next() {
match argument.as_str() {
"--url" if command.is_empty() => {
url = Some(args.next().ok_or("--url requires an instance URL")?);
}
"--server" if command.is_empty() => {
server = Some(args.next().ok_or("--server requires a profile name")?);
}
_ => command.push(argument),
}
}
Ok(Self {
url,
server,
command,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_server_selection_and_repository_scope() {
let args = Args::parse(
["--server", "work", "repo", "show"].map(str::to_owned),
None,
)
.unwrap();
let repository = RepositoryScope::parse("alice/project").unwrap();
assert_eq!(args.server.as_deref(), Some("work"));
assert_eq!(args.command, ["repo", "show"]);
assert_eq!(
(repository.owner.as_str(), repository.repository.as_str()),
("alice", "project")
);
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
assert!(
requested_help(&["repo".into(), "show".into(), "--help".into()])
.unwrap()
.unwrap()
.starts_with("Usage: gotcha repo show")
);
}
}

View File

@@ -0,0 +1,853 @@
use std::{error::Error, io::Read};
use gotcha_gitea::{
Client, apis,
models::{
ChangedFile, Comment, Commit, CreateIssueCommentOption, CreateIssueOption,
CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption,
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
},
};
use serde::de::DeserializeOwned;
use crate::config::{RepositoryScope, Selection};
const ISSUE_HELP: &str = "\
Usage: gotcha issue SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List issues
show INDEX [OWNER/REPOSITORY] Show an issue
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin
edit INDEX [OWNER/REPOSITORY] Edit from EditIssueOption YAML on stdin
delete INDEX [OWNER/REPOSITORY] Delete an issue
comments INDEX [OWNER/REPOSITORY]
List comments
comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";
const MILESTONE_HELP: &str = "\
Usage: gotcha milestone SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List milestones
show ID [OWNER/REPOSITORY] Show a milestone
create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin
edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin
delete ID [OWNER/REPOSITORY] Delete a milestone";
const PULL_HELP: &str = "\
Usage: gotcha pull SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List pull requests
show INDEX [OWNER/REPOSITORY] Show a pull request
create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin
edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin
merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin
commits INDEX [OWNER/REPOSITORY] List commits
files INDEX [OWNER/REPOSITORY] List changed files
reviews INDEX [OWNER/REPOSITORY] List reviews";
pub fn domain_help(domain: &str) -> Option<&'static str> {
match domain {
"issue" => Some(ISSUE_HELP),
"milestone" => Some(MILESTONE_HELP),
"pull" => Some(PULL_HELP),
_ => None,
}
}
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
match (domain, command) {
("issue", "list") => Some("Usage: gotcha issue list [OWNER/REPOSITORY]"),
("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
("issue", "create") => Some(
"Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin, for example:\n title: Fix the bug\n body: Reproduction steps",
),
("issue", "edit") => Some(
"Usage: gotcha issue edit INDEX [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin.",
),
("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
("issue", "comment") => Some(
"Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
),
("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
("milestone", "create") => Some(
"Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z",
),
("milestone", "edit") => Some(
"Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
),
("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
("pull", "create") => Some(
"Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main",
),
("pull", "edit") => Some(
"Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
),
("pull", "merge") => Some(
"Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true",
),
("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
_ => None,
}
}
pub async fn run(
command: &[String],
selection: &Selection,
client: &Client,
) -> Result<bool, Box<dyn Error>> {
let configuration = client.configuration();
match command {
[domain, action] if domain == "issue" && action == "list" => {
let scope = scope(selection, None)?;
let issues = apis::issue_api::issue_list_issues(
&configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
Some("issues"),
None,
None,
None,
Some("issues"),
None,
None,
None,
None,
)
.await?;
print_issues(&issues);
}
[domain, action, repository] if domain == "issue" && action == "list" => {
let scope = scope(selection, Some(repository))?;
let issues = apis::issue_api::issue_list_issues(
&configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await?;
print_issues(&issues);
}
[domain, action, index] if domain == "issue" && action == "show" => {
let scope = scope(selection, None)?;
print_issue(
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
}
[domain, action, index, repository] if domain == "issue" && action == "show" => {
let scope = scope(selection, Some(repository))?;
print_issue(
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
}
[domain, action] if domain == "issue" && action == "create" => {
create_issue(&configuration, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "issue" && action == "create" => {
create_issue(&configuration, &scope(selection, Some(repository))?).await?;
}
[domain, action, index] if domain == "issue" && action == "edit" => {
edit_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "edit" => {
edit_issue(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action, index] if domain == "issue" && action == "delete" => {
delete_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "delete" => {
delete_issue(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action, index] if domain == "issue" && action == "comments" => {
list_comments(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "comments" => {
list_comments(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action, index] if domain == "issue" && action == "comment" => {
create_comment(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "comment" => {
create_comment(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action] if domain == "milestone" && action == "list" => {
list_milestones(&configuration, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "milestone" && action == "list" => {
list_milestones(&configuration, &scope(selection, Some(repository))?).await?;
}
[domain, action, id] if domain == "milestone" && action == "show" => {
show_milestone(&configuration, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "show" => {
show_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
}
[domain, action] if domain == "milestone" && action == "create" => {
create_milestone(&configuration, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "milestone" && action == "create" => {
create_milestone(&configuration, &scope(selection, Some(repository))?).await?;
}
[domain, action, id] if domain == "milestone" && action == "edit" => {
edit_milestone(&configuration, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "edit" => {
edit_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
}
[domain, action, id] if domain == "milestone" && action == "delete" => {
delete_milestone(&configuration, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "delete" => {
delete_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
}
[domain, action] if domain == "pull" && action == "list" => {
list_pulls(&configuration, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "pull" && action == "list" => {
list_pulls(&configuration, &scope(selection, Some(repository))?).await?;
}
[domain, action, index] if domain == "pull" && action == "show" => {
show_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "show" => {
show_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action] if domain == "pull" && action == "create" => {
create_pull(&configuration, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "pull" && action == "create" => {
create_pull(&configuration, &scope(selection, Some(repository))?).await?;
}
[domain, action, index] if domain == "pull" && action == "edit" => {
edit_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "edit" => {
edit_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action, index] if domain == "pull" && action == "merge" => {
merge_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "merge" => {
merge_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
}
[domain, action, index]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{
pull_details(
&configuration,
&scope(selection, None)?,
number(index)?,
action,
)
.await?;
}
[domain, action, index, repository]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{
pull_details(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
action,
)
.await?;
}
[domain, ..] if matches!(domain.as_str(), "issue" | "milestone" | "pull") => {
return Ok(false);
}
_ => return Ok(false),
}
Ok(true)
}
fn scope(selection: &Selection, value: Option<&str>) -> Result<RepositoryScope, Box<dyn Error>> {
match value {
Some(value) => Ok(RepositoryScope::parse(value)?),
None => selection.repository.clone().ok_or_else(|| {
"cannot infer a repository; run inside one or pass OWNER/REPOSITORY".into()
}),
}
}
fn number(value: &str) -> Result<i64, Box<dyn Error>> {
let number: i64 = value.parse()?;
if number < 1 {
return Err("index must be a positive integer".into());
}
Ok(number)
}
fn read_stdin() -> Result<String, Box<dyn Error>> {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
if input.trim().is_empty() {
return Err("stdin must not be empty".into());
}
Ok(input)
}
fn read_yaml<T: DeserializeOwned>() -> Result<T, Box<dyn Error>> {
parse_yaml(&read_stdin()?)
}
fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
Ok(serde_yaml::from_str(input)?)
}
async fn create_issue(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let body: CreateIssueOption = read_yaml()?;
if body.title.trim().is_empty() {
return Err("issue title must not be empty".into());
}
print_issue(
&apis::issue_api::issue_create_issue(
configuration,
&scope.owner,
&scope.repository,
Some(body),
)
.await?,
);
Ok(())
}
async fn edit_issue(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let issue = apis::issue_api::issue_edit_issue(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<EditIssueOption>()?),
)
.await?;
print_issue(&issue);
Ok(())
}
async fn delete_issue(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
apis::issue_api::issue_delete(configuration, &scope.owner, &scope.repository, index).await?;
println!("Deleted issue #{index}.");
Ok(())
}
async fn list_comments(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let comments = apis::issue_api::issue_get_comments(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
)
.await?;
print_comments(&comments);
Ok(())
}
async fn create_comment(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let body = read_stdin()?.trim_end().to_owned();
let comment = apis::issue_api::issue_create_comment(
configuration,
&scope.owner,
&scope.repository,
index,
Some(CreateIssueCommentOption::new(body)),
)
.await?;
print_comments(&[comment]);
Ok(())
}
async fn list_milestones(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let milestones = apis::issue_api::issue_get_milestones_list(
configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
None,
)
.await?;
print_milestones(&milestones);
Ok(())
}
async fn show_milestone(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
let milestone =
apis::issue_api::issue_get_milestone(configuration, &scope.owner, &scope.repository, id)
.await?;
print_milestone(&milestone);
Ok(())
}
async fn create_milestone(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let body: CreateMilestoneOption = read_yaml()?;
if body.title.as_deref().unwrap_or_default().trim().is_empty() {
return Err("milestone title must not be empty".into());
}
let milestone = apis::issue_api::issue_create_milestone(
configuration,
&scope.owner,
&scope.repository,
Some(body),
)
.await?;
print_milestone(&milestone);
Ok(())
}
async fn edit_milestone(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
let milestone = apis::issue_api::issue_edit_milestone(
configuration,
&scope.owner,
&scope.repository,
id,
Some(read_yaml::<EditMilestoneOption>()?),
)
.await?;
print_milestone(&milestone);
Ok(())
}
async fn delete_milestone(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
apis::issue_api::issue_delete_milestone(configuration, &scope.owner, &scope.repository, id)
.await?;
println!("Deleted milestone {id}.");
Ok(())
}
async fn list_pulls(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let pulls = apis::repository_api::repo_list_pull_requests(
configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await?;
print_pulls(&pulls);
Ok(())
}
async fn show_pull(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_get_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
)
.await?;
print_pull(&pull);
Ok(())
}
async fn create_pull(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let body: CreatePullRequestOption = read_yaml()?;
if [
body.title.as_deref(),
body.head.as_deref(),
body.base.as_deref(),
]
.into_iter()
.any(|value| value.unwrap_or_default().trim().is_empty())
{
return Err("pull request title, head, and base must not be empty".into());
}
let pull = apis::repository_api::repo_create_pull_request(
configuration,
&scope.owner,
&scope.repository,
Some(body),
)
.await?;
print_pull(&pull);
Ok(())
}
async fn edit_pull(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_edit_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<EditPullRequestOption>()?),
)
.await?;
print_pull(&pull);
Ok(())
}
async fn merge_pull(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
apis::repository_api::repo_merge_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<MergePullRequestOption>()?),
)
.await?;
println!("Merged pull request #{index}.");
Ok(())
}
async fn pull_details(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
action: &str,
) -> Result<(), Box<dyn Error>> {
match action {
"commits" => print_commits(
&apis::repository_api::repo_get_pull_request_commits(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
None,
None,
)
.await?,
),
"files" => print_files(
&apis::repository_api::repo_get_pull_request_files(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
None,
None,
)
.await?,
),
"reviews" => print_reviews(
&apis::repository_api::repo_list_pull_reviews(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
)
.await?,
),
_ => unreachable!(),
}
Ok(())
}
fn print_issues(issues: &[Issue]) {
println!("INDEX\tSTATE\tTITLE\tUPDATED");
for issue in issues {
println!(
"{}\t{}\t{}\t{}",
issue.number.unwrap_or_default(),
issue.state.as_deref().unwrap_or("unknown"),
one_line(issue.title.as_deref().unwrap_or("")),
issue.updated_at.as_deref().unwrap_or("-")
);
}
}
fn print_issue(issue: &Issue) {
println!(
"#{} [{}] {}",
issue.number.unwrap_or_default(),
issue.state.as_deref().unwrap_or("unknown"),
issue.title.as_deref().unwrap_or("")
);
field("url", issue.html_url.as_deref());
field(
"author",
issue.user.as_deref().and_then(|user| user.login.as_deref()),
);
field("updated", issue.updated_at.as_deref());
body(issue.body.as_deref());
}
fn print_comments(comments: &[Comment]) {
for comment in comments {
println!(
"{} · {}",
comment
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown"),
comment.created_at.as_deref().unwrap_or("-")
);
body(comment.body.as_deref());
}
}
fn print_milestones(milestones: &[Milestone]) {
println!("ID\tSTATE\tTITLE\tDUE\tOPEN/CLOSED");
for milestone in milestones {
println!(
"{}\t{}\t{}\t{}\t{}/{}",
milestone.id.unwrap_or_default(),
milestone.state.as_deref().unwrap_or("unknown"),
one_line(milestone.title.as_deref().unwrap_or("")),
milestone.due_on.as_deref().unwrap_or("-"),
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
);
}
}
fn print_milestone(milestone: &Milestone) {
println!(
"{} [{}] {}",
milestone.id.unwrap_or_default(),
milestone.state.as_deref().unwrap_or("unknown"),
milestone.title.as_deref().unwrap_or("")
);
field("due", milestone.due_on.as_deref());
println!(
"issues: {} open, {} closed",
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
);
body(milestone.description.as_deref());
}
fn print_pulls(pulls: &[PullRequest]) {
println!("INDEX\tSTATE\tTITLE\tUPDATED");
for pull in pulls {
println!(
"{}\t{}\t{}\t{}",
pull.number.unwrap_or_default(),
pull.state.as_deref().unwrap_or("unknown"),
one_line(pull.title.as_deref().unwrap_or("")),
pull.updated_at.as_deref().unwrap_or("-")
);
}
}
fn print_pull(pull: &PullRequest) {
println!(
"#{} [{}] {}",
pull.number.unwrap_or_default(),
pull.state.as_deref().unwrap_or("unknown"),
pull.title.as_deref().unwrap_or("")
);
field("url", pull.html_url.as_deref());
field(
"author",
pull.user.as_deref().and_then(|user| user.login.as_deref()),
);
field("updated", pull.updated_at.as_deref());
println!("mergeable: {}", pull.mergeable.unwrap_or(false));
body(pull.body.as_deref());
}
fn print_commits(commits: &[Commit]) {
println!("SHA\tMESSAGE");
for commit in commits {
println!(
"{}\t{}",
commit.sha.as_deref().unwrap_or("unknown"),
one_line(
commit
.commit
.as_deref()
.and_then(|commit| commit.message.as_deref())
.unwrap_or("")
)
);
}
}
fn print_files(files: &[ChangedFile]) {
println!("STATUS\tCHANGES\tFILE");
for file in files {
println!(
"{}\t{}\t{}",
file.status.as_deref().unwrap_or("unknown"),
file.changes.unwrap_or_default(),
file.filename.as_deref().unwrap_or("")
);
}
}
fn print_reviews(reviews: &[PullReview]) {
println!("ID\tSTATE\tREVIEWER\tSUBMITTED");
for review in reviews {
println!(
"{}\t{}\t{}\t{}",
review.id.unwrap_or_default(),
review.state.as_deref().unwrap_or("unknown"),
review
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown"),
review.submitted_at.as_deref().unwrap_or("-")
);
}
}
fn field(name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("{name}: {value}");
}
}
fn body(value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("\n{value}");
}
}
fn one_line(value: &str) -> &str {
value.lines().next().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_typed_yaml_and_rejects_bad_indexes() {
let issue: CreateIssueOption = parse_yaml("title: Fix it\nbody: Details\n").unwrap();
assert_eq!(issue.title, "Fix it");
assert_eq!(issue.body.as_deref(), Some("Details"));
assert!(number("0").is_err());
assert!(number("7").is_ok());
}
}

13
crates/gitea/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "gotcha_gitea"
version = "0.1.0"
description = "A small, reusable Rust client for the Gitea API"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-features = false, features = ["rustls"] }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true

207
crates/gitea/src/lib.rs Normal file
View File

@@ -0,0 +1,207 @@
use std::{error, fmt};
use reqwest::{
Client as HttpClient,
header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue},
};
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
use serde::Deserialize;
pub use gitea_openapi::{apis, models};
pub use models::{Repository, ServerVersion, User};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Configuration(String),
Transport(reqwest::Error),
Generated(String),
Api { status: StatusCode, message: String },
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Configuration(message) => formatter.write_str(message),
Self::Transport(error) => error.fmt(formatter),
Self::Generated(message) => formatter.write_str(message),
Self::Api { status, message } => {
write!(formatter, "Gitea returned {status}: {message}")
}
}
}
}
impl error::Error for Error {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
Self::Transport(error) => Some(error),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::Transport(error)
}
}
#[derive(Clone, Debug)]
pub struct Client {
api_url: Url,
http: HttpClient,
}
impl Client {
pub fn new(instance_url: &str, token: Option<&str>) -> Result<Self> {
let mut instance_url = Url::parse(instance_url)
.map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?;
if !matches!(instance_url.scheme(), "http" | "https") {
return Err(Error::Configuration(
"Gitea 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(),
));
}
if !instance_url.path().ends_with('/') {
instance_url.set_path(&format!("{}/", instance_url.path()));
}
let api_url = instance_url
.join("api/v1/")
.map_err(|error| Error::Configuration(format!("invalid Gitea 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()))?;
headers.insert(AUTHORIZATION, value);
}
Ok(Self {
api_url,
http: HttpClient::builder().default_headers(headers).build()?,
})
}
pub fn api_url(&self) -> &Url {
&self.api_url
}
/// Returns an authenticated configuration for every generated typed API.
pub fn configuration(&self) -> apis::configuration::Configuration {
apis::configuration::Configuration {
base_path: self.api_url.as_str().trim_end_matches('/').into(),
client: self.http.clone(),
user_agent: Some(format!("gotcha_gitea/{}", env!("CARGO_PKG_VERSION"))),
..Default::default()
}
}
/// Builds an authenticated request to an API-relative endpoint.
///
/// The returned reqwest builder supports JSON, forms, multipart uploads,
/// query parameters, and byte streams without duplicating those APIs here.
pub fn request(&self, method: Method, endpoint: &str) -> Result<RequestBuilder> {
if endpoint.starts_with('/')
|| Url::parse(endpoint).is_ok()
|| endpoint.split('/').any(|segment| segment == "..")
{
return Err(Error::Configuration(
"endpoint must be a relative path below /api/v1".into(),
));
}
let url = self
.api_url
.join(endpoint)
.map_err(|error| Error::Configuration(format!("invalid endpoint: {error}")))?;
Ok(self.http.request(method, url))
}
pub async fn execute(&self, request: RequestBuilder) -> Result<Response> {
let response = request.send().await?;
let status = response.status();
if status.is_success() {
return Ok(response);
}
let body = response.text().await?;
let message = serde_json::from_str::<ApiError>(&body)
.ok()
.map(|error| error.message)
.filter(|message| !message.is_empty())
.unwrap_or(body);
Err(Error::Api { status, message })
}
pub async fn version(&self) -> Result<models::ServerVersion> {
apis::miscellaneous_api::get_version(&self.configuration())
.await
.map_err(|error| Error::Generated(error.to_string()))
}
pub async fn current_user(&self) -> Result<models::User> {
apis::user_api::user_get_current(&self.configuration())
.await
.map_err(|error| Error::Generated(error.to_string()))
}
pub async fn current_user_repositories(&self) -> Result<Vec<models::Repository>> {
apis::user_api::user_current_list_repos(&self.configuration(), None, None)
.await
.map_err(|error| Error::Generated(error.to_string()))
}
pub async fn repository(&self, owner: &str, repository: &str) -> Result<models::Repository> {
if owner.is_empty() || repository.is_empty() {
return Err(Error::Configuration(
"repository owner and name must not be empty".into(),
));
}
apis::repository_api::repo_get(&self.configuration(), owner, repository)
.await
.map_err(|error| Error::Generated(error.to_string()))
}
}
#[derive(Debug, Deserialize)]
struct ApiError {
#[serde(default)]
message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_instance_subpaths_and_rejects_endpoint_escapes() {
let client = Client::new("https://example.com/gitea", Some("secret")).unwrap();
assert_eq!(
client.api_url().as_str(),
"https://example.com/gitea/api/v1/"
);
assert!(client.request(Method::GET, "repos/octo/demo").is_ok());
assert!(client.request(Method::GET, "../admin").is_err());
assert!(
client
.request(Method::GET, "https://example.net/user")
.is_err()
);
assert_eq!(
client.configuration().base_path,
"https://example.com/gitea/api/v1"
);
let _typed_query = apis::issue_api::issue_list_issues;
let _typed_model = models::Issue::default();
}
}