26.3 KiB · text History 8e00315
//! 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::{ArgGroup, 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),

    /// A repository's labels: list them, or create one.
    #[command(name = "labels", visible_alias = "label")]
    Labels(LabelsArgs),

    /// Wiki pages.
    Wiki(WikiArgs),

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

    /// Status badges: list them, print a README block, or patch one into
    /// a README in place.
    #[command(visible_alias = "badge")]
    Badges(BadgesArgs),

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

    /// Who changed what: a repository's audit log, or an organization's
    /// with --org. Needs a token with the `audit:read` scope, which no
    /// bundle includes.
    Audit(AuditArgs),
}

// ── 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>,
}

// ── audit ────────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct AuditArgs {
    /// An organization's log instead of the repository's.
    #[arg(long, value_name = "ORG")]
    pub org: Option<String>,

    /// With --org: only entries about this one of its repositories.
    #[arg(long = "in", value_name = "NAME", requires = "org")]
    pub in_repo: Option<String>,

    /// Only this category: secrets, members, settings, repositories,
    /// branch_protection, deploy_keys, webhooks, credentials, runners, ci,
    /// moderation.
    #[arg(long, value_name = "CATEGORY")]
    pub category: Option<String>,

    /// Only what this person did, by handle.
    #[arg(long, value_name = "HANDLE")]
    pub actor: Option<String>,

    /// How many entries (server caps this at 100).
    #[arg(long, default_value_t = 50)]
    pub limit: u32,

    /// Entries older than this one, by id — the next page.
    #[arg(long, value_name = "ID")]
    pub before: Option<i64>,
}

// ── 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,
    },

    /// Workflows that can be run by hand (`on: workflow_dispatch` on the
    /// default branch), with the inputs each declares.
    Workflows,

    /// Run a workflow by hand (`workflow_dispatch`).
    #[command(visible_alias = "run-workflow")]
    Dispatch {
        /// The workflow: its path, file name (with or without `.yml`), or
        /// `name:` (see `gg ci workflows`).
        workflow: String,

        /// Branch or tag to run it on. Defaults to the default branch.
        #[arg(long = "ref", value_name = "REF")]
        git_ref: Option<String>,

        /// An input, as NAME=VALUE. Repeat for each; booleans are
        /// `true`/`false`. Undeclared ones are refused by the server.
        #[arg(short = 'f', long = "field", value_name = "NAME=VALUE", value_parser = parse_field)]
        fields: Vec<(String, String)>,

        /// Follow the new run until it finishes, as `gg ci watch` does.
        /// Ignored with --json.
        #[arg(long)]
        watch: bool,

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

/// `NAME=VALUE`, split at the first `=` so a value may contain more.
fn parse_field(raw: &str) -> Result<(String, String), String> {
    match raw.split_once('=') {
        Some((name, value)) if !name.trim().is_empty() => {
            Ok((name.trim().to_string(), value.to_string()))
        }
        _ => Err(format!("expected NAME=VALUE, got {raw:?}")),
    }
}

// ── 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,
    },

    /// Open an issue. Prints its number and URL.
    Create {
        /// The issue's title.
        #[arg(long)]
        title: String,

        #[command(flatten)]
        body: OptionalBodyArgs,

        /// A label to apply, by name. Repeat for more; applied if you
        /// have triage access, and refused if the repository has no such
        /// label.
        #[arg(long = "label", value_name = "LABEL")]
        labels: Vec<String>,
    },

    /// 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>,
    },

    /// Open a pull request. Prints its number and URL.
    #[command(visible_alias = "new")]
    Create {
        /// The branch to merge (default: the branch checked out here). It
        /// has to be pushed already.
        #[arg(long, value_name = "BRANCH")]
        head: Option<String>,

        /// The branch to merge it into (default: the repository's default
        /// branch).
        #[arg(long, value_name = "BRANCH")]
        base: Option<String>,

        /// Take the head branch from this repository — a fork, or any
        /// repository sharing this one's history that you can read. The
        /// pull request opens on --repo.
        #[arg(long, value_name = "OWNER/NAME")]
        head_repo: Option<String>,

        /// The pull request's title.
        #[arg(long, value_name = "TEXT")]
        title: String,

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

    /// Change a pull request's title or description. Its author or a
    /// repository admin.
    #[command(group(ArgGroup::new("change").required(true).multiple(true).args(["title", "body", "body_file"])))]
    Edit {
        number: i64,

        /// The new title.
        #[arg(long, value_name = "TEXT")]
        title: Option<String>,

        /// The new description, replacing the old one. Empty clears it.
        #[arg(long, value_name = "TEXT")]
        body: Option<String>,

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

    /// Close a pull request without merging it. Its author or a
    /// repository admin.
    Close { number: i64 },

    /// Reopen a closed pull request. A merged one stays merged.
    Reopen { number: i64 },

    /// Add or remove labels. Its author or a repository admin; the labels
    /// have to exist (see `gg labels`).
    #[command(group(ArgGroup::new("change").required(true).multiple(true).args(["add", "remove"])))]
    Label {
        number: i64,

        /// A label to add. Repeat for more.
        #[arg(long, value_name = "LABEL")]
        add: Vec<String>,

        /// A label to remove. Repeat for more.
        #[arg(long, value_name = "LABEL")]
        remove: Vec<String>,
    },

    /// The labels a pull request in this repository can be given.
    Labels,

    /// 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,
    },

    /// Let a fork's workflow runs on the current head start. They run
    /// the fork's code, without this repository's secrets. Needs write
    /// access.
    ApproveRuns { number: i64 },

    /// Cancel a fork's workflow runs waiting for approval, on any head.
    /// Needs write access.
    RejectRuns { number: i64 },
}

