Commit 94884a2232

94884a22329740d40876a987689c17ab77aa9965

parent: 2147a76cd5

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 00:55 UTC

control: one flag parser for every command

Thirty-eight commands walked argv by hand and each decided on its own
whether an unknown --flag was an error, a positional or nothing at
all; mr review --approve --bogus reported repository --bogus as not
found. parseFlags reads argv against a spec of value flags, repeatable
flags, switches and a positional limit, and answers every mistake the
same way with the command's usage attached: unknown flag, missing
value, too many arguments. A value is taken verbatim, - included, and
-- ends flag parsing. Only the page-flag pre-pass keeps its own loop.

Closes #96

Layout: unified · split

internal/control/admin.go +13 −30
@@ -91,19 +91,12 @@ func runAdminUserList(c *Ctx, args []string) int {
91 if code >= 0 { 91 if code >= 0 {
92 return code 92 return code
93 } 93 }
94 state := "" 94 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
95 for i := 0; i < len(args); i++ { 95 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
96 switch args[i] { 96 if err != nil {
97 case "--state": 97 return c.fail(protocol.ExitUsage, "%v", err)
98 if i+1 >= len(args) {
99 return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
100 }
101 state = args[i+1]
102 i++
103 default:
104 return c.fail(protocol.ExitUsage, "usage: admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]")
105 }
106 } 98 }
99 state := f.Value("--state")
107 switch state { 100 switch state {
108 case "", "active", "pending", "disabled", "admin": 101 case "", "active", "pending", "disabled", "admin":
109 default: 102 default:
@@ -344,24 +337,14 @@ func runAdminRepoList(c *Ctx, args []string) int {
344 if code >= 0 { 337 if code >= 0 {
345 return code 338 return code
346 } 339 }
347 var owner, visibility string 340 f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
348 for i := 0; i < len(args); i++ { 341 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
349 switch args[i] { 342 if err != nil {
350 case "--owner": 343 return c.fail(protocol.ExitUsage, "%v", err)
351 if i+1 >= len(args) { 344 }
352 return c.fail(protocol.ExitUsage, "--owner requires a value") 345 owner, visibility := f.Value("--owner"), f.Value("--visibility")
353 } 346 if visibility != "" && visibility != "public" && visibility != "private" {
354 owner = args[i+1] 347 return c.fail(protocol.ExitUsage, "--visibility requires public|private")
355 i++
356 case "--visibility":
357 if i+1 >= len(args) || (args[i+1] != "public" && args[i+1] != "private") {
358 return c.fail(protocol.ExitUsage, "--visibility requires public|private")
359 }
360 visibility = args[i+1]
361 i++
362 default:
363 return c.fail(protocol.ExitUsage, "usage: admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]")
364 }
365 } 348 }
366 repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key) 349 repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
367 if err != nil { 350 if err != nil {
internal/control/adminhost.go +11 −26
@@ -56,32 +56,17 @@ func runAdminUserCreate(c *Ctx, args []string) int {
56 return code 56 return code
57 } 57 }
58 const usage = "usage: admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub" 58 const usage = "usage: admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub"
59 var username, email string 59 f, err := parseFlags(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: usage})
60 var isAdmin, verified, withKey bool 60 if err != nil {
61 for i := 0; i < len(args); i++ { 61 return c.fail(protocol.ExitUsage, "%v", err)
62 switch args[i] { 62 }
63 case "--admin": 63 username, email := f.pos(0), f.Value("--email")
64 isAdmin = true 64 isAdmin, verified, withKey := f.Has("--admin"), f.Has("--verified"), f.Has("--key")
65 case "--verified": 65 if withKey && f.Value("--key") != "-" {
66 verified = true 66 return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)")
67 case "--email": 67 }
68 if i+1 >= len(args) { 68 if username == "" || username[0] == '-' {
69 return c.fail(protocol.ExitUsage, "--email requires a value") 69 return c.fail(protocol.ExitUsage, usage)
70 }
71 email = args[i+1]
72 i++
73 case "--key":
74 if i+1 >= len(args) || args[i+1] != "-" {
75 return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)")
76 }
77 withKey = true
78 i++
79 default:
80 if username != "" || len(args[i]) == 0 || args[i][0] == '-' {
81 return c.fail(protocol.ExitUsage, usage)
82 }
83 username = args[i]
84 }
85 } 70 }
86 if username == "" || (verified && email == "") { 71 if username == "" || (verified && email == "") {
87 return c.fail(protocol.ExitUsage, usage) 72 return c.fail(protocol.ExitUsage, usage)
internal/control/audit.go +16 −24
@@ -25,32 +25,24 @@ func runAudit(c *Ctx, args []string) int {
25 return c.fail(protocol.ExitDenied, "the audit log is for instance admins") 25 return c.fail(protocol.ExitDenied, "the audit log is for instance admins")
26 } 26 }
27 f := store.AuditFilter{Limit: 100} 27 f := store.AuditFilter{Limit: 100}
28 for i := 0; i < len(args); i++ { 28 fl, err := parseFlags(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: auditUsage})
29 if i+1 >= len(args) { 29 if err != nil {
30 return c.fail(protocol.ExitUsage, auditUsage) 30 return c.fail(protocol.ExitUsage, "%v", err)
31 }
32 if fl.Has("--limit") {
33 n, err := strconv.Atoi(fl.Value("--limit"))
34 if err != nil || n < 1 || n > 10000 {
35 return c.fail(protocol.ExitUsage, "--limit must be 1 to 10000")
31 } 36 }
32 v := args[i+1] 37 f.Limit = n
33 switch args[i] { 38 }
34 case "--limit": 39 f.Actor, f.ActionPrefix = fl.Value("--actor"), fl.Value("--action")
35 n, err := strconv.Atoi(v) 40 if fl.Has("--since") {
36 if err != nil || n < 1 || n > 10000 { 41 t, ok := parseSince(fl.Value("--since"), time.Now())
37 return c.fail(protocol.ExitUsage, "--limit must be 1 to 10000") 42 if !ok {
38 } 43 return c.fail(protocol.ExitUsage, "--since takes a duration (30m, 24h, 7d) or a date (2026-09-01, RFC 3339)")
39 f.Limit = n
40 case "--actor":
41 f.Actor = v
42 case "--action":
43 f.ActionPrefix = v
44 case "--since":
45 t, ok := parseSince(v, time.Now())
46 if !ok {
47 return c.fail(protocol.ExitUsage, "--since takes a duration (30m, 24h, 7d) or a date (2026-09-01, RFC 3339)")
48 }
49 f.Since = t.UTC().Format("2006-01-02T15:04:05.000Z")
50 default:
51 return c.fail(protocol.ExitUsage, auditUsage)
52 } 44 }
53 i++ 45 f.Since = t.UTC().Format("2006-01-02T15:04:05.000Z")
54 } 46 }
55 entries, err := c.Store.AuditEntries(f) 47 entries, err := c.Store.AuditEntries(f)
56 if err != nil { 48 if err != nil {
internal/control/commitfile.go +5 −23
@@ -35,30 +35,12 @@ const maxCommitFileBytes = 1 << 20
35// command rather than writing a commit its own policy would reject. 35// command rather than writing a commit its own policy would reject.
36func runCommitFile(c *Ctx, args []string) int { 36func runCommitFile(c *Ctx, args []string) int {
37 const usage = "repo commit-file <owner/name> <path> --ref <branch> [--message <m>] [--file -]" 37 const usage = "repo commit-file <owner/name> <path> --ref <branch> [--message <m>] [--file -]"
38 var rest []string 38 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: usage})
39 var ref, message, file string 39 if err != nil {
40 for i := 0; i < len(args); i++ { 40 return c.fail(protocol.ExitUsage, "%v", err)
41 switch args[i] {
42 case "--ref", "--message", "--file":
43 if i+1 >= len(args) {
44 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
45 }
46 switch args[i] {
47 case "--ref":
48 ref = args[i+1]
49 case "--message":
50 message = args[i+1]
51 case "--file":
52 file = args[i+1]
53 }
54 i++
55 default:
56 if strings.HasPrefix(args[i], "--") {
57 return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
58 }
59 rest = append(rest, args[i])
60 }
61 } 41 }
42 rest := f.Pos
43 ref, message, file := f.Value("--ref"), f.Value("--message"), f.Value("--file")
62 if len(rest) != 2 || ref == "" { 44 if len(rest) != 2 || ref == "" {
63 return c.fail(protocol.ExitUsage, "usage: %s", usage) 45 return c.fail(protocol.ExitUsage, "usage: %s", usage)
64 } 46 }
internal/control/diffcomment.go +19 −35
@@ -31,43 +31,27 @@ func init() {
31} 31}
32 32
33func runDiffComment(c *Ctx, args []string) int { 33func runDiffComment(c *Ctx, args []string) int {
34 var rest []string 34 f, err := parseFlags(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"}, Bools: []string{"--old"}, MaxPos: -1,
35 var path, message, file string 35 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--reply <id>] [--message <m> | --file -]"})
36 if err != nil {
37 return c.fail(protocol.ExitUsage, "%v", err)
38 }
39 rest := f.Pos
40 path, message, file, old := f.Value("--path"), f.Value("--message"), f.Value("--file"), f.Has("--old")
36 var line, replyTo int64 41 var line, replyTo int64
37 old := false 42 if f.Has("--line") {
38 for i := 0; i < len(args); i++ { 43 n, err := strconv.ParseInt(f.Value("--line"), 10, 64)
39 switch args[i] { 44 if err != nil || n < 1 {
40 case "--path", "--line", "--reply", "--message", "--file": 45 return c.fail(protocol.ExitUsage, "--line must be a positive number")
41 if i+1 >= len(args) { 46 }
42 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 47 line = n
43 } 48 }
44 v := args[i+1] 49 if f.Has("--reply") {
45 switch args[i] { 50 n, err := strconv.ParseInt(f.Value("--reply"), 10, 64)
46 case "--path": 51 if err != nil || n < 1 {
47 path = v 52 return c.fail(protocol.ExitUsage, "--reply must be a thread id")
48 case "--line":
49 n, err := strconv.ParseInt(v, 10, 64)
50 if err != nil || n < 1 {
51 return c.fail(protocol.ExitUsage, "--line must be a positive number")
52 }
53 line = n
54 case "--reply":
55 n, err := strconv.ParseInt(v, 10, 64)
56 if err != nil || n < 1 {
57 return c.fail(protocol.ExitUsage, "--reply must be a thread id")
58 }
59 replyTo = n
60 case "--message":
61 message = v
62 case "--file":
63 file = v
64 }
65 i++
66 case "--old":
67 old = true
68 default:
69 rest = append(rest, args[i])
70 } 53 }
54 replyTo = n
71 } 55 }
72 repo, mr, code := mrRef(c, rest, policy.CanRead) 56 repo, mr, code := mrRef(c, rest, policy.CanRead)
73 if code >= 0 { 57 if code >= 0 {
internal/control/explore.go +4 −13
@@ -80,20 +80,11 @@ func runExplore(c *Ctx, args []string) int {
80// same bytes with a Content-Disposition on them. 80// same bytes with a Content-Disposition on them.
81func runRepoDownload(c *Ctx, args []string) int { 81func runRepoDownload(c *Ctx, args []string) int {
82 const usage = "repo download <owner/name> [--ref <r>] > repo.tar.gz" 82 const usage = "repo download <owner/name> [--ref <r>] > repo.tar.gz"
83 var rest []string 83 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: -1, Usage: usage})
84 var ref string 84 if err != nil {
85 for i := 0; i < len(args); i++ { 85 return c.fail(protocol.ExitUsage, "%v", err)
86 switch args[i] {
87 case "--ref":
88 if i+1 >= len(args) {
89 return c.fail(protocol.ExitUsage, "--ref requires a value")
90 }
91 ref = args[i+1]
92 i++
93 default:
94 rest = append(rest, args[i])
95 }
96 } 86 }
87 rest, ref := f.Pos, f.Value("--ref")
97 if len(rest) != 1 { 88 if len(rest) != 1 {
98 return c.fail(protocol.ExitUsage, "usage: %s", usage) 89 return c.fail(protocol.ExitUsage, "usage: %s", usage)
99 } 90 }
internal/control/flags.go added +101
@@ -0,0 +1,101 @@
1package control
2
3import (
4 "fmt"
5 "strings"
6)
7
8// flagSpec is what a command accepts: flags that take one value, flags
9// that take a value and may repeat, switches, and how many positional
10// arguments are allowed (-1 for any number). Every command used to walk
11// argv by hand and decided on its own whether an unknown --flag was an
12// error, a positional or nothing at all (#96); parseFlags decides once.
13type flagSpec struct {
14 Values []string
15 Multi []string
16 Bools []string
17 MaxPos int
18 Usage string
19}
20
21// flags is a parsed argv: positionals in order, and each flag by name.
22type flags struct {
23 Pos []string
24 vals map[string]string
25 multi map[string][]string
26 seen map[string]bool
27}
28
29// Value is the last value given for a value flag, or "".
30func (f flags) Value(name string) string { return f.vals[name] }
31
32// Has reports whether a flag of any kind was given.
33func (f flags) Has(name string) bool { return f.seen[name] }
34
35// List is every value given for a repeatable flag, in order.
36func (f flags) List(name string) []string { return f.multi[name] }
37
38// parseFlags reads args against spec. A flag must be one the spec names;
39// a value flag consumes the next argument verbatim, "-" included; "--"
40// ends flag parsing. The error, when there is one, is the usage message.
41func parseFlags(args []string, spec flagSpec) (flags, error) {
42 f := flags{vals: map[string]string{}, multi: map[string][]string{}, seen: map[string]bool{}}
43 kind := map[string]byte{}
44 for _, n := range spec.Values {
45 kind[n] = 'v'
46 }
47 for _, n := range spec.Multi {
48 kind[n] = 'm'
49 }
50 for _, n := range spec.Bools {
51 kind[n] = 'b'
52 }
53 usage := func(format string, a ...any) error {
54 msg := fmt.Sprintf(format, a...)
55 if spec.Usage != "" {
56 msg += "\nusage: " + strings.TrimPrefix(spec.Usage, "usage: ")
57 }
58 return fmt.Errorf("%s", msg)
59 }
60 onlyPos := false
61 for i := 0; i < len(args); i++ {
62 a := args[i]
63 if !onlyPos && a == "--" {
64 onlyPos = true
65 continue
66 }
67 if !onlyPos && strings.HasPrefix(a, "--") {
68 switch kind[a] {
69 case 'b':
70 f.seen[a] = true
71 case 'v', 'm':
72 if i+1 >= len(args) {
73 return f, usage("%s requires a value", a)
74 }
75 f.seen[a] = true
76 if kind[a] == 'v' {
77 f.vals[a] = args[i+1]
78 } else {
79 f.multi[a] = append(f.multi[a], args[i+1])
80 }
81 i++
82 default:
83 return f, usage("unknown flag %q", a)
84 }
85 continue
86 }
87 if spec.MaxPos >= 0 && len(f.Pos) >= spec.MaxPos {
88 return f, usage("unexpected argument %q", a)
89 }
90 f.Pos = append(f.Pos, a)
91 }
92 return f, nil
93}
94
95// pos is the nth positional argument, or "" when absent.
96func (f flags) pos(n int) string {
97 if n < len(f.Pos) {
98 return f.Pos[n]
99 }
100 return ""
101}
internal/control/flags_test.go added +33
@@ -0,0 +1,33 @@
1package control
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestParseFlags(t *testing.T) {
9 spec := flagSpec{Values: []string{"--title", "--file"}, Multi: []string{"--add"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "cmd <owner/name> [--title <t>]"}
10 f, err := parseFlags([]string{"a/b", "--title", "-x", "--add", "one", "--private", "--add", "two", "--file", "-"}, spec)
11 if err != nil {
12 t.Fatal(err)
13 }
14 if f.pos(0) != "a/b" || f.Value("--title") != "-x" || f.Value("--file") != "-" || !f.Has("--private") ||
15 strings.Join(f.List("--add"), ",") != "one,two" || f.Has("--nope") || f.pos(1) != "" {
16 t.Fatalf("parsed wrong: %+v", f)
17 }
18 for _, bad := range [][]string{
19 {"a/b", "--bogus"}, // unknown flag
20 {"a/b", "--title"}, // missing value
21 {"a/b", "c/d"}, // too many positionals
22 {"--private", "a/b", "x"}, // too many, flags first
23 } {
24 if _, err := parseFlags(bad, spec); err == nil || !strings.Contains(err.Error(), "usage: cmd") {
25 t.Errorf("%v: err=%v", bad, err)
26 }
27 }
28 // "--" ends flags; MaxPos -1 takes any number.
29 f, err = parseFlags([]string{"--", "--title", "x"}, flagSpec{Values: []string{"--title"}, MaxPos: -1})
30 if err != nil || strings.Join(f.Pos, " ") != "--title x" || f.Has("--title") {
31 t.Fatalf("-- handling: %v %+v", err, f)
32 }
33}
internal/control/ghimport.go +5 −22
@@ -100,29 +100,12 @@ func attribution(src string, n int64, kind, login, date string) string {
100} 100}
101 101
102func runImportIssues(c *Ctx, args []string) int { 102func runImportIssues(c *Ctx, args []string) int {
103 var path, from, apiBase string 103 f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--api-base"}, Bools: []string{"--token-stdin"}, MaxPos: 1,
104 tokenStdin := false 104 Usage: "repo import-issues <owner/name> --from <owner/repo> [--api-base <url>] [--token-stdin]"})
105 for i := 0; i < len(args); i++ { 105 if err != nil {
106 switch args[i] { 106 return c.fail(protocol.ExitUsage, "%v", err)
107 case "--from", "--api-base":
108 if i+1 >= len(args) {
109 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
110 }
111 if args[i] == "--from" {
112 from = args[i+1]
113 } else {
114 apiBase = args[i+1]
115 }
116 i++
117 case "--token-stdin":
118 tokenStdin = true
119 default:
120 if path != "" {
121 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
122 }
123 path = args[i]
124 }
125 } 107 }
108 path, from, apiBase, tokenStdin := f.pos(0), f.Value("--from"), f.Value("--api-base"), f.Has("--token-stdin")
126 if path == "" || from == "" { 109 if path == "" || from == "" {
127 return c.fail(protocol.ExitUsage, "usage: repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin]") 110 return c.fail(protocol.ExitUsage, "usage: repo import-issues <owner/name> --from <ghowner/ghrepo> [--token-stdin]")
128 } 111 }
internal/control/identity.go +6 −11
@@ -81,18 +81,13 @@ func runKeysList(c *Ctx, args []string) int {
81} 81}
82 82
83func runKeysAdd(c *Ctx, args []string) int { 83func runKeysAdd(c *Ctx, args []string) int {
84 f, err := parseFlags(args, flagSpec{Values: []string{"--scope"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] < key.pub"})
85 if err != nil {
86 return c.fail(protocol.ExitUsage, "%v", err)
87 }
84 scope := "full" 88 scope := "full"
85 for i := 0; i < len(args); i++ { 89 if f.Has("--scope") {
86 switch args[i] { 90 scope = f.Value("--scope")
87 case "--scope":
88 if i+1 >= len(args) {
89 return c.fail(protocol.ExitUsage, "--scope requires a value")
90 }
91 scope = args[i+1]
92 i++
93 default:
94 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git|runner] < key.pub")
95 }
96 } 91 }
97 if scope != "full" && scope != "git" && scope != "runner" { 92 if scope != "full" && scope != "git" && scope != "runner" {
98 // deploy:* scopes are granted via repo settings, not self-service. 93 // deploy:* scopes are granted via repo settings, not self-service.
internal/control/import.go +5 −21
@@ -33,28 +33,12 @@ esac
33` 33`
34 34
35func runRepoImport(c *Ctx, args []string) int { 35func runRepoImport(c *Ctx, args []string) int {
36 var path, from string 36 f, err := parseFlags(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1,
37 private := false 37 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"})
38 tokenStdin := false 38 if err != nil {
39 for i := 0; i < len(args); i++ { 39 return c.fail(protocol.ExitUsage, "%v", err)
40 switch args[i] {
41 case "--from":
42 if i+1 >= len(args) {
43 return c.fail(protocol.ExitUsage, "--from requires a URL")
44 }
45 from = args[i+1]
46 i++
47 case "--private":
48 private = true
49 case "--token-stdin":
50 tokenStdin = true
51 default:
52 if path != "" {
53 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
54 }
55 path = args[i]
56 }
57 } 40 }
41 path, from, private, tokenStdin := f.pos(0), f.Value("--from"), f.Has("--private"), f.Has("--token-stdin")
58 if path == "" || from == "" { 42 if path == "" || from == "" {
59 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url> [--private] [--token-stdin]") 43 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url> [--private] [--token-stdin]")
60 } 44 }
internal/control/issue.go +30 −123
@@ -123,40 +123,12 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
123} 123}
124 124
125func runIssueCreate(c *Ctx, args []string) int { 125func runIssueCreate(c *Ctx, args []string) int {
126 var path, title, body, file, format string 126 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
127 for i := 0; i < len(args); i++ { 127 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
128 switch args[i] { 128 if err != nil {
129 case "--format": 129 return c.fail(protocol.ExitUsage, "%v", err)
130 if i+1 >= len(args) {
131 return c.fail(protocol.ExitUsage, "--format requires a value")
132 }
133 format = args[i+1]
134 i++
135 case "--title":
136 if i+1 >= len(args) {
137 return c.fail(protocol.ExitUsage, "--title requires a value")
138 }
139 title = args[i+1]
140 i++
141 case "--body":
142 if i+1 >= len(args) {
143 return c.fail(protocol.ExitUsage, "--body requires a value")
144 }
145 body = args[i+1]
146 i++
147 case "--file":
148 if i+1 >= len(args) {
149 return c.fail(protocol.ExitUsage, "--file requires a value")
150 }
151 file = args[i+1]
152 i++
153 default:
154 if path != "" {
155 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
156 }
157 path = args[i]
158 }
159 } 130 }
131 path, title, body, file, format := f.pos(0), f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
160 if path == "" || title == "" { 132 if path == "" || title == "" {
161 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]") 133 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
162 } 134 }
@@ -200,33 +172,15 @@ func runIssueList(c *Ctx, args []string) int {
200 } 172 }
201 const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]" 173 const usage = "usage: issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
202 f := store.IssueFilter{State: "open"} 174 f := store.IssueFilter{State: "open"}
203 var path string 175 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone"}, MaxPos: 1, Usage: usage})
204 for i := 0; i < len(args); i++ { 176 if err != nil {
205 var target *string 177 return c.fail(protocol.ExitUsage, "%v", err)
206 switch args[i] { 178 }
207 case "--state": 179 path := fl.pos(0)
208 target = &f.State 180 if fl.Has("--state") {
209 case "--label": 181 f.State = fl.Value("--state")
210 target = &f.Label
211 case "--assignee":
212 target = &f.Assignee
213 case "--author":
214 target = &f.Author
215 case "--milestone":
216 target = &f.Milestone
217 default:
218 if path != "" || strings.HasPrefix(args[i], "--") {
219 return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
220 }
221 path = args[i]
222 continue
223 }
224 if i+1 >= len(args) {
225 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
226 }
227 *target = args[i+1]
228 i++
229 } 182 }
183 f.Label, f.Assignee, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--assignee"), fl.Value("--author"), fl.Value("--milestone")
230 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") { 184 if path == "" || (f.State != "open" && f.State != "closed" && f.State != "all") {
231 return c.fail(protocol.ExitUsage, usage) 185 return c.fail(protocol.ExitUsage, usage)
232 } 186 }
@@ -298,32 +252,13 @@ func runIssueShow(c *Ctx, args []string) int {
298} 252}
299 253
300func runIssueComment(c *Ctx, args []string) int { 254func runIssueComment(c *Ctx, args []string) int {
301 var rest []string 255 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--message", "--file"}, MaxPos: -1,
302 var message, file, format string 256 Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
303 for i := 0; i < len(args); i++ { 257 if err != nil {
304 switch args[i] { 258 return c.fail(protocol.ExitUsage, "%v", err)
305 case "--format":
306 if i+1 >= len(args) {
307 return c.fail(protocol.ExitUsage, "--format requires a value")
308 }
309 format = args[i+1]
310 i++
311 case "--message":
312 if i+1 >= len(args) {
313 return c.fail(protocol.ExitUsage, "--message requires a value")
314 }
315 message = args[i+1]
316 i++
317 case "--file":
318 if i+1 >= len(args) {
319 return c.fail(protocol.ExitUsage, "--file requires a value")
320 }
321 file = args[i+1]
322 i++
323 default:
324 rest = append(rest, args[i])
325 }
326 } 259 }
260 rest := f.Pos
261 message, file, format := f.Value("--message"), f.Value("--file"), f.Value("--format")
327 fmtName, err := markupFormat(format) 262 fmtName, err := markupFormat(format)
328 if err != nil { 263 if err != nil {
329 return c.failErr(err) 264 return c.failErr(err)
@@ -398,29 +333,14 @@ func setIssueState(c *Ctx, args []string, state string) int {
398// editText parses --title/--body/--file -/--format and authorizes: author or 333// editText parses --title/--body/--file -/--format and authorizes: author or
399// write. A nil format means the stored markup format stays as it is. 334// write. A nil format means the stored markup format stays as it is.
400func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) { 335func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
401 var titleV, bodyV, file, formatV string 336 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--body", "--file", "--format"}, MaxPos: -1,
402 haveTitle, haveBody := false, false 337 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
403 for i := 0; i < len(args); i++ { 338 if err != nil {
404 switch args[i] { 339 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
405 case "--title", "--body", "--file", "--format":
406 if i+1 >= len(args) {
407 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
408 }
409 switch args[i] {
410 case "--title":
411 titleV, haveTitle = args[i+1], true
412 case "--body":
413 bodyV, haveBody = args[i+1], true
414 case "--file":
415 file = args[i+1]
416 case "--format":
417 formatV = args[i+1]
418 }
419 i++
420 default:
421 rest = append(rest, args[i])
422 }
423 } 340 }
341 rest = f.Pos
342 titleV, bodyV, file, formatV := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
343 haveTitle, haveBody := f.Has("--title"), f.Has("--body")
424 if file != "" { 344 if file != "" {
425 b, err := bodyFrom(c, "", file) 345 b, err := bodyFrom(c, "", file)
426 if err != nil { 346 if err != nil {
@@ -482,24 +402,11 @@ func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "
482 402
483// addRemoveFlags parses repeated --add/--remove flags. 403// addRemoveFlags parses repeated --add/--remove flags.
484func addRemoveFlags(args []string) (rest, adds, removes []string, err error) { 404func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
485 for i := 0; i < len(args); i++ { 405 f, err := parseFlags(args, flagSpec{Multi: []string{"--add", "--remove"}, MaxPos: -1})
486 switch args[i] { 406 if err != nil {
487 case "--add": 407 return nil, nil, nil, err
488 if i+1 >= len(args) {
489 return nil, nil, nil, errors.New("--add requires a value")
490 }
491 adds = append(adds, args[i+1])
492 i++
493 case "--remove":
494 if i+1 >= len(args) {
495 return nil, nil, nil, errors.New("--remove requires a value")
496 }
497 removes = append(removes, args[i+1])
498 i++
499 default:
500 rest = append(rest, args[i])
501 }
502 } 408 }
409 rest, adds, removes = f.Pos, f.List("--add"), f.List("--remove")
503 return rest, adds, removes, nil 410 return rest, adds, removes, nil
504} 411}
505 412
internal/control/label.go +5 −12
@@ -50,19 +50,12 @@ func runLabelList(c *Ctx, args []string) int {
50 50
51func runLabelSet(c *Ctx, args []string) int { 51func runLabelSet(c *Ctx, args []string) int {
52 const usage = "usage: label set <owner/name> <label> [--color #rrggbb|'']" 52 const usage = "usage: label set <owner/name> <label> [--color #rrggbb|'']"
53 var rest []string 53 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: -1, Usage: usage})
54 color, colorSet := "", false 54 if err != nil {
55 for i := 0; i < len(args); i++ { 55 return c.fail(protocol.ExitUsage, "%v", err)
56 if args[i] == "--color" {
57 if i+1 >= len(args) {
58 return c.fail(protocol.ExitUsage, "--color requires rrggbb, or '' to clear")
59 }
60 color, colorSet = strings.ToLower(args[i+1]), true
61 i++
62 continue
63 }
64 rest = append(rest, args[i])
65 } 56 }
57 rest := f.Pos
58 color, colorSet := strings.ToLower(f.Value("--color")), f.Has("--color")
66 if len(rest) != 2 { 59 if len(rest) != 2 {
67 return c.fail(protocol.ExitUsage, usage) 60 return c.fail(protocol.ExitUsage, usage)
68 } 61 }
internal/control/migrate.go +4 −8
@@ -139,15 +139,11 @@ func migAttribution(src, kind, author, date string, n int64) string {
139// the git push that follows cannot be refused by them. Resumable: markers 139// the git push that follows cannot be refused by them. Resumable: markers
140// skip everything already imported. 140// skip everything already imported.
141func runAccountImportBundle(c *Ctx, args []string) int { 141func runAccountImportBundle(c *Ctx, args []string) int {
142 var src string 142 f, err := parseFlags(args, flagSpec{Values: []string{"--source"}, MaxPos: 0, Usage: "account import-bundle [--source <host>] < bundle.json"})
143 for i := 0; i < len(args); i++ { 143 if err != nil {
144 if args[i] == "--source" && i+1 < len(args) { 144 return c.fail(protocol.ExitUsage, "%v", err)
145 src = args[i+1]
146 i++
147 } else {
148 return c.fail(protocol.ExitUsage, "usage: account import-bundle [--source <host>] < bundle.json")
149 }
150 } 145 }
146 src := f.Value("--source")
151 if src == "" { 147 if src == "" {
152 src = "the previous instance" 148 src = "the previous instance"
153 } 149 }
internal/control/milestone.go +12 −36
@@ -41,29 +41,12 @@ func init() {
41var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) 41var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
42 42
43func runMilestoneCreate(c *Ctx, args []string) int { 43func runMilestoneCreate(c *Ctx, args []string) int {
44 var path, title, description, due string 44 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2,
45 for i := 0; i < len(args); i++ { 45 Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"})
46 switch args[i] { 46 if err != nil {
47 case "--description", "--due": 47 return c.fail(protocol.ExitUsage, "%v", err)
48 if i+1 >= len(args) {
49 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
50 }
51 if args[i] == "--description" {
52 description = args[i+1]
53 } else {
54 due = args[i+1]
55 }
56 i++
57 default:
58 if path == "" {
59 path = args[i]
60 } else if title == "" {
61 title = args[i]
62 } else {
63 return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]")
64 }
65 }
66 } 48 }
49 path, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
67 if path == "" || title == "" { 50 if path == "" || title == "" {
68 return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]") 51 return c.fail(protocol.ExitUsage, "usage: milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]")
69 } 52 }
@@ -86,20 +69,13 @@ func runMilestoneCreate(c *Ctx, args []string) int {
86} 69}
87 70
88func runMilestoneList(c *Ctx, args []string) int { 71func runMilestoneList(c *Ctx, args []string) int {
89 state := "open" 72 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
90 var path string 73 if err != nil {
91 for i := 0; i < len(args); i++ { 74 return c.fail(protocol.ExitUsage, "%v", err)
92 if args[i] == "--state" { 75 }
93 if i+1 >= len(args) { 76 state, path := "open", f.pos(0)
94 return c.fail(protocol.ExitUsage, "--state requires open|closed|all") 77 if f.Has("--state") {
95 } 78 state = f.Value("--state")
96 state = args[i+1]
97 i++
98 } else if path == "" {
99 path = args[i]
100 } else {
101 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
102 }
103 } 79 }
104 if path == "" || (state != "open" && state != "closed" && state != "all") { 80 if path == "" || (state != "open" && state != "closed" && state != "all") {
105 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]") 81 return c.fail(protocol.ExitUsage, "usage: milestone list <owner/name> [--state open|closed|all]")
internal/control/mirrorcmd.go +6 −25
@@ -31,32 +31,13 @@ func init() {
31} 31}
32 32
33func runMirrorAdd(c *Ctx, args []string) int { 33func runMirrorAdd(c *Ctx, args []string) int {
34 var path, urlArg, direction, username string 34 f, err := parseFlags(args, flagSpec{Values: []string{"--direction", "--username"}, Bools: []string{"--token-stdin"}, MaxPos: 2,
35 tokenStdin := false 35 Usage: "repo mirror add <owner/name> <url> --direction push|pull [--username <u>] [--token-stdin]"})
36 for i := 0; i < len(args); i++ { 36 if err != nil {
37 switch args[i] { 37 return c.fail(protocol.ExitUsage, "%v", err)
38 case "--direction", "--username":
39 if i+1 >= len(args) {
40 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
41 }
42 if args[i] == "--direction" {
43 direction = args[i+1]
44 } else {
45 username = args[i+1]
46 }
47 i++
48 case "--token-stdin":
49 tokenStdin = true
50 default:
51 if path == "" {
52 path = args[i]
53 } else if urlArg == "" {
54 urlArg = args[i]
55 } else {
56 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
57 }
58 }
59 } 38 }
39 path, urlArg := f.pos(0), f.pos(1)
40 direction, username, tokenStdin := f.Value("--direction"), f.Value("--username"), f.Has("--token-stdin")
60 if path == "" || urlArg == "" || (direction != "push" && direction != "pull") { 41 if path == "" || urlArg == "" || (direction != "push" && direction != "pull") {
61 return c.fail(protocol.ExitUsage, "usage: repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]") 42 return c.fail(protocol.ExitUsage, "usage: repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]")
62 } 43 }
internal/control/mr.go +28 −98
@@ -68,22 +68,11 @@ func init() {
68} 68}
69 69
70func runRepoFork(c *Ctx, args []string) int { 70func runRepoFork(c *Ctx, args []string) int {
71 var path, name string 71 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"})
72 for i := 0; i < len(args); i++ { 72 if err != nil {
73 switch args[i] { 73 return c.fail(protocol.ExitUsage, "%v", err)
74 case "--name":
75 if i+1 >= len(args) {
76 return c.fail(protocol.ExitUsage, "--name requires a value")
77 }
78 name = args[i+1]
79 i++
80 default:
81 if path != "" {
82 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
83 }
84 path = args[i]
85 }
86 } 74 }
75 path, name := f.pos(0), f.Value("--name")
87 if path == "" { 76 if path == "" {
88 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]") 77 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
89 } 78 }
@@ -231,36 +220,13 @@ func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool
231func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } 220func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
232 221
233func runMRCreate(c *Ctx, args []string) int { 222func runMRCreate(c *Ctx, args []string) int {
234 var path, source, target, title, body, file, format string 223 f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"}, MaxPos: 1,
235 for i := 0; i < len(args); i++ { 224 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>"})
236 switch args[i] { 225 if err != nil {
237 case "--source", "--target", "--title", "--body", "--file", "--format": 226 return c.fail(protocol.ExitUsage, "%v", err)
238 if i+1 >= len(args) {
239 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
240 }
241 v := args[i+1]
242 switch args[i] {
243 case "--source":
244 source = v
245 case "--target":
246 target = v
247 case "--title":
248 title = v
249 case "--body":
250 body = v
251 case "--file":
252 file = v
253 case "--format":
254 format = v
255 }
256 i++
257 default:
258 if path != "" {
259 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
260 }
261 path = args[i]
262 }
263 } 227 }
228 path, source, target := f.pos(0), f.Value("--source"), f.Value("--target")
229 title, body, file, format := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
264 if path == "" || source == "" || title == "" { 230 if path == "" || source == "" || title == "" {
265 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>") 231 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
266 } 232 }
@@ -415,29 +381,15 @@ func runMRList(c *Ctx, args []string) int {
415 } 381 }
416 const usage = "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]" 382 const usage = "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
417 f := store.MRFilter{State: "open"} 383 f := store.MRFilter{State: "open"}
418 var path string 384 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--author", "--milestone"}, MaxPos: 1, Usage: usage})
419 for i := 0; i < len(args); i++ { 385 if err != nil {
420 var target *string 386 return c.fail(protocol.ExitUsage, "%v", err)
421 switch args[i] {
422 case "--state":
423 target = &f.State
424 case "--author":
425 target = &f.Author
426 case "--milestone":
427 target = &f.Milestone
428 default:
429 if path != "" || strings.HasPrefix(args[i], "--") {
430 return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
431 }
432 path = args[i]
433 continue
434 }
435 if i+1 >= len(args) {
436 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
437 }
438 *target = args[i+1]
439 i++
440 } 387 }
388 path := fl.pos(0)
389 if fl.Has("--state") {
390 f.State = fl.Value("--state")
391 }
392 f.Author, f.Milestone = fl.Value("--author"), fl.Value("--milestone")
441 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} 393 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
442 if path == "" || !valid[f.State] { 394 if path == "" || !valid[f.State] {
443 return c.fail(protocol.ExitUsage, usage) 395 return c.fail(protocol.ExitUsage, usage)
@@ -728,27 +680,13 @@ func runMRRetarget(c *Ctx, args []string) int {
728} 680}
729 681
730func runMRComment(c *Ctx, args []string) int { 682func runMRComment(c *Ctx, args []string) int {
731 var rest []string 683 f, err := parseFlags(args, flagSpec{Values: []string{"--message", "--file", "--format"}, MaxPos: -1,
732 var message, file, format string 684 Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
733 for i := 0; i < len(args); i++ { 685 if err != nil {
734 switch args[i] { 686 return c.fail(protocol.ExitUsage, "%v", err)
735 case "--message", "--file", "--format":
736 if i+1 >= len(args) {
737 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
738 }
739 switch args[i] {
740 case "--message":
741 message = args[i+1]
742 case "--file":
743 file = args[i+1]
744 case "--format":
745 format = args[i+1]
746 }
747 i++
748 default:
749 rest = append(rest, args[i])
750 }
751 } 687 }
688 rest := f.Pos
689 message, file, format := f.Value("--message"), f.Value("--file"), f.Value("--format")
752 fmtName, err := markupFormat(format) 690 fmtName, err := markupFormat(format)
753 if err != nil { 691 if err != nil {
754 return c.failErr(err) 692 return c.failErr(err)
@@ -824,19 +762,11 @@ func runMRReview(c *Ctx, args []string) int {
824} 762}
825 763
826func runMRMerge(c *Ctx, args []string) int { 764func runMRMerge(c *Ctx, args []string) int {
827 strategy := "" 765 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
828 var rest []string 766 if err != nil {
829 for i := 0; i < len(args); i++ { 767 return c.fail(protocol.ExitUsage, "%v", err)
830 if args[i] == "--strategy" {
831 if i+1 >= len(args) {
832 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
833 }
834 strategy = args[i+1]
835 i++
836 continue
837 }
838 rest = append(rest, args[i])
839 } 768 }
769 strategy, rest := f.Value("--strategy"), f.Pos
840 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true} 770 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
841 if !valid[strategy] { 771 if !valid[strategy] {
842 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") 772 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
internal/control/org.go +7 −12
@@ -192,18 +192,13 @@ func runOrgDelete(c *Ctx, args []string) int {
192} 192}
193 193
194func runOrgMembersAdd(c *Ctx, args []string) int { 194func runOrgMembersAdd(c *Ctx, args []string) int {
195 role := "member" 195 f, err := parseFlags(args, flagSpec{Values: []string{"--role"}, MaxPos: -1, Usage: "org members add <org> <user> [--role member|admin]"})
196 var rest []string 196 if err != nil {
197 for i := 0; i < len(args); i++ { 197 return c.fail(protocol.ExitUsage, "%v", err)
198 if args[i] == "--role" { 198 }
199 if i+1 >= len(args) { 199 role, rest := "member", f.Pos
200 return c.fail(protocol.ExitUsage, "--role requires member|admin") 200 if f.Has("--role") {
201 } 201 role = f.Value("--role")
202 role = args[i+1]
203 i++
204 continue
205 }
206 rest = append(rest, args[i])
207 } 202 }
208 if len(rest) != 2 || (role != "member" && role != "admin") { 203 if len(rest) != 2 || (role != "member" && role != "admin") {
209 return c.fail(protocol.ExitUsage, "usage: org members add <org> <user> [--role member|admin]") 204 return c.fail(protocol.ExitUsage, "usage: org members add <org> <user> [--role member|admin]")
internal/control/profile.go +36 −34
@@ -52,41 +52,43 @@ func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err
52 about, file := "", "" 52 about, file := "", ""
53 sawAbout := false 53 sawAbout := false
54 var links []store.ProfileLink 54 var links []store.ProfileLink
55 for i := 0; i < len(args); i++ { 55 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--website", "--about", "--about-format", "--file"}, Multi: []string{"--link"}, MaxPos: -1})
56 switch args[i] { 56 if err != nil {
57 case "--description", "--website", "--about", "--about-format", "--file", "--link": 57 return nil, e, err
58 if i+1 >= len(args) { 58 }
59 return nil, e, fmt.Errorf("%s requires a value", args[i]) 59 rest = f.Pos
60 } 60 for _, name := range []string{"--description", "--website", "--about-format"} {
61 v := args[i+1] 61 if !f.Has(name) {
62 switch args[i] { 62 continue
63 case "--description": 63 }
64 e.Description = &v 64 v := f.Value(name)
65 case "--website": 65 switch name {
66 e.Website = &v 66 case "--description":
67 case "--about": 67 e.Description = &v
68 about, sawAbout = v, true 68 case "--website":
69 case "--about-format": 69 e.Website = &v
70 e.AboutFormat = &v 70 case "--about-format":
71 case "--file": 71 e.AboutFormat = &v
72 file, sawAbout = v, true 72 }
73 case "--link": 73 }
74 if v == "" { 74 if f.Has("--about") {
75 links = nil 75 about, sawAbout = f.Value("--about"), true
76 e.Links = &links 76 }
77 break 77 if f.Has("--file") {
78 } 78 file, sawAbout = f.Value("--file"), true
79 l, lerr := parseProfileLink(v) 79 }
80 if lerr != nil { 80 for _, v := range f.List("--link") {
81 return nil, e, lerr 81 if v == "" {
82 } 82 links = nil
83 links = append(links, l) 83 e.Links = &links
84 e.Links = &links 84 continue
85 } 85 }
86 i++ 86 l, lerr := parseProfileLink(v)
87 default: 87 if lerr != nil {
88 rest = append(rest, args[i]) 88 return nil, e, lerr
89 } 89 }
90 links = append(links, l)
91 e.Links = &links
90 } 92 }
91 if sawAbout { 93 if sawAbout {
92 body, berr := bodyFrom(c, about, file) 94 body, berr := bodyFrom(c, about, file)
internal/control/read.go +17 −45
@@ -94,36 +94,21 @@ const BlameSpan = 1000
94 94
95func runRepoBlame(c *Ctx, args []string) int { 95func runRepoBlame(c *Ctx, args []string) int {
96 const usage = "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]" 96 const usage = "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]"
97 var rest []string 97 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: usage})
98 var ref string 98 if err != nil {
99 return c.fail(protocol.ExitUsage, "%v", err)
100 }
101 rest, ref := f.Pos, f.Value("--ref")
99 from, to := 0, 0 102 from, to := 0, 0
100 for i := 0; i < len(args); i++ { 103 for name, dst := range map[string]*int{"--from": &from, "--to": &to} {
101 switch args[i] { 104 if !f.Has(name) {
102 case "--ref", "--from", "--to": 105 continue
103 if i+1 >= len(args) { 106 }
104 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 107 n, err := strconv.Atoi(f.Value(name))
105 } 108 if err != nil || n < 1 {
106 v := args[i+1] 109 return c.fail(protocol.ExitUsage, "%s must be a positive line number", name)
107 if args[i] == "--ref" {
108 ref = v
109 } else {
110 n, err := strconv.Atoi(v)
111 if err != nil || n < 1 {
112 return c.fail(protocol.ExitUsage, "%s must be a positive line number", args[i])
113 }
114 if args[i] == "--from" {
115 from = n
116 } else {
117 to = n
118 }
119 }
120 i++
121 default:
122 if strings.HasPrefix(args[i], "--") {
123 return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
124 }
125 rest = append(rest, args[i])
126 } 110 }
111 *dst = n
127 } 112 }
128 if len(rest) != 2 { 113 if len(rest) != 2 {
129 return c.fail(protocol.ExitUsage, "usage: %s", usage) 114 return c.fail(protocol.ExitUsage, "usage: %s", usage)
@@ -219,24 +204,11 @@ func runRepoBlame(c *Ctx, args []string) int {
219// off argv. Positionals are returned in order so each command can name them 204// off argv. Positionals are returned in order so each command can name them
220// in its own usage message. 205// in its own usage message.
221func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) { 206func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
222 for i := 0; i < len(args); i++ { 207 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: usage})
223 switch args[i] { 208 if err != nil {
224 case "--ref": 209 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
225 if i+1 >= len(args) {
226 return nil, "", c.fail(protocol.ExitUsage, "--ref requires a value")
227 }
228 ref = args[i+1]
229 i++
230 default:
231 if strings.HasPrefix(args[i], "--") {
232 return nil, "", c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
233 }
234 if len(pos) >= maxPos {
235 return nil, "", c.fail(protocol.ExitUsage, "usage: %s", usage)
236 }
237 pos = append(pos, args[i])
238 }
239 } 210 }
211 pos, ref = f.Pos, f.Value("--ref")
240 return pos, ref, -1 212 return pos, ref, -1
241} 213}
242 214
internal/control/register.go +6 −22
@@ -120,29 +120,13 @@ func runEmailVerify(c *Ctx, args []string) int {
120// that registration is enabled and that argv[0] == "register". 120// that registration is enabled and that argv[0] == "register".
121func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string, 121func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
122 stdout, stderr io.Writer) int { 122 stdout, stderr io.Writer) int {
123 var username, email, invite string 123 f, err := parseFlags(argv[1:], flagSpec{Values: []string{"--username", "--email", "--invite"}, MaxPos: 0,
124 args := argv[1:] 124 Usage: "register --username <n> --email <a> | --invite <code>"})
125 for i := 0; i < len(args); i++ { 125 if err != nil {
126 switch args[i] { 126 fmt.Fprintln(stderr, err)
127 case "--username", "--email", "--invite": 127 return protocol.ExitUsage
128 if i+1 >= len(args) {
129 fmt.Fprintf(stderr, "%s requires a value\n", args[i])
130 return protocol.ExitUsage
131 }
132 switch args[i] {
133 case "--username":
134 username = args[i+1]
135 case "--email":
136 email = args[i+1]
137 case "--invite":
138 invite = args[i+1]
139 }
140 i++
141 default:
142 fmt.Fprintf(stderr, "unexpected argument %q\n", args[i])
143 return protocol.ExitUsage
144 }
145 } 128 }
129 username, email, invite := f.Value("--username"), f.Value("--email"), f.Value("--invite")
146 fail := func(code int, format string, a ...any) int { 130 fail := func(code int, format string, a ...any) int {
147 fmt.Fprintf(stderr, format+"\n", a...) 131 fmt.Fprintf(stderr, format+"\n", a...)
148 return code 132 return code
internal/control/release.go +11 −55
@@ -77,34 +77,12 @@ func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string)
77 77
78func runReleaseCreate(c *Ctx, args []string) int { 78func runReleaseCreate(c *Ctx, args []string) int {
79 const usage = "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]" 79 const usage = "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
80 var path, tag, title, notes, file, format string 80 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: usage})
81 for i := 0; i < len(args); i++ { 81 if err != nil {
82 switch args[i] { 82 return c.fail(protocol.ExitUsage, "%v", err)
83 case "--title", "--notes", "--file", "--format":
84 if i+1 >= len(args) {
85 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
86 }
87 switch args[i] {
88 case "--title":
89 title = args[i+1]
90 case "--notes":
91 notes = args[i+1]
92 case "--file":
93 file = args[i+1]
94 case "--format":
95 format = args[i+1]
96 }
97 i++
98 default:
99 if path == "" {
100 path = args[i]
101 } else if tag == "" {
102 tag = args[i]
103 } else {
104 return c.fail(protocol.ExitUsage, usage)
105 }
106 }
107 } 83 }
84 path, tag := f.pos(0), f.pos(1)
85 title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
108 if path == "" || tag == "" { 86 if path == "" || tag == "" {
109 return c.fail(protocol.ExitUsage, usage) 87 return c.fail(protocol.ExitUsage, usage)
110 } 88 }
@@ -172,35 +150,13 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut {
172 150
173func runReleaseEdit(c *Ctx, args []string) int { 151func runReleaseEdit(c *Ctx, args []string) int {
174 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]" 152 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
175 var path, tag, title, notes, file, format string 153 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: usage})
176 var setTitle, setNotes bool 154 if err != nil {
177 for i := 0; i < len(args); i++ { 155 return c.fail(protocol.ExitUsage, "%v", err)
178 switch args[i] {
179 case "--title", "--notes", "--file", "--format":
180 if i+1 >= len(args) {
181 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
182 }
183 switch args[i] {
184 case "--title":
185 title, setTitle = args[i+1], true
186 case "--notes":
187 notes, setNotes = args[i+1], true
188 case "--file":
189 file, setNotes = args[i+1], true
190 case "--format":
191 format = args[i+1]
192 }
193 i++
194 default:
195 if path == "" {
196 path = args[i]
197 } else if tag == "" {
198 tag = args[i]
199 } else {
200 return c.fail(protocol.ExitUsage, usage)
201 }
202 }
203 } 156 }
157 path, tag := f.pos(0), f.pos(1)
158 title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
159 setTitle, setNotes := f.Has("--title"), f.Has("--notes") || f.Has("--file")
204 fmtName, err := markupFormat(format) 160 fmtName, err := markupFormat(format)
205 if err != nil { 161 if err != nil {
206 return c.failErr(err) 162 return c.failErr(err)
internal/control/repo.go +11 −36
@@ -146,24 +146,13 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string)
146} 146}
147 147
148func runRepoCreate(c *Ctx, args []string) int { 148func runRepoCreate(c *Ctx, args []string) int {
149 visibility := "public" 149 f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
150 var path, description string 150 if err != nil {
151 for i := 0; i < len(args); i++ { 151 return c.fail(protocol.ExitUsage, "%v", err)
152 switch args[i] { 152 }
153 case "--private": 153 visibility, path, description := "public", f.pos(0), f.Value("--description")
154 visibility = "private" 154 if f.Has("--private") {
155 case "--description": 155 visibility = "private"
156 if i+1 >= len(args) {
157 return c.fail(protocol.ExitUsage, "--description requires a value")
158 }
159 description = args[i+1]
160 i++
161 default:
162 if path != "" {
163 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
164 }
165 path = args[i]
166 }
167 } 156 }
168 owner, name, ok := strings.Cut(path, "/") 157 owner, name, ok := strings.Cut(path, "/")
169 if !ok { 158 if !ok {
@@ -810,25 +799,11 @@ func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
810} 799}
811 800
812func runRepoGrep(c *Ctx, args []string) int { 801func runRepoGrep(c *Ctx, args []string) int {
813 var path, query, ref string 802 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
814 for i := 0; i < len(args); i++ { 803 if err != nil {
815 switch args[i] { 804 return c.fail(protocol.ExitUsage, "%v", err)
816 case "--ref":
817 if i+1 >= len(args) {
818 return c.fail(protocol.ExitUsage, "--ref requires a value")
819 }
820 ref = args[i+1]
821 i++
822 default:
823 if path == "" {
824 path = args[i]
825 } else if query == "" {
826 query = args[i]
827 } else {
828 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
829 }
830 }
831 } 805 }
806 path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
832 if path == "" || query == "" { 807 if path == "" || query == "" {
833 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]") 808 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
834 } 809 }
internal/control/sig.go +10 −31
@@ -127,38 +127,17 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh
127} 127}
128 128
129func runRepoLog(c *Ctx, args []string) int { 129func runRepoLog(c *Ctx, args []string) int {
130 limit := 30 130 f, perr := parseFlags(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
131 var path, filePath, ref string 131 if perr != nil {
132 for i := 0; i < len(args); i++ { 132 return c.fail(protocol.ExitUsage, "%v", perr)
133 switch args[i] { 133 }
134 case "--ref": 134 limit, path, filePath, ref := 30, f.pos(0), f.Value("--path"), f.Value("--ref")
135 if i+1 >= len(args) { 135 if f.Has("--limit") {
136 return c.fail(protocol.ExitUsage, "--ref requires a value") 136 n, err := strconv.Atoi(f.Value("--limit"))
137 } 137 if err != nil || n < 1 || n > 1000 {
138 ref = args[i+1] 138 return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
139 i++
140 case "--limit":
141 if i+1 >= len(args) {
142 return c.fail(protocol.ExitUsage, "--limit requires a value")
143 }
144 n, err := strconv.Atoi(args[i+1])
145 if err != nil || n < 1 || n > 1000 {
146 return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
147 }
148 limit = n
149 i++
150 case "--path":
151 if i+1 >= len(args) {
152 return c.fail(protocol.ExitUsage, "--path requires a value")
153 }
154 filePath = args[i+1]
155 i++
156 default:
157 if path != "" {
158 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
159 }
160 path = args[i]
161 } 139 }
140 limit = n
162 } 141 }
163 if path == "" { 142 if path == "" {
164 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]") 143 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
internal/control/token.go +7 −19
@@ -39,25 +39,13 @@ func parseTTL(s string) (time.Duration, error) {
39} 39}
40 40
41func runTokenCreate(c *Ctx, args []string) int { 41func runTokenCreate(c *Ctx, args []string) int {
42 name, scope, ttl := "", "full", "" 42 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: "token create --name <n> [--scope full|read] [--ttl 30d]"})
43 for i := 0; i < len(args); i++ { 43 if err != nil {
44 switch args[i] { 44 return c.fail(protocol.ExitUsage, "%v", err)
45 case "--name", "--scope", "--ttl": 45 }
46 if i+1 >= len(args) { 46 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl")
47 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 47 if f.Has("--scope") {
48 } 48 scope = f.Value("--scope")
49 switch args[i] {
50 case "--name":
51 name = args[i+1]
52 case "--scope":
53 scope = args[i+1]
54 case "--ttl":
55 ttl = args[i+1]
56 }
57 i++
58 default:
59 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
60 }
61 } 49 }
62 if name == "" || (scope != "full" && scope != "read") { 50 if name == "" || (scope != "full" && scope != "read") {
63 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]") 51 return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
internal/control/webhook.go +17 −41
@@ -31,29 +31,13 @@ func init() {
31} 31}
32 32
33func runWebhookAdd(c *Ctx, args []string) int { 33func runWebhookAdd(c *Ctx, args []string) int {
34 var path, url, secret string 34 f, err := parseFlags(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
35 events := "*" 35 if err != nil {
36 for i := 0; i < len(args); i++ { 36 return c.fail(protocol.ExitUsage, "%v", err)
37 switch args[i] { 37 }
38 case "--secret", "--events": 38 path, url, secret, events := f.pos(0), f.pos(1), f.Value("--secret"), "*"
39 if i+1 >= len(args) { 39 if f.Has("--events") {
40 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 40 events = f.Value("--events")
41 }
42 if args[i] == "--secret" {
43 secret = args[i+1]
44 } else {
45 events = args[i+1]
46 }
47 i++
48 default:
49 if path == "" {
50 path = args[i]
51 } else if url == "" {
52 url = args[i]
53 } else {
54 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
55 }
56 }
57 } 41 }
58 if path == "" || url == "" { 42 if path == "" || url == "" {
59 return c.fail(protocol.ExitUsage, "usage: webhook add <owner/name> <url> [--secret <s>] [--events <k1,k2>|*]") 43 return c.fail(protocol.ExitUsage, "usage: webhook add <owner/name> <url> [--secret <s>] [--events <k1,k2>|*]")
@@ -128,25 +112,17 @@ func runWebhookRemove(c *Ctx, args []string) int {
128} 112}
129 113
130func runWebhookDeliveries(c *Ctx, args []string) int { 114func runWebhookDeliveries(c *Ctx, args []string) int {
131 limit := 20 115 f, err := parseFlags(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"})
132 var path string 116 if err != nil {
133 for i := 0; i < len(args); i++ { 117 return c.fail(protocol.ExitUsage, "%v", err)
134 if args[i] == "--limit" { 118 }
135 if i+1 >= len(args) { 119 limit, path := 20, f.pos(0)
136 return c.fail(protocol.ExitUsage, "--limit requires a value") 120 if f.Has("--limit") {
137 } 121 n, err := strconv.Atoi(f.Value("--limit"))
138 n, err := strconv.Atoi(args[i+1]) 122 if err != nil || n < 1 || n > 200 {
139 if err != nil || n < 1 || n > 200 { 123 return c.fail(protocol.ExitUsage, "--limit must be 1..200")
140 return c.fail(protocol.ExitUsage, "--limit must be 1..200")
141 }
142 limit = n
143 i++
144 continue
145 }
146 if path != "" {
147 return c.fail(protocol.ExitUsage, "usage: webhook deliveries <owner/name> [--limit n]")
148 } 124 }
149 path = args[i] 125 limit = n
150 } 126 }
151 if path == "" { 127 if path == "" {
152 return c.fail(protocol.ExitUsage, "usage: webhook deliveries <owner/name> [--limit n]") 128 return c.fail(protocol.ExitUsage, "usage: webhook deliveries <owner/name> [--limit n]")