feat(cli): mgit CLI over the library (flags accepted after positionals)

This commit is contained in:
2026-08-29 16:21:03 -05:00
parent 5b35e8cd84
commit 9b08589092
4 changed files with 1023 additions and 0 deletions
+179
View File
@@ -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
}
+331
View File
@@ -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)
}
}
+501
View File
@@ -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
}