#[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",
        }
    }
}

// ── labels ───────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct LabelsArgs {
    /// What to do; lists them when omitted.
    #[command(subcommand)]
    pub command: Option<LabelCommand>,
}

#[derive(Debug, Subcommand)]
pub enum LabelCommand {
    /// The repository's labels, including those inherited from its
    /// organization.
    #[command(visible_alias = "ls")]
    List,

    /// Create a repository label. Repository admins only.
    Create {
        name: String,

        /// As #RRGGBB (default: a neutral slate).
        #[arg(long, value_name = "#RRGGBB")]
        color: Option<String>,

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

// ── 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 {
    /// The text itself (optional).
    #[arg(long, value_name = "TEXT")]
    pub body: Option<String>,

    /// Read it from a file, or from stdin with `-`.
    #[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();
    }

    #[test]
    fn dispatch_fields_split_at_the_first_equals() {
        assert_eq!(
            parse_field("query=a=b").unwrap(),
            ("query".to_string(), "a=b".to_string())
        );
        assert_eq!(parse_field("flag=").unwrap().1, "");
        assert!(parse_field("novalue").is_err());
        assert!(parse_field("=x").is_err());
    }

    #[test]
    fn dispatch_parses_repeated_fields() {
        let cli = Cli::try_parse_from([
            "gg",
            "ci",
            "dispatch",
            "deploy.yml",
            "--ref",
            "main",
            "-f",
            "env=prod",
            "-f",
            "dry_run=true",
        ])
        .unwrap();
        let Command::Ci(CiArgs {
            command:
                CiCommand::Dispatch {
                    workflow,
                    git_ref,
                    fields,
                    ..
                },
        }) = cli.command
        else {
            panic!("not a dispatch");
        };
        assert_eq!(workflow, "deploy.yml");
        assert_eq!(git_ref.as_deref(), Some("main"));
        assert_eq!(fields.len(), 2);
    }

    #[test]
    fn pull_create_defaults_head_and_base_to_none() {
        let cli = Cli::try_parse_from(["gg", "pr", "create", "--title", "Fix it"]).unwrap();
        let Command::Pull(PullArgs {
            command: PullCommand::Create {
                head, base, title, ..
            },
        }) = cli.command
        else {
            panic!("not a create");
        };
        assert_eq!(title, "Fix it");
        assert!(head.is_none() && base.is_none());

        assert!(
            Cli::try_parse_from(["gg", "pull", "create"]).is_err(),
            "title is required"
        );
    }

    #[test]
    fn audit_narrows_an_org_log_to_one_repository_only_with_org() {
        let cli = Cli::try_parse_from([
            "gg", "audit", "--org", "acme", "--in", "web", "--actor", "alice",
        ])
        .unwrap();
        let Command::Audit(args) = cli.command else {
            panic!("not audit");
        };
        assert_eq!(args.org.as_deref(), Some("acme"));
        assert_eq!(args.in_repo.as_deref(), Some("web"));
        assert_eq!(args.actor.as_deref(), Some("alice"));
        assert_eq!(args.limit, 50);

        assert!(Cli::try_parse_from(["gg", "audit", "--in", "web"]).is_err());
    }

