initial commit
This commit is contained in:
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user