initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
|
||||||
41
API_COVERAGE.md
Normal file
41
API_COVERAGE.md
Normal 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
1736
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
22
LICENSE
Normal 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
61
README.md
Normal 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
19
crates/cli/Cargo.toml
Normal 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
348
crates/cli/src/config.rs
Normal 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
396
crates/cli/src/main.rs
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
853
crates/cli/src/work_items.rs
Normal file
853
crates/cli/src/work_items.rs
Normal 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
13
crates/gitea/Cargo.toml
Normal 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
207
crates/gitea/src/lib.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user