Files
mopac-gitea-go/internal/cli/cli.go
T

180 lines
5.5 KiB
Go

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