489 lines
16 KiB
Rust
489 lines
16 KiB
Rust
mod config;
|
|
mod work_items;
|
|
|
|
use std::{env, error::Error, process};
|
|
|
|
use config::{Config, RepositoryScope, Selection, server_url};
|
|
use gotcha_gitea::{Client, Method, Provider, 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 or Forgejo server
|
|
user Work with the authenticated user
|
|
repo Work with repositories
|
|
issue Work with issues and comments
|
|
milestone Work with repository milestones
|
|
pull Work with pull requests
|
|
api Send a raw server 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 [--provider gitea|forgejo]
|
|
Verify and store a token read from standard input
|
|
list List configured servers without showing tokens
|
|
status Show the selected server and repository scope
|
|
logout SERVER Remove a stored server and token";
|
|
|
|
const SERVER_HELP: &str = "\
|
|
Usage: gotcha server SUBCOMMAND
|
|
|
|
Subcommands:
|
|
version Show the selected 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()?;
|
|
if let Some((name, fallback)) = login_command(&args.command)? {
|
|
let url = server_url(name)?;
|
|
let token = rpassword::prompt_password("Token: ")?;
|
|
if token.is_empty() {
|
|
return Err("token must not be empty".into());
|
|
}
|
|
let client = Client::discover(&url, Some(&token), fallback).await?;
|
|
let user = client.current_user().await?;
|
|
config.login(name, &token, client.provider())?;
|
|
println!(
|
|
"Logged in to {url} as {} ({name}, {}).",
|
|
user.login.as_deref().unwrap_or("unknown"),
|
|
client.provider()
|
|
);
|
|
return Ok(());
|
|
}
|
|
match args.command.as_slice() {
|
|
[domain, command] if domain == "auth" && command == "list" => {
|
|
for (name, server) in &config.servers {
|
|
println!("{name}\t{}\t{}", server.provider, 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::with_provider(
|
|
&selection.url,
|
|
selection.token.as_deref(),
|
|
selection.provider,
|
|
)?;
|
|
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 login_command(command: &[String]) -> Result<Option<(&str, Provider)>, Box<dyn Error>> {
|
|
match command {
|
|
[domain, action, name] if domain == "auth" && action == "login" => {
|
|
Ok(Some((name, Provider::Gitea)))
|
|
}
|
|
[domain, action, name, option, provider]
|
|
if domain == "auth" && action == "login" && option == "--provider" =>
|
|
{
|
|
Ok(Some((name, provider.parse()?)))
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn print_user(user: &User) {
|
|
field("login", user.login.as_deref());
|
|
field("name", user.full_name.as_deref());
|
|
field("email", user.email.as_deref());
|
|
field("profile", user.html_url.as_deref());
|
|
field("visibility", user.visibility.as_deref());
|
|
}
|
|
|
|
fn print_repositories(repositories: &[Repository]) {
|
|
print_table(
|
|
&[
|
|
("REPOSITORY", 40),
|
|
("VISIBILITY", 10),
|
|
("UPDATED", 25),
|
|
("DESCRIPTION", 60),
|
|
],
|
|
repositories
|
|
.iter()
|
|
.map(|repository| {
|
|
vec![
|
|
repository.full_name.as_deref().unwrap_or("unknown").into(),
|
|
if repository.private.unwrap_or(false) {
|
|
"private"
|
|
} else {
|
|
"public"
|
|
}
|
|
.into(),
|
|
repository.updated_at.as_deref().unwrap_or("-").into(),
|
|
repository.description.as_deref().unwrap_or("").into(),
|
|
]
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
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 print_table(columns: &[(&str, usize)], rows: Vec<Vec<String>>) {
|
|
println!("{}", format_table(columns, &rows, terminal_width()));
|
|
}
|
|
|
|
fn format_table(columns: &[(&str, usize)], rows: &[Vec<String>], width: usize) -> String {
|
|
let minimums = columns
|
|
.iter()
|
|
.map(|(header, _)| header.chars().count())
|
|
.collect::<Vec<_>>();
|
|
let mut widths = minimums.clone();
|
|
for (column, (_, maximum)) in columns.iter().enumerate() {
|
|
widths[column] = rows
|
|
.iter()
|
|
.filter_map(|row| row.get(column))
|
|
.map(|value| value.chars().count())
|
|
.max()
|
|
.unwrap_or_default()
|
|
.max(widths[column])
|
|
.min(*maximum);
|
|
}
|
|
let separator_width = columns.len().saturating_sub(1) * 2;
|
|
while widths.iter().sum::<usize>() + separator_width > width {
|
|
let Some(column) = widths
|
|
.iter()
|
|
.zip(&minimums)
|
|
.enumerate()
|
|
.filter(|(_, (current, minimum))| current > minimum)
|
|
.max_by_key(|(_, (current, minimum))| *current - *minimum)
|
|
.map(|(column, _)| column)
|
|
else {
|
|
break;
|
|
};
|
|
widths[column] -= 1;
|
|
}
|
|
|
|
let mut lines = Vec::with_capacity(rows.len() + 2);
|
|
lines.push(table_row(
|
|
&columns
|
|
.iter()
|
|
.map(|(header, _)| (*header).to_owned())
|
|
.collect::<Vec<_>>(),
|
|
&widths,
|
|
));
|
|
lines.push(
|
|
widths
|
|
.iter()
|
|
.map(|width| "-".repeat(*width))
|
|
.collect::<Vec<_>>()
|
|
.join(" "),
|
|
);
|
|
lines.extend(rows.iter().map(|row| table_row(row, &widths)));
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn table_row(values: &[String], widths: &[usize]) -> String {
|
|
values
|
|
.iter()
|
|
.zip(widths)
|
|
.map(|(value, width)| table_cell(value, *width))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.trim_end()
|
|
.to_owned()
|
|
}
|
|
|
|
fn table_cell(value: &str, width: usize) -> String {
|
|
let value = value.lines().next().unwrap_or_default();
|
|
let length = value.chars().count();
|
|
if length <= width {
|
|
return format!("{value}{}", " ".repeat(width - length));
|
|
}
|
|
let mut shortened = value
|
|
.chars()
|
|
.take(width.saturating_sub(1))
|
|
.collect::<String>();
|
|
shortened.push('…');
|
|
shortened
|
|
}
|
|
|
|
fn terminal_width() -> usize {
|
|
env::var("COLUMNS")
|
|
.ok()
|
|
.and_then(|value| value.parse().ok())
|
|
.filter(|width| *width >= 40)
|
|
.unwrap_or(100)
|
|
}
|
|
|
|
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 [--provider gitea|forgejo]\n\nPrompts for a token, discovers and verifies the provider, and stores the server in the YAML config.",
|
|
),
|
|
("auth", "list") => {
|
|
Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
|
|
}
|
|
("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 server version."),
|
|
("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."),
|
|
("repo", "list") => Some(
|
|
"Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.",
|
|
),
|
|
("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"
|
|
}
|
|
);
|
|
println!("provider: {}", selection.provider);
|
|
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;
|