feat(cli): mgit CLI over the library (flags accepted after positionals)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user