    #[test]
    fn pull_label_takes_repeated_adds_and_removes_but_needs_one() {
        let cli = Cli::try_parse_from([
            "gg", "pull", "label", "7", "--add", "bug", "--add", "ui", "--remove", "triage",
        ])
        .unwrap();
        let Command::Pull(PullArgs {
            command:
                PullCommand::Label {
                    number,
                    add,
                    remove,
                },
        }) = cli.command
        else {
            panic!("not a label");
        };
        assert_eq!(number, 7);
        assert_eq!(add, ["bug", "ui"]);
        assert_eq!(remove, ["triage"]);

        assert!(Cli::try_parse_from(["gg", "pull", "label", "7"]).is_err());
    }

    #[test]
    fn pull_edit_needs_something_to_change() {
        assert!(Cli::try_parse_from(["gg", "pull", "edit", "3"]).is_err());
        assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--title", "New"]).is_ok());
        assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--body-file", "-"]).is_ok());
    }

    #[test]
    fn labels_lists_without_a_subcommand() {
        let cli = Cli::try_parse_from(["gg", "labels"]).unwrap();
        assert!(matches!(
            cli.command,
            Command::Labels(LabelsArgs { command: None })
        ));
        let cli =
            Cli::try_parse_from(["gg", "label", "create", "bug", "--color", "#ff0000"]).unwrap();
        assert!(matches!(
            cli.command,
            Command::Labels(LabelsArgs {
                command: Some(LabelCommand::Create { .. })
            })
        ));
    }
}

// ── badges ───────────────────────────────────────────────────────────

#[derive(Debug, Args)]
pub struct BadgesArgs {
    #[command(subcommand)]
    pub command: Option<BadgesCommand>,
}

#[derive(Debug, Subcommand)]
pub enum BadgesCommand {
    /// Every badge the repository has, and what each says now (the default).
    #[command(visible_alias = "ls")]
    List,

    /// A README block of badges: print it, or patch it into a file.
    ///
    /// With --patch, the block goes between `<!-- gg:badges … -->` and
    /// `<!-- /gg:badges -->`, replacing what's there, or under the first
    /// heading if there's no block yet. The opening marker records the
    /// badges and options, so running it again with no arguments keeps
    /// them; arguments given override what it recorded.
    Readme(ReadmeArgs),
}

#[derive(Debug, Args)]
pub struct ReadmeArgs {
    /// Badge keys, in order (see `gg badges`): `ci`, `release`,
    /// `workflow:audit`, `package:cargo:gitgud-cli`. Default: what the
    /// block already has, else the forge's usual four.
    #[arg(value_name = "KEY")]
    pub keys: Vec<String>,

    /// Every badge the repository has.
    #[arg(long, conflicts_with = "keys")]
    pub all: bool,

    /// split, dot or outline.
    #[arg(long, value_name = "STYLE", value_parser = ["split", "dot", "outline"])]
    pub style: Option<String>,

    /// round (the theme's radius) or square.
    #[arg(long, value_name = "CORNERS", value_parser = ["round", "square"])]
    pub corners: Option<String>,

    /// 1x, 2x or 2.5x.
    #[arg(long, value_name = "SCALE", value_parser = ["1x", "2x", "2.5x"])]
    pub scale: Option<String>,

    /// Lead each badge with the GG mark.
    #[arg(long, conflicts_with = "no_gg")]
    pub gg: bool,

    /// Drop the GG mark, where the block had it.
    #[arg(long)]
    pub no_gg: bool,

    /// auto (follows the reader's light or dark setting), or a theme by name.
    #[arg(long, value_name = "THEME")]
    pub theme: Option<String>,

    /// svg or png.
    #[arg(long, value_name = "FORMAT", value_parser = ["svg", "png"])]
    pub format: Option<String>,

    /// HTML with separate dark and light images, instead of Markdown.
    #[arg(long, conflicts_with = "markdown")]
    pub html: bool,

    /// Markdown, where the block was HTML.
    #[arg(long)]
    pub markdown: bool,

    /// Write the block into this file (e.g. README.md) instead of printing it.
    #[arg(long, value_name = "FILE")]
    pub patch: Option<std::path::PathBuf>,

    /// With --patch: change nothing, and exit 1 if the file's block is out
    /// of date. For CI.
    #[arg(long, requires = "patch")]
    pub check: bool,
}