12.6 KiB · text History 1294137
//! The command surface.
//!
//! The vocabulary follows the Android client's tabs — repos, CI, issues,
//! pulls, wiki, packages — because the two speak the same API and there's
//! no reason for the same concept to be called two things.

use clap::{Args, Parser, Subcommand, ValueEnum};

#[derive(Debug, Parser)]
#[command(
    name = "gg",
    version,
    about = "Command-line client for a GitGud forge",
    long_about = "Talks to the same /api/mobile/v1 surface the Android app uses.\n\
                  Sign in once with `gg login`, then work from any repository \
                  checkout — the repo is taken from your git remote unless you pass --repo.",
    propagate_version = true
)]
pub struct Cli {
    /// Forge base URL (default: the one saved by `gg login`).
    #[arg(long, global = true, value_name = "URL", env = crate::config::ENV_URL)]
    pub url: Option<String>,

    /// API token (default: the one saved by `gg login`).
    #[arg(long, global = true, value_name = "TOKEN", env = crate::config::ENV_TOKEN, hide_env_values = true)]
    pub token: Option<String>,

    /// Repository as owner/name. Inferred from the git remote if omitted.
    #[arg(short = 'R', long, global = true, value_name = "OWNER/NAME", env = crate::config::ENV_REPO)]
    pub repo: Option<String>,

    /// Print the server's JSON instead of a table.
    #[arg(long, global = true)]
    pub json: bool,

    #[command(subcommand)]
    pub command: Command,
}

#[derive(Debug, Subcommand)]
pub enum Command {
    /// Save credentials for a forge and verify them.
    Login(LoginArgs),

    /// Forget the saved credentials.
    Logout,

    /// Show who the saved token belongs to, and what it may do.
    Whoami,

    /// List the repositories this token can reach.
    #[command(name = "repos")]
    RepoList(RepoListArgs),

    /// Show one repository: counts, permissions, latest run.
    Repo(RepoArgs),

    /// Workflow runs, jobs and logs.
    Ci(CiArgs),

    /// Issues.
    #[command(visible_alias = "issues")]
    Issue(IssueArgs),

    /// Pull requests.
    #[command(visible_aliases = ["pulls", "pr"])]
    Pull(PullArgs),

    /// Wiki pages.
    Wiki(WikiArgs),

    /// Published packages and how to consume them.
    #[command(visible_alias = "packages")]
    Package(PackageArgs),

    /// Interactive CI dashboard.
    #[command(visible_aliases = ["tui", "ui"])]
    Dashboard(DashboardArgs),
}

// ── auth ─────────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct LoginArgs {
    /// A `gitgud://pair?base=…&token=…` URI — the same one the pairing QR
    /// encodes. Pass it instead of --url/--token.
    #[arg(value_name = "PAIRING_URI")]
    pub pairing_uri: Option<String>,

    /// Read the token from stdin rather than an argument, so it stays out
    /// of the shell history and the process table.
    #[arg(long, conflicts_with = "pairing_uri")]
    pub token_stdin: bool,

    /// Remember this repository when no git remote names one.
    #[arg(long, value_name = "OWNER/NAME")]
    pub default_repo: Option<String>,
}

// ── repositories ─────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct RepoListArgs {
    /// Order of the listing.
    #[arg(long, value_enum, default_value_t = RepoSort::Pushed)]
    pub sort: RepoSort,

    /// Only repositories whose name or description matches.
    #[arg(long, value_name = "TEXT")]
    pub search: Option<String>,

    /// Include archived repositories.
    #[arg(long)]
    pub archived: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum RepoSort {
    /// Most recently pushed first.
    Pushed,
    /// Alphabetical.
    Name,
    /// Failures first, then running, then queued.
    Ci,
}

#[derive(Debug, Args)]
pub struct RepoArgs {
    /// Repository as owner/name (default: --repo, or the git remote).
    #[arg(value_name = "OWNER/NAME")]
    pub target: Option<String>,
}

// ── CI ───────────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct CiArgs {
    #[command(subcommand)]
    pub command: CiCommand,
}

#[derive(Debug, Subcommand)]
pub enum CiCommand {
    /// Recent workflow runs.
    #[command(visible_alias = "runs")]
    List {
        /// How many runs to fetch (server caps this at 200).
        #[arg(long, default_value_t = 20)]
        limit: u32,
    },

    /// One run, with its jobs, steps and artifacts.
    #[command(visible_alias = "run")]
    Show {
        /// Run number. Defaults to the most recent run.
        number: Option<i64>,

        /// Also show each job's steps.
        #[arg(long)]
        steps: bool,
    },

    /// A job's logs, grouped by step.
    Logs {
        /// Run number. Defaults to the most recent run.
        number: Option<i64>,

        /// Which job, by name or workflow id. Defaults to the only job, or
        /// to the failed one when there's exactly one.
        #[arg(long, value_name = "JOB")]
        job: Option<String>,

        /// Maximum log lines (server caps this at 10000).
        #[arg(long)]
        limit: Option<u32>,

        /// Drop the step headers and print the log body alone, for piping.
        #[arg(long)]
        raw: bool,
    },

    /// Re-run a finished run, or one job within it.
    Rerun {
        /// Run number.
        number: i64,

        /// Re-run only this job, by its workflow id.
        #[arg(long, value_name = "JOB")]
        job: Option<String>,
    },

    /// Cancel a run that's queued or in flight.
    Cancel {
        /// Run number.
        number: i64,
    },

