From 9b08589092de77f4f915359a2eb872ae6bfabafc Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 16:21:03 -0500 Subject: [PATCH] feat(cli): mgit CLI over the library (flags accepted after positionals) --- cmd/mgit/main.go | 12 + internal/cli/cli.go | 179 ++++++++++++++ internal/cli/cli_test.go | 331 ++++++++++++++++++++++++++ internal/cli/commands.go | 501 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 1023 insertions(+) create mode 100644 cmd/mgit/main.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/commands.go diff --git a/cmd/mgit/main.go b/cmd/mgit/main.go new file mode 100644 index 0000000..d42a4a6 --- /dev/null +++ b/cmd/mgit/main.go @@ -0,0 +1,12 @@ +// Command mgit is the Gitea CLI over the mopac-gitea-go library. +package main + +import ( + "os" + + "git.knownelement.com/ukrrs/mopac-gitea-go/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..7904180 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,179 @@ +// Package cli implements the mgit command line: a thin, machine-friendly +// front end over the gitea library. Connection settings come from +// GITEA_URL / GITEA_KEY env vars or a 0600 --config env file — the token +// is never a flag value and never logged. Every command accepts -o json +// for machine output. Exit codes: 0 ok, 1 usage/config, 2 API error +// (single-line stderr carrying the http status, parseable). +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "git.knownelement.com/ukrrs/mopac-gitea-go/gitea" + "git.knownelement.com/ukrrs/mopac-gitea-go/internal/config" +) + +const usage = `mgit: Gitea CLI (stdlib-only, library-backed) + +Usage: + mgit repo create -n NAME [--owner ORG] [--desc TEXT] [--private] + [--auto-init] [--default-branch B] [-o json] + mgit repo list [--owner OWNER] [-o json] + mgit repo show OWNER/REPO [-o json] + mgit branch list OWNER/REPO [-o json] + mgit status create OWNER/REPO REF --state success|pending|error|failure + [--context NAME] [--description TEXT] [--target-url URL] [-o json] + mgit status list OWNER/REPO REF [-o json] (combined + per-check) + mgit pr create OWNER/REPO --title TITLE [--body FILE|-] --base B --head H [-o json] + mgit pr list OWNER/REPO [--state open|closed|all] [-o json] + mgit pr show OWNER/REPO NUMBER [-o json] + mgit pr merge OWNER/REPO NUMBER [--do merge|rebase|rebase-merge|squash|fast-forward] + mgit help + +Connection: GITEA_URL + GITEA_KEY env vars, or --config PATH pointing at a +0600 env file with the same keys. The token never appears in flags, +logs, or error output. + +REF accepts anything the server resolves (full or short sha, branch, +tag). PR head may be "branch" or "owner:branch" (fork syntax). Flags +parse before OR after positionals. + +Exit codes: 0 ok, 1 usage/config error, 2 API error (stderr: one line, +"http NNN" included).` + +// Run executes one command; it returns the process exit code. +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return 1 + } + // Hoist a leading global --config PATH onto the subcommand (the + // subcommand flag sets already accept it anywhere). + var hoisted []string + for len(args) >= 2 && (args[0] == "--config" || args[0] == "-config") { + hoisted = append(hoisted, args[0], args[1]) + args = args[2:] + } + if len(hoisted) > 0 { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return 1 + } + args = append(args, hoisted...) + } + switch args[0] { + case "help", "-h", "--help": + fmt.Fprint(stdout, usage) + return 0 + case "repo": + return cmdRepo(args[1:], stdout, stderr) + case "branch": + return cmdBranch(args[1:], stdout, stderr) + case "status": + return cmdStatus(args[1:], stdout, stderr) + case "pr": + return cmdPR(args[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "mgit: unknown command %q\n\n%s\n", args[0], usage) + return 1 + } +} + +// newFlags builds a quiet flag set with the shared --config and -o flags. +func newFlags(name string, stderr io.Writer) (*flag.FlagSet, *string, *string) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + cfg := fs.String("config", "", "env file with GITEA_URL/GITEA_KEY (must be 0600)") + out := fs.String("o", "text", "output format: text|json") + return fs, cfg, out +} + +// parseArgs parses flags that may appear AFTER positionals (the standard +// flag package stops at the first positional; mgit's documented surface +// is "pr merge OWNER/REPO NUMBER --do squash"). +func parseArgs(fs *flag.FlagSet, args []string) ([]string, error) { + var positionals []string + rest := args + for { + if err := fs.Parse(rest); err != nil { + return nil, err + } + got := fs.Args() + i := 0 + for i < len(got) && (got[i] == "-" || !strings.HasPrefix(got[i], "-")) { + i++ + } + positionals = append(positionals, got[:i]...) + if i == len(got) { + return positionals, nil + } + rest = got[i:] + } +} + +// client resolves config (env + optional 0600 file) and builds the +// client. A nil client and non-nil error means exit 1; API failures +// surface later as exit 2. +func client(cfgPath string) (*gitea.Client, error) { + if cfgPath == "" { + cfgPath = os.Getenv("MGIT_CONFIG") + } + if cfgPath == "" { + if home, err := os.UserHomeDir(); err == nil { + cand := filepath.Join(home, ".config", "mgit", "env") + if _, err := os.Stat(cand); err == nil { + cfgPath = cand + } + } + } + cfg, _, err := config.Load(cfgPath) + if err != nil { + return nil, err + } + return gitea.New(gitea.Config{BaseURL: cfg.BaseURL, Token: cfg.Token}), nil +} + +// apiErr reports an API failure the mgit way: one line, exit 2. +func apiErr(stderr io.Writer, err error) int { + fmt.Fprintf(stderr, "mgit: %v\n", err) + return 2 +} + +// usageErr reports a usage/config failure: one line, exit 1. +func usageErr(stderr io.Writer, format string, args ...any) int { + fmt.Fprintf(stderr, "mgit: "+format+"\n", args...) + return 1 +} + +// emitJSON pretty-prints v for -o json. +func emitJSON(stdout io.Writer, v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return // library types are JSON-clean by construction + } + stdout.Write(b) + fmt.Fprintln(stdout) +} + +// repoArg splits "OWNER/REPO" into its parts. +func repoArg(arg string) (owner, repo string, err error) { + parts := strings.Split(arg, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("bad repository %q (want OWNER/REPO)", arg) + } + return parts[0], parts[1], nil +} + +// shortSha renders a sha for humans (8 chars). +func shortSha(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..966e8e1 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,331 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.knownelement.com/ukrrs/mopac-gitea-go/internal/fakegitea" +) + +const fakeToken = "fake-gitea-token-0123456789" + +// run boots a fake Gitea, points the CLI at it via env, and runs one +// command; it returns the exit code plus captured output. +func run(t *testing.T, srv *fakegitea.Server, args ...string) (int, string, string) { + t.Helper() + t.Setenv("GITEA_URL", srv.URL) + t.Setenv("GITEA_KEY", fakeToken) + var out, errb bytes.Buffer + code := Run(args, &out, &errb) + return code, out.String(), errb.String() +} + +func newServer(t *testing.T) *fakegitea.Server { + t.Helper() + srv := fakegitea.New(fakeToken, "ukrrs") + t.Cleanup(srv.Close) + return srv +} + +func TestHelpAndUsage(t *testing.T) { + srv := newServer(t) + + tests := []struct { + name string + args []string + code int + }{ + {name: "help", args: []string{"help"}, code: 0}, + {name: "no args", args: nil, code: 1}, + {name: "unknown command", args: []string{"frobnicate"}, code: 1}, + {name: "unknown subcommand", args: []string{"repo", "frob"}, code: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, _, errOut := run(t, srv, tt.args...) + if code != tt.code { + t.Fatalf("code = %d, want %d (stderr: %s)", code, tt.code, errOut) + } + }) + } + + code, out, _ := run(t, srv, "help") + if code != 0 || !strings.Contains(out, "mgit repo create") || !strings.Contains(out, "GITEA_URL") { + t.Fatalf("help output = %q", out) + } +} + +func TestRepoLifecycle(t *testing.T) { + srv := newServer(t) + + code, out, errOut := run(t, srv, "repo", "create", "-n", "vertical-x", "--desc", "one vertical", + "--private", "--auto-init") + if code != 0 { + t.Fatalf("repo create: code=%d stderr=%s", code, errOut) + } + if !strings.Contains(out, "ukrrs/vertical-x") || !strings.Contains(out, "private") { + t.Fatalf("out = %q", out) + } + + code, out, errOut = run(t, srv, "repo", "create", "-n", "vertical-y", "-o", "json") + if code != 0 { + t.Fatalf("repo create json: %s", errOut) + } + var repo struct { + FullName string `json:"full_name"` + Private bool `json:"private"` + } + if err := json.Unmarshal([]byte(out), &repo); err != nil { + t.Fatalf("json out: %v (%s)", err, out) + } + if repo.FullName != "ukrrs/vertical-y" || repo.Private { + t.Fatalf("json = %+v", repo) + } + + code, out, errOut = run(t, srv, "repo", "list") + if code != 0 { + t.Fatalf("repo list: %s", errOut) + } + if !strings.Contains(out, "ukrrs/vertical-x") || !strings.Contains(out, "ukrrs/vertical-y") { + t.Fatalf("list = %q", out) + } + + code, out, errOut = run(t, srv, "repo", "list", "--owner", "other") + if code != 0 { + t.Fatalf("repo list other: %s", errOut) + } + if strings.Contains(out, "ukrrs/") { + t.Fatalf("list other = %q", out) + } + + code, out, errOut = run(t, srv, "repo", "show", "ukrrs/vertical-x") + if code != 0 || !strings.Contains(out, "one vertical") { + t.Fatalf("repo show: code=%d out=%q err=%s", code, out, errOut) + } + + // Duplicate create -> exit 2, one-line stderr with the http code. + code, out, errOut = run(t, srv, "repo", "create", "-n", "vertical-x") + if code != 2 { + t.Fatalf("dup create code = %d, want 2", code) + } + if !strings.Contains(errOut, "http 409") || strings.Count(strings.TrimRight(errOut, "\n"), "\n") != 0 { + t.Fatalf("dup stderr = %q", errOut) + } +} + +func TestRepoCreateMissingName(t *testing.T) { + srv := newServer(t) + code, _, errOut := run(t, srv, "repo", "create") + if code != 1 || !strings.Contains(errOut, "-n") { + t.Fatalf("code=%d err=%q", code, errOut) + } +} + +func TestBranchList(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + code, out, errOut := run(t, srv, "branch", "list", "ukrrs/MOPAC") + if code != 0 { + t.Fatalf("branch list: %s", errOut) + } + if !strings.Contains(out, "main") || !strings.Contains(out, "feat/quota") { + t.Fatalf("out = %q", out) + } + + code, out, errOut = run(t, srv, "branch", "list", "ukrrs/MOPAC", "-o", "json") + if code != 0 { + t.Fatalf("branch list json: %s", errOut) + } + var branches []struct { + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(out), &branches); err != nil || len(branches) != 2 { + t.Fatalf("json = %s (%v)", out, err) + } + + if code, _, errOut = run(t, srv, "branch", "list", "ukrrs/absent"); code != 2 || !strings.Contains(errOut, "http 404") { + t.Fatalf("missing repo: code=%d err=%q", code, errOut) + } +} + +func TestStatusCommands(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main") + sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") + + code, out, errOut := run(t, srv, "status", "create", "ukrrs/MOPAC", sha, + "--state", "success", "--context", "ci/lint", "--description", "clean") + if code != 0 { + t.Fatalf("status create: %s", errOut) + } + if !strings.Contains(out, "success") || !strings.Contains(out, "ci/lint") { + t.Fatalf("out = %q", out) + } + + // Invalid state is a usage error before any request. + if code, _, errOut = run(t, srv, "status", "create", "ukrrs/MOPAC", sha, "--state", "green"); code != 1 { + t.Fatalf("invalid state code = %d err=%q", code, errOut) + } + + code, out, errOut = run(t, srv, "status", "list", "ukrrs/MOPAC", "feat/quota") + if code != 0 { + t.Fatalf("status list: %s", errOut) + } + if !strings.Contains(out, "success") || !strings.Contains(out, "ci/lint") { + t.Fatalf("out = %q", out) + } +} + +func TestPullRequestFlow(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + bodyFile := filepath.Join(t.TempDir(), "body.md") + os.WriteFile(bodyFile, []byte("## Scope\n- quota accounting"), 0o600) + + code, out, errOut := run(t, srv, "pr", "create", "ukrrs/MOPAC", + "--title", "Quota accounting", "--body", bodyFile, + "--base", "main", "--head", "feat/quota", "-o", "json") + if code != 0 { + t.Fatalf("pr create: %s", errOut) + } + var pr struct { + Number int64 `json:"number"` + State string `json:"state"` + } + if err := json.Unmarshal([]byte(out), &pr); err != nil || pr.Number != 1 || pr.State != "open" { + t.Fatalf("json = %s (%v)", out, err) + } + + code, out, errOut = run(t, srv, "pr", "list", "ukrrs/MOPAC") + if code != 0 || !strings.Contains(out, "Quota accounting") || !strings.Contains(out, "#1") { + t.Fatalf("pr list: code=%d out=%q err=%s", code, out, errOut) + } + + code, out, errOut = run(t, srv, "pr", "show", "ukrrs/MOPAC", "1") + if code != 0 || !strings.Contains(out, "quota accounting") || !strings.Contains(out, "main..feat/quota") { + t.Fatalf("pr show: code=%d out=%q err=%s", code, out, errOut) + } + + code, out, errOut = run(t, srv, "pr", "merge", "ukrrs/MOPAC", "1", "--do", "squash") + if code != 0 || !strings.Contains(out, "#1") || !strings.Contains(out, "squash") { + t.Fatalf("pr merge: code=%d out=%q err=%s", code, out, errOut) + } + + // Merged: the open list is empty and re-merge fails with 409. + code, out, _ = run(t, srv, "pr", "list", "ukrrs/MOPAC") + if code != 0 || strings.Contains(out, "Quota accounting") { + t.Fatalf("pr list after merge = %q", out) + } + if code, _, errOut = run(t, srv, "pr", "merge", "ukrrs/MOPAC", "1"); code != 2 || !strings.Contains(errOut, "http 409") { + t.Fatalf("re-merge: code=%d err=%q", code, errOut) + } +} + +func TestPullRequestUsageErrors(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + tests := []struct { + name string + args []string + }{ + {"missing title", []string{"pr", "create", "ukrrs/MOPAC", "--base", "main", "--head", "feat/quota"}}, + {"missing base", []string{"pr", "create", "ukrrs/MOPAC", "--title", "t", "--head", "feat/quota"}}, + {"missing head", []string{"pr", "create", "ukrrs/MOPAC", "--title", "t", "--base", "main"}}, + {"bad repo arg", []string{"pr", "create", "MOPAC", "--title", "t", "--base", "main", "--head", "x"}}, + {"non-numeric index", []string{"pr", "merge", "ukrrs/MOPAC", "abc"}}, + {"bad merge style", []string{"pr", "merge", "ukrrs/MOPAC", "1", "--do", "blend"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, _, errOut := run(t, srv, tt.args...) + if code != 1 { + t.Fatalf("code = %d, want usage error 1 (stderr: %s)", code, errOut) + } + }) + } +} + +func TestFlagsAfterPositionals(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + code, _, errOut := run(t, srv, "pr", "create", "ukrrs/MOPAC", "--base", "main", + "--head", "feat/quota", "--title", "flags after positional") + if code != 0 { + t.Fatalf("pr create: %s", errOut) + } +} + +func TestTokenNeverInOutput(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main") + + t.Setenv("GITEA_URL", srv.URL) + t.Setenv("GITEA_KEY", fakeToken) + var out, errb bytes.Buffer + srv.Token = "rotated-" + fakeToken // force a 401 body that echoes the token + code := Run([]string{"repo", "list"}, &out, &errb) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if strings.Contains(out.String()+errb.String(), fakeToken) { + t.Fatalf("token leaked: out=%q err=%q", out.String(), errb.String()) + } +} + +func TestMissingConfigIsUsageError(t *testing.T) { + t.Setenv("GITEA_URL", "") + t.Setenv("GITEA_KEY", "") + var out, errb bytes.Buffer + code := Run([]string{"repo", "list", "--config", filepath.Join(t.TempDir(), "absent")}, &out, &errb) + if code != 1 || !strings.Contains(errb.String(), "GITEA_URL") { + t.Fatalf("code=%d err=%q", code, errb.String()) + } +} + +func TestLooseConfigRefused(t *testing.T) { + srv := newServer(t) + path := filepath.Join(t.TempDir(), "loose.env") + os.WriteFile(path, []byte("GITEA_URL="+srv.URL+"\nGITEA_KEY="+fakeToken+"\n"), 0o644) + + t.Setenv("GITEA_URL", "") + t.Setenv("GITEA_KEY", "") + var out, errb bytes.Buffer + code := Run([]string{"repo", "list", "--config", path}, &out, &errb) + if code != 1 || !strings.Contains(errb.String(), "insecure mode") { + t.Fatalf("code=%d err=%q", code, errb.String()) + } +} + +func TestBodyFromStdinDash(t *testing.T) { + srv := newServer(t) + srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") + + t.Setenv("GITEA_URL", srv.URL) + t.Setenv("GITEA_KEY", fakeToken) + var out, errb bytes.Buffer + old := os.Stdin + r, w, _ := os.Pipe() + w.WriteString("body via stdin\n") + w.Close() + os.Stdin = r + defer func() { os.Stdin = old; time.Sleep(0) }() + code := Run([]string{"pr", "create", "ukrrs/MOPAC", "--title", "stdin body", + "--body", "-", "--base", "main", "--head", "feat/quota"}, &out, &errb) + os.Stdin = old + if code != 0 { + t.Fatalf("pr create stdin: code=%d err=%s", code, errb.String()) + } + stored, ok := srv.PullRequest("ukrrs", "MOPAC", 1) + if !ok || stored.Body != "body via stdin\n" { + t.Fatalf("stored body = %q ok=%v", stored.Body, ok) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..427107d --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,501 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "strconv" + "strings" + + "git.knownelement.com/ukrrs/mopac-gitea-go/gitea" +) + +// --- repo ------------------------------------------------------------------- + +func cmdRepo(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return usageErr(stderr, "repo: want create|list|show") + } + sub, rest := args[0], args[1:] + switch sub { + case "create": + return repoCreate(rest, stdout, stderr) + case "list": + return repoList(rest, stdout, stderr) + case "show": + return repoShow(rest, stdout, stderr) + default: + return usageErr(stderr, "repo: unknown subcommand %q (create|list|show)", sub) + } +} + +func repoCreate(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("repo create", stderr) + owner := fs.String("owner", "", "create under this organization (default: your account)") + name := fs.String("n", "", "repository name (required)") + desc := fs.String("desc", "", "description") + priv := fs.Bool("private", false, "make the repository private") + autoInit := fs.Bool("auto-init", false, "initialize with a README commit") + defBranch := fs.String("default-branch", "", "default branch (with --auto-init)") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "repo create: %v", err) + } + if len(pos) > 0 { + return usageErr(stderr, "repo create: unexpected argument %q", pos[0]) + } + if *name == "" { + return usageErr(stderr, "repo create: -n NAME is required") + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + repo, err := c.CreateRepo(context.Background(), gitea.RepoParams{ + Owner: *owner, + Name: *name, + Description: *desc, + Private: *priv, + AutoInit: *autoInit, + DefaultBranch: *defBranch, + }) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, repo) + return 0 + } + vis := "public" + if repo.Private { + vis = "private" + } + fmt.Fprintf(stdout, "created repo %s (%s)\n%s\n", repo.FullName, vis, repo.HTMLURL) + return 0 +} + +func repoList(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("repo list", stderr) + owner := fs.String("owner", "", "list this account's repos (default: yours)") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "repo list: %v", err) + } + if len(pos) > 0 { + return usageErr(stderr, "repo list: unexpected argument %q", pos[0]) + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + repos, err := c.ListRepos(context.Background(), *owner) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, repos) + return 0 + } + for _, r := range repos { + vis := "public" + if r.Private { + vis = "private" + } + fmt.Fprintf(stdout, "%-40s %-12s %-7s %s\n", r.FullName, r.DefaultBranch, vis, r.Description) + } + return 0 +} + +func repoShow(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("repo show", stderr) + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "repo show: %v", err) + } + if len(pos) != 1 { + return usageErr(stderr, "repo show: want OWNER/REPO") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "repo show: %v", err) + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + repo, err := c.GetRepo(context.Background(), owner, name) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, repo) + return 0 + } + vis := "public" + if repo.Private { + vis = "private" + } + fmt.Fprintf(stdout, "%s (%s)\n", repo.FullName, vis) + if repo.Description != "" { + fmt.Fprintf(stdout, "%s\n", repo.Description) + } + fmt.Fprintf(stdout, "default branch: %s\n", repo.DefaultBranch) + fmt.Fprintf(stdout, "%s\n", repo.HTMLURL) + return 0 +} + +// --- branch ------------------------------------------------------------------- + +func cmdBranch(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return usageErr(stderr, "branch: want list") + } + if args[0] != "list" { + return usageErr(stderr, "branch: unknown subcommand %q (list)", args[0]) + } + fs, cfgPath, out := newFlags("branch list", stderr) + pos, err := parseArgs(fs, args[1:]) + if err != nil { + return usageErr(stderr, "branch list: %v", err) + } + if len(pos) != 1 { + return usageErr(stderr, "branch list: want OWNER/REPO") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "branch list: %v", err) + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + branches, err := c.ListBranches(context.Background(), owner, name) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, branches) + return 0 + } + for _, b := range branches { + fmt.Fprintf(stdout, "%-30s %s\n", b.Name, shortSha(b.Commit.ID)) + } + return 0 +} + +// --- status ------------------------------------------------------------------- + +var statusStates = map[string]bool{ + gitea.StatusPending: true, + gitea.StatusSuccess: true, + gitea.StatusError: true, + gitea.StatusFailure: true, +} + +func cmdStatus(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return usageErr(stderr, "status: want create|list") + } + sub, rest := args[0], args[1:] + switch sub { + case "create": + return statusCreate(rest, stdout, stderr) + case "list": + return statusList(rest, stdout, stderr) + default: + return usageErr(stderr, "status: unknown subcommand %q (create|list)", sub) + } +} + +func statusCreate(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("status create", stderr) + state := fs.String("state", "", "success|pending|error|failure (required)") + ctxName := fs.String("context", "", "check name (e.g. ci/lint)") + desc := fs.String("description", "", "free text") + target := fs.String("target-url", "", "link to the check run") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "status create: %v", err) + } + if len(pos) != 2 { + return usageErr(stderr, "status create: want OWNER/REPO REF") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "status create: %v", err) + } + ref := pos[1] + if !statusStates[*state] { + return usageErr(stderr, "status create: --state must be success|pending|error|failure") + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + st, err := c.CreateCommitStatus(context.Background(), owner, name, ref, gitea.CommitStatusParams{ + State: *state, + Context: *ctxName, + Description: *desc, + TargetURL: *target, + }) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, st) + return 0 + } + fmt.Fprintf(stdout, "reported %s on %s/%s@%s (context %s)\n", + st.State, owner, name, shortSha(ref), st.Context) + return 0 +} + +func statusList(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("status list", stderr) + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "status list: %v", err) + } + if len(pos) != 2 { + return usageErr(stderr, "status list: want OWNER/REPO REF") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "status list: %v", err) + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + comb, err := c.GetCombinedStatus(context.Background(), owner, name, pos[1]) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, comb) + return 0 + } + fmt.Fprintf(stdout, "combined %s (%d checks) on %s\n", comb.State, comb.TotalCount, shortSha(comb.SHA)) + for _, s := range comb.Statuses { + fmt.Fprintf(stdout, "%-8s %-20s %s\n", s.State, s.Context, s.Description) + } + return 0 +} + +// --- pull requests -------------------------------------------------------------- + +var mergeStyles = map[string]bool{ + gitea.MergeMerge: true, + gitea.MergeRebase: true, + gitea.MergeRebaseMerge: true, + gitea.MergeSquash: true, + gitea.MergeFastForward: true, +} + +func cmdPR(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return usageErr(stderr, "pr: want create|list|show|merge") + } + sub, rest := args[0], args[1:] + switch sub { + case "create": + return prCreate(rest, stdout, stderr) + case "list": + return prList(rest, stdout, stderr) + case "show": + return prShow(rest, stdout, stderr) + case "merge": + return prMerge(rest, stdout, stderr) + default: + return usageErr(stderr, "pr: unknown subcommand %q (create|list|show|merge)", sub) + } +} + +// readBody loads a PR body from a file, or stdin when path is "-". +func readBody(path string) (string, error) { + if path == "" { + return "", nil + } + var b []byte + var err error + if path == "-" { + b, err = io.ReadAll(os.Stdin) + } else { + b, err = os.ReadFile(path) + } + if err != nil { + return "", fmt.Errorf("cannot read body: %w", err) + } + return string(b), nil +} + +func prCreate(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("pr create", stderr) + title := fs.String("title", "", "PR title (required)") + body := fs.String("body", "", "PR body from FILE or - for stdin") + base := fs.String("base", "", "target branch (required)") + head := fs.String("head", "", "source branch, owner:branch for forks (required)") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "pr create: %v", err) + } + if len(pos) != 1 { + return usageErr(stderr, "pr create: want OWNER/REPO") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "pr create: %v", err) + } + if *title == "" { + return usageErr(stderr, "pr create: --title is required") + } + if *base == "" { + return usageErr(stderr, "pr create: --base is required") + } + if *head == "" { + return usageErr(stderr, "pr create: --head is required") + } + bodyText, err := readBody(*body) + if err != nil { + return usageErr(stderr, "pr create: %v", err) + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + pr, err := c.CreatePullRequest(context.Background(), owner, name, gitea.PRParams{ + Title: *title, Body: bodyText, Base: *base, Head: *head, + }) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, pr) + return 0 + } + fmt.Fprintf(stdout, "created pull request #%d: %s\n%s\n", pr.Number, pr.Title, pr.HTMLURL) + return 0 +} + +func prList(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("pr list", stderr) + state := fs.String("state", gitea.PRStateOpen, "open|closed|all") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "pr list: %v", err) + } + if len(pos) != 1 { + return usageErr(stderr, "pr list: want OWNER/REPO") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "pr list: %v", err) + } + switch *state { + case gitea.PRStateOpen, gitea.PRStateClosed, gitea.PRStateAll: + default: + return usageErr(stderr, "pr list: --state must be open|closed|all") + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + prs, err := c.ListPullRequests(context.Background(), owner, name, *state) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, prs) + return 0 + } + for _, pr := range prs { + fmt.Fprintf(stdout, "#%-4d %-7s %s (%s..%s)\n", pr.Number, pr.State, pr.Title, + prBranchRef(pr.Base), prBranchRef(pr.Head)) + } + return 0 +} + +func prBranchRef(b *gitea.PRBranch) string { + if b == nil { + return "?" + } + return b.Ref +} + +func prShow(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, out := newFlags("pr show", stderr) + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "pr show: %v", err) + } + if len(pos) != 2 { + return usageErr(stderr, "pr show: want OWNER/REPO NUMBER") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "pr show: %v", err) + } + num, err := strconv.ParseInt(pos[1], 10, 64) + if err != nil { + return usageErr(stderr, "pr show: NUMBER must be numeric") + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + pr, err := c.GetPullRequest(context.Background(), owner, name, num) + if err != nil { + return apiErr(stderr, err) + } + if *out == "json" { + emitJSON(stdout, pr) + return 0 + } + state := pr.State + if pr.Merged { + state = "merged" + } + fmt.Fprintf(stdout, "#%d %s [%s]\n", pr.Number, pr.Title, state) + fmt.Fprintf(stdout, "%s..%s\n", prBranchRef(pr.Base), prBranchRef(pr.Head)) + fmt.Fprintf(stdout, "mergeable: %v\n", pr.Mergeable) + if pr.Body != "" { + for _, line := range strings.Split(strings.TrimRight(pr.Body, "\n"), "\n") { + fmt.Fprintf(stdout, " %s\n", line) + } + } + fmt.Fprintf(stdout, "%s\n", pr.HTMLURL) + return 0 +} + +func prMerge(args []string, stdout, stderr io.Writer) int { + fs, cfgPath, _ := newFlags("pr merge", stderr) + style := fs.String("do", gitea.MergeMerge, "merge|rebase|rebase-merge|squash|fast-forward") + pos, err := parseArgs(fs, args) + if err != nil { + return usageErr(stderr, "pr merge: %v", err) + } + if len(pos) != 2 { + return usageErr(stderr, "pr merge: want OWNER/REPO NUMBER") + } + owner, name, err := repoArg(pos[0]) + if err != nil { + return usageErr(stderr, "pr merge: %v", err) + } + num, err := strconv.ParseInt(pos[1], 10, 64) + if err != nil { + return usageErr(stderr, "pr merge: NUMBER must be numeric") + } + if !mergeStyles[*style] { + return usageErr(stderr, "pr merge: --do must be merge|rebase|rebase-merge|squash|fast-forward") + } + c, err := client(*cfgPath) + if err != nil { + return usageErr(stderr, "%v", err) + } + if err := c.MergePullRequest(context.Background(), owner, name, num, gitea.MergeParams{Do: *style}); err != nil { + return apiErr(stderr, err) + } + fmt.Fprintf(stdout, "merged pull request #%d (%s)\n", num, *style) + return 0 +}