    /// Follow a run until it finishes.
    Watch {
        /// Run number. Defaults to the most recent run.
        number: Option<i64>,

        /// Seconds between polls.
        #[arg(long, default_value_t = 5)]
        interval: u64,
    },
}

// ── issues ───────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct IssueArgs {
    #[command(subcommand)]
    pub command: IssueCommand,
}

#[derive(Debug, Subcommand)]
pub enum IssueCommand {
    /// List issues.
    #[command(visible_alias = "ls")]
    List {
        #[arg(long, value_enum, default_value_t = IssueState::Open)]
        state: IssueState,

        /// Full-text search over title and body.
        #[arg(long, value_name = "TEXT")]
        search: Option<String>,

        #[arg(long)]
        limit: Option<u32>,
    },

    /// Show one issue and its comments.
    Show {
        number: i64,

        /// Hide the comment thread.
        #[arg(long)]
        no_comments: bool,
    },

    /// Add a comment.
    Comment {
        number: i64,

        #[command(flatten)]
        body: BodyArgs,
    },

    /// Close an issue.
    Close { number: i64 },

    /// Reopen a closed issue.
    Reopen { number: i64 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum IssueState {
    Open,
    Closed,
    All,
}

impl IssueState {
    pub fn as_param(self) -> &'static str {
        match self {
            IssueState::Open => "open",
            IssueState::Closed => "closed",
            IssueState::All => "all",
        }
    }
}

// ── pull requests ────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct PullArgs {
    #[command(subcommand)]
    pub command: PullCommand,
}

#[derive(Debug, Subcommand)]
pub enum PullCommand {
    /// List pull requests.
    #[command(visible_alias = "ls")]
    List {
        #[arg(long, value_enum, default_value_t = PullState::Open)]
        state: PullState,

        #[arg(long, value_name = "TEXT")]
        search: Option<String>,

        #[arg(long)]
        limit: Option<u32>,
    },

    /// Show one pull request: discussion, reviews and merge blockers.
    Show {
        number: i64,

        #[arg(long)]
        no_comments: bool,
    },

    /// Add a comment to the discussion.
    Comment {
        number: i64,

        #[command(flatten)]
        body: BodyArgs,
    },

    /// Leave a review verdict. One standing verdict per reviewer —
    /// posting again replaces your previous one.
    Review {
        number: i64,

        #[arg(long, value_enum)]
        state: ReviewState,

        #[command(flatten)]
        body: OptionalBodyArgs,
    },

    /// Merge a pull request.
    Merge {
        number: i64,

        /// Merge commit message.
        #[arg(long, value_name = "TEXT")]
        message: Option<String>,

        /// Bypass the repository's review rules. Repo admins only; the
        /// server refuses outright rather than quietly merging normally.
        #[arg(long)]
        override_rules: bool,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PullState {
    Open,
    Closed,
    Merged,
    All,
}

impl PullState {
    pub fn as_param(self) -> &'static str {
        match self {
            PullState::Open => "open",
            PullState::Closed => "closed",
            PullState::Merged => "merged",
            PullState::All => "all",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReviewState {
    Approve,
    RequestChanges,
    Comment,
}

impl ReviewState {
    pub fn as_param(self) -> &'static str {
        match self {
            ReviewState::Approve => "approved",
            ReviewState::RequestChanges => "changes_requested",
            ReviewState::Comment => "commented",
        }
    }
}

// ── wiki ─────────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct WikiArgs {
    #[command(subcommand)]
    pub command: WikiCommand,
}

#[derive(Debug, Subcommand)]
pub enum WikiCommand {
    /// List wiki pages.
    #[command(visible_alias = "ls")]
    List,

    /// Print one page's markdown.
    Show {
        /// Page slug, as shown by `gg wiki list`.
        slug: String,
    },
}

// ── packages ─────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct PackageArgs {
    #[command(subcommand)]
    pub command: PackageCommand,
}

#[derive(Debug, Subcommand)]
pub enum PackageCommand {
    /// List published packages, grouped by format.
    #[command(visible_alias = "ls")]
    List,

    /// Show one package's versions and files.
    Show {
        /// Registry format, e.g. `hex`, `npm`, `container`.
        format: String,
        /// Package name.
        name: String,
    },

    /// Print the forge's own setup instructions for a format.
    Usage {
        /// Registry format, e.g. `hex`, `npm`, `container`.
        format: String,
    },
}

// ── dashboard ────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct DashboardArgs {
    /// Seconds between background refreshes.
    #[arg(long, default_value_t = 10)]
    pub refresh: u64,

    /// How many runs to keep in the list.
    #[arg(long, default_value_t = 50)]
    pub limit: u32,
}

// ── shared ───────────────────────────────────────────────────────────

/// A comment body: required, from wherever the user finds it easiest.
#[derive(Debug, Args)]
pub struct BodyArgs {
    /// The text itself.
    #[arg(long, value_name = "TEXT")]
    pub body: Option<String>,

    /// Read the body from a file, or from stdin with `-`.
    #[arg(long, value_name = "PATH", conflicts_with = "body")]
    pub body_file: Option<String>,
}

/// The same, where the server will take an empty one.
#[derive(Debug, Args)]
pub struct OptionalBodyArgs {
    #[arg(long, value_name = "TEXT")]
    pub body: Option<String>,

    #[arg(long, value_name = "PATH", conflicts_with = "body")]
    pub body_file: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn command_tree_is_well_formed() {
        Cli::command().debug_assert();
    }
}