Commit bc49092e23

bc49092e2314bc25c40ede89ec5a744f5cc6fec8

parent: ffcb3af61a

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

cmc <hello@cleberg.net> · 2026-09-03 23:59 UTC

control: a store failure is a failure, not a usage error

Fifty-seven sites reported any error from a store or helper call as a
usage error, so a SQLite I/O failure was exit 2 over ssh and HTTP 400
over the API, and a not-found from the store was a usage error too.
failErr maps not-found to exit 3, a database or I/O failure to exit 1
(store.IsInternal), and everything else, which is the caller's input
or a state that refuses the change, to exit 2 as before.

Closes #107

Layout: unified · split

internal/control/admin.go +1 −1
@@ -310,7 +310,7 @@ func setAdmin(c *Ctx, args []string, admin bool) int {
310310 }
311311 if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
312312 if errors.Is(err, store.ErrLastAdmin) {
313 return c.fail(protocol.ExitUsage, "%v", err)
313 return c.failErr(err)
314314 }
315315 return c.fail(protocol.ExitFailure, "%v", err)
316316 }
internal/control/adminhost.go +5 −5
@@ -87,7 +87,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
8787 return c.fail(protocol.ExitUsage, usage)
8888 }
8989 if err := policy.ValidateOwnerName(username); err != nil {
90 return c.fail(protocol.ExitUsage, "%v", err)
90 return c.failErr(err)
9191 }
9292 // Parse the key before creating anything, so a bad key leaves no
9393 // half-made account behind.
@@ -103,7 +103,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
103103 }
104104 uid, err := c.Store.CreateUser(username, isAdmin)
105105 if err != nil {
106 return c.fail(protocol.ExitUsage, "%v", err)
106 return c.failErr(err)
107107 }
108108 if email != "" {
109109 by := ""
@@ -111,14 +111,14 @@ func runAdminUserCreate(c *Ctx, args []string) int {
111111 by = "admin"
112112 }
113113 if err := c.Store.AddEmail(uid, email, by, true); err != nil {
114 return c.fail(protocol.ExitUsage, "%v", err)
114 return c.failErr(err)
115115 }
116116 }
117117 fp := ""
118118 if pub != nil {
119119 fp = ssh.FingerprintSHA256(pub)
120120 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
121 return c.fail(protocol.ExitUsage, "%v", err)
121 return c.failErr(err)
122122 }
123123 }
124124 c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username})
@@ -201,7 +201,7 @@ func runAdminUserDelete(c *Ctx, args []string) int {
201201 return c.fail(protocol.ExitUsage, "that is your own account")
202202 }
203203 if err := c.Store.DeleteUser(u.ID); err != nil {
204 return c.fail(protocol.ExitUsage, "%v", err)
204 return c.failErr(err)
205205 }
206206 c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username})
207207 return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) {
internal/control/build.go +1 −1
@@ -173,7 +173,7 @@ func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
173173 }
174174 jobs, err := ci.Parse(raw)
175175 if err != nil {
176 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
176 return nil, "", c.failErr(err)
177177 }
178178 return jobs, sha, -1
179179}
internal/control/control.go +17
@@ -5,6 +5,7 @@ package control
55
66import (
77 "encoding/json"
8 "errors"
89 "fmt"
910 "io"
1011 "reflect"
@@ -164,6 +165,22 @@ func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
164165 return protocol.ExitOK
165166}
166167
168// failErr reports an error from a store or helper call with the exit code
169// its kind deserves: not-found is not-found, the database or I/O failing
170// is a failure, and anything else is the caller's mistake, which is what
171// most such errors are (a name that does not validate, a state that does
172// not allow the change). A SQLite I/O error used to be a usage error and
173// an HTTP 400 (#107).
174func (c *Ctx) failErr(err error) int {
175 switch {
176 case errors.Is(err, store.ErrNotFound):
177 return c.fail(protocol.ExitNotFound, "%v", err)
178 case store.IsInternal(err):
179 return c.fail(protocol.ExitFailure, "%v", err)
180 }
181 return c.fail(protocol.ExitUsage, "%v", err)
182}
183
167184func (c *Ctx) fail(code int, format string, args ...any) int {
168185 msg := fmt.Sprintf(format, args...)
169186 if c.JSON {
internal/control/control_test.go +24
@@ -3,7 +3,10 @@ package control
33import (
44 "bytes"
55 "encoding/json"
6 "errors"
7 "fmt"
68 "io"
9 "io/fs"
710 "slices"
811 "strings"
912 "testing"
@@ -223,3 +226,24 @@ func TestRefusalsHonourJSON(t *testing.T) {
223226 }
224227 }
225228}
229
230// TestFailErrExitCodes: not-found, an internal failure, and the caller's
231// mistake each get their own exit code (#107).
232func TestFailErrExitCodes(t *testing.T) {
233 code := func(err error) int {
234 c := &Ctx{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
235 return c.failErr(err)
236 }
237 if got := code(store.ErrNotFound); got != protocol.ExitNotFound {
238 t.Errorf("not found: %d", got)
239 }
240 if got := code(fmt.Errorf("looking up: %w", store.ErrNotFound)); got != protocol.ExitNotFound {
241 t.Errorf("wrapped not found: %d", got)
242 }
243 if got := code(errors.New("name must be lowercase")); got != protocol.ExitUsage {
244 t.Errorf("caller's mistake: %d", got)
245 }
246 if got := code(&fs.PathError{Op: "open", Path: "/x", Err: fs.ErrPermission}); got != protocol.ExitFailure {
247 t.Errorf("i/o failure: %d", got)
248 }
249}
internal/control/deploykey.go +1 −1
@@ -58,7 +58,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
5858 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
5959 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
6060 if errors.Is(err, store.ErrDuplicateKey) {
61 return c.fail(protocol.ExitUsage, "%v", err)
61 return c.failErr(err)
6262 }
6363 return c.fail(protocol.ExitFailure, "%v", err)
6464 }
internal/control/diffcomment.go +2 −2
@@ -81,7 +81,7 @@ func runDiffComment(c *Ctx, args []string) int {
8181 }
8282 body, err := bodyFrom(c, message, file)
8383 if err != nil {
84 return c.fail(protocol.ExitUsage, "%v", err)
84 return c.failErr(err)
8585 }
8686 if strings.TrimSpace(body) == "" {
8787 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
@@ -116,7 +116,7 @@ func runDiffComment(c *Ctx, args []string) int {
116116 if errors.Is(err, store.ErrNotFound) {
117117 return c.fail(protocol.ExitNotFound, "%v", err)
118118 }
119 return c.fail(protocol.ExitUsage, "%v", err)
119 return c.failErr(err)
120120 }
121121 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
122122 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
internal/control/identity.go +1 −1
@@ -109,7 +109,7 @@ func runKeysAdd(c *Ctx, args []string) int {
109109 fp := ssh.FingerprintSHA256(pub)
110110 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
111111 if errors.Is(err, store.ErrDuplicateKey) {
112 return c.fail(protocol.ExitUsage, "%v", err)
112 return c.failErr(err)
113113 }
114114 return c.fail(protocol.ExitFailure, "adding key: %v", err)
115115 }
internal/control/import.go +1 −1
@@ -63,7 +63,7 @@ func runRepoImport(c *Ctx, args []string) int {
6363 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url>")
6464 }
6565 if err := policy.ValidateName(name); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err)
66 return c.failErr(err)
6767 }
6868 // Same ownership rule as repo create: yourself, or an org you admin.
6969 ownerKind, ownerID := "user", c.User.ID
internal/control/issue.go +8 −8
@@ -162,7 +162,7 @@ func runIssueCreate(c *Ctx, args []string) int {
162162 }
163163 fmtName, err := markupFormat(format)
164164 if err != nil {
165 return c.fail(protocol.ExitUsage, "%v", err)
165 return c.failErr(err)
166166 }
167167 if fmtName == "" {
168168 fmtName = "md"
@@ -177,7 +177,7 @@ func runIssueCreate(c *Ctx, args []string) int {
177177 }
178178 b, err := bodyFrom(c, body, file)
179179 if err != nil {
180 return c.fail(protocol.ExitUsage, "%v", err)
180 return c.failErr(err)
181181 }
182182 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
183183 if err != nil {
@@ -326,7 +326,7 @@ func runIssueComment(c *Ctx, args []string) int {
326326 }
327327 fmtName, err := markupFormat(format)
328328 if err != nil {
329 return c.fail(protocol.ExitUsage, "%v", err)
329 return c.failErr(err)
330330 }
331331 if fmtName == "" {
332332 fmtName = "md"
@@ -340,7 +340,7 @@ func runIssueComment(c *Ctx, args []string) int {
340340 }
341341 body, err := bodyFrom(c, message, file)
342342 if err != nil {
343 return c.fail(protocol.ExitUsage, "%v", err)
343 return c.failErr(err)
344344 }
345345 if strings.TrimSpace(body) == "" {
346346 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
@@ -424,13 +424,13 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body, f
424424 if file != "" {
425425 b, err := bodyFrom(c, "", file)
426426 if err != nil {
427 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
427 return nil, nil, nil, nil, c.failErr(err)
428428 }
429429 bodyV, haveBody = b, true
430430 }
431431 fmtName, err := markupFormat(formatV)
432432 if err != nil {
433 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
433 return nil, nil, nil, nil, c.failErr(err)
434434 }
435435 if !haveTitle && !haveBody && fmtName == "" {
436436 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
@@ -506,7 +506,7 @@ func addRemoveFlags(args []string) (rest, adds, removes []string, err error) {
506506func runIssueLabel(c *Ctx, args []string) int {
507507 rest, adds, removes, err := addRemoveFlags(args)
508508 if err != nil {
509 return c.fail(protocol.ExitUsage, "%v", err)
509 return c.failErr(err)
510510 }
511511 if len(adds)+len(removes) == 0 {
512512 return c.fail(protocol.ExitUsage, "usage: issue label <owner/name> <n> [--add <l>]... [--remove <l>]...")
@@ -543,7 +543,7 @@ func runIssueLabel(c *Ctx, args []string) int {
543543func runIssueAssign(c *Ctx, args []string) int {
544544 rest, adds, removes, err := addRemoveFlags(args)
545545 if err != nil {
546 return c.fail(protocol.ExitUsage, "%v", err)
546 return c.failErr(err)
547547 }
548548 if len(adds)+len(removes) == 0 {
549549 return c.fail(protocol.ExitUsage, "usage: issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...")
internal/control/milestone.go +1 −1
@@ -78,7 +78,7 @@ func runMilestoneCreate(c *Ctx, args []string) int {
7878 return code
7979 }
8080 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil {
81 return c.fail(protocol.ExitUsage, "%v", err)
81 return c.failErr(err)
8282 }
8383 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
8484 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
internal/control/mirrorcmd.go +1 −1
@@ -63,7 +63,7 @@ func runMirrorAdd(c *Ctx, args []string) int {
6363 // The worker's git process dials this URL from the server: same SSRF
6464 // surface as a webhook target, same rules.
6565 if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err)
66 return c.failErr(err)
6767 }
6868 repo, code := resolveRepo(c, path, policy.CanAdmin)
6969 if code >= 0 {
internal/control/mr.go +5 −5
@@ -95,7 +95,7 @@ func runRepoFork(c *Ctx, args []string) int {
9595 name = src.Name
9696 }
9797 if err := policy.ValidateName(name); err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err)
98 return c.failErr(err)
9999 }
100100 if code := checkRepoQuota(c); code >= 0 {
101101 return code
@@ -266,7 +266,7 @@ func runMRCreate(c *Ctx, args []string) int {
266266 }
267267 fmtName, err := markupFormat(format)
268268 if err != nil {
269 return c.fail(protocol.ExitUsage, "%v", err)
269 return c.failErr(err)
270270 }
271271 if fmtName == "" {
272272 fmtName = "md"
@@ -303,7 +303,7 @@ func runMRCreate(c *Ctx, args []string) int {
303303 }
304304 b, err := bodyFrom(c, body, file)
305305 if err != nil {
306 return c.fail(protocol.ExitUsage, "%v", err)
306 return c.failErr(err)
307307 }
308308 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
309309 if err != nil {
@@ -751,7 +751,7 @@ func runMRComment(c *Ctx, args []string) int {
751751 }
752752 fmtName, err := markupFormat(format)
753753 if err != nil {
754 return c.fail(protocol.ExitUsage, "%v", err)
754 return c.failErr(err)
755755 }
756756 if fmtName == "" {
757757 fmtName = "md"
@@ -765,7 +765,7 @@ func runMRComment(c *Ctx, args []string) int {
765765 }
766766 body, err := bodyFrom(c, message, file)
767767 if err != nil {
768 return c.fail(protocol.ExitUsage, "%v", err)
768 return c.failErr(err)
769769 }
770770 if strings.TrimSpace(body) == "" {
771771 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
internal/control/org.go +5 −5
@@ -63,7 +63,7 @@ func runOrgCreate(c *Ctx, args []string) int {
6363 return c.fail(protocol.ExitUsage, "usage: org create <name>")
6464 }
6565 if err := policy.ValidateOwnerName(args[0]); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err)
66 return c.failErr(err)
6767 }
6868 if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil {
6969 return c.fail(protocol.ExitFailure, "%v", err)
@@ -138,7 +138,7 @@ func runOrgRename(c *Ctx, args []string) int {
138138 }
139139 newName := args[1]
140140 if err := policy.ValidateOwnerName(newName); err != nil {
141 return c.fail(protocol.ExitUsage, "%v", err)
141 return c.failErr(err)
142142 }
143143 oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name)
144144 newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName)
@@ -146,7 +146,7 @@ func runOrgRename(c *Ctx, args []string) int {
146146 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName)
147147 }
148148 if err := c.Store.RenameOrg(org.ID, newName); err != nil {
149 return c.fail(protocol.ExitUsage, "%v", err)
149 return c.failErr(err)
150150 }
151151 // Repo paths on disk derive from the owner name; move the tree. If the
152152 // move fails, revert the database so name and disk stay consistent.
@@ -220,7 +220,7 @@ func runOrgMembersAdd(c *Ctx, args []string) int {
220220 return c.fail(protocol.ExitFailure, "%v", err)
221221 }
222222 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil {
223 return c.fail(protocol.ExitUsage, "%v", err)
223 return c.failErr(err)
224224 }
225225 return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) {
226226 fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name)
@@ -246,7 +246,7 @@ func runOrgMembersRemove(c *Ctx, args []string) int {
246246 if errors.Is(err, store.ErrNotFound) {
247247 return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name)
248248 }
249 return c.fail(protocol.ExitUsage, "%v", err)
249 return c.failErr(err)
250250 }
251251 return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) {
252252 fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name)
internal/control/pagescmd.go +1 −1
@@ -74,7 +74,7 @@ func runDomainAdd(c *Ctx, args []string) int {
7474 }
7575 domain := strings.ToLower(args[1])
7676 if err := validatePageDomain(c, domain); err != nil {
77 return c.fail(protocol.ExitUsage, "%v", err)
77 return c.failErr(err)
7878 }
7979 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
8080 if code >= 0 {
internal/control/profile.go +4 −4
@@ -345,7 +345,7 @@ func runProfileShow(c *Ctx, args []string) int {
345345func runProfileSet(c *Ctx, args []string) int {
346346 rest, e, err := parseProfileFlags(c, args)
347347 if err != nil {
348 return c.fail(protocol.ExitUsage, "%v", err)
348 return c.failErr(err)
349349 }
350350 if len(rest) != 0 {
351351 return c.fail(protocol.ExitUsage,
@@ -360,7 +360,7 @@ func runProfileSet(c *Ctx, args []string) int {
360360 }
361361 p, err = applyProfile(p, e)
362362 if err != nil {
363 return c.fail(protocol.ExitUsage, "%v", err)
363 return c.failErr(err)
364364 }
365365 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
366366 return c.fail(protocol.ExitFailure, "%v", err)
@@ -373,7 +373,7 @@ func runProfileSet(c *Ctx, args []string) int {
373373func runOrgProfile(c *Ctx, args []string) int {
374374 rest, e, err := parseProfileFlags(c, args)
375375 if err != nil {
376 return c.fail(protocol.ExitUsage, "%v", err)
376 return c.failErr(err)
377377 }
378378 if len(rest) != 1 {
379379 return c.fail(protocol.ExitUsage,
@@ -393,7 +393,7 @@ func runOrgProfile(c *Ctx, args []string) int {
393393 }
394394 p, err = applyProfile(p, e)
395395 if err != nil {
396 return c.fail(protocol.ExitUsage, "%v", err)
396 return c.failErr(err)
397397 }
398398 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
399399 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/release.go +6 −6
@@ -110,7 +110,7 @@ func runReleaseCreate(c *Ctx, args []string) int {
110110 }
111111 fmtName, err := markupFormat(format)
112112 if err != nil {
113 return c.fail(protocol.ExitUsage, "%v", err)
113 return c.failErr(err)
114114 }
115115 if fmtName == "" {
116116 fmtName = "md"
@@ -128,13 +128,13 @@ func runReleaseCreate(c *Ctx, args []string) int {
128128 }
129129 body, err := bodyFrom(c, notes, file)
130130 if err != nil {
131 return c.fail(protocol.ExitUsage, "%v", err)
131 return c.failErr(err)
132132 }
133133 if title == "" {
134134 title = tag
135135 }
136136 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
137 return c.fail(protocol.ExitUsage, "%v", err)
137 return c.failErr(err)
138138 }
139139 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
140140 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
@@ -203,7 +203,7 @@ func runReleaseEdit(c *Ctx, args []string) int {
203203 }
204204 fmtName, err := markupFormat(format)
205205 if err != nil {
206 return c.fail(protocol.ExitUsage, "%v", err)
206 return c.failErr(err)
207207 }
208208 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
209209 return c.fail(protocol.ExitUsage, usage)
@@ -228,7 +228,7 @@ func runReleaseEdit(c *Ctx, args []string) int {
228228 body := rel.Notes
229229 if setNotes {
230230 if body, err = bodyFrom(c, notes, file); err != nil {
231 return c.fail(protocol.ExitUsage, "%v", err)
231 return c.failErr(err)
232232 }
233233 }
234234 if fmtName == "" {
@@ -349,7 +349,7 @@ func runAssetAdd(c *Ctx, args []string) int {
349349 }
350350 sum := hex.EncodeToString(h.Sum(nil))
351351 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
352 return c.fail(protocol.ExitUsage, "%v", err)
352 return c.failErr(err)
353353 }
354354 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
355355 c.Store.RemoveReleaseAsset(rel.ID, name)
internal/control/repo.go +6 −6
@@ -170,7 +170,7 @@ func runRepoCreate(c *Ctx, args []string) int {
170170 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
171171 }
172172 if err := policyValidateRepoName(name); err != nil {
173 return c.fail(protocol.ExitUsage, "%v", err)
173 return c.failErr(err)
174174 }
175175 ownerKind, ownerID := "user", c.User.ID
176176 if owner != c.User.Username {
@@ -382,7 +382,7 @@ func runRepoTransfer(c *Ctx, args []string) int {
382382 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
383383 }
384384 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
385 return c.fail(protocol.ExitUsage, "%v", err)
385 return c.failErr(err)
386386 }
387387 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
388388 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
@@ -554,7 +554,7 @@ func runSetWebsite(c *Ctx, args []string) int {
554554 }
555555 site := strings.TrimSpace(args[1])
556556 if err := validateWebsite(site); err != nil {
557 return c.fail(protocol.ExitUsage, "%v", err)
557 return c.failErr(err)
558558 }
559559 if len(site) > 256 {
560560 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
@@ -710,7 +710,7 @@ func editTopics(c *Ctx, args []string, add bool) int {
710710 if add {
711711 for _, t := range topics {
712712 if err := policy.ValidateTopic(t); err != nil {
713 return c.fail(protocol.ExitUsage, "%v", err)
713 return c.failErr(err)
714714 }
715715 }
716716 have, err := c.Store.ListTopics(repo.ID)
@@ -757,7 +757,7 @@ func runRepoSearch(c *Ctx, args []string) int {
757757 return c.fail(protocol.ExitUsage, "usage: repo search <query>")
758758 }
759759 if err := validQuery(args[0]); err != nil {
760 return c.fail(protocol.ExitUsage, "%v", err)
760 return c.failErr(err)
761761 }
762762 q := strings.ToLower(args[0])
763763
@@ -833,7 +833,7 @@ func runRepoGrep(c *Ctx, args []string) int {
833833 return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
834834 }
835835 if err := validQuery(query); err != nil {
836 return c.fail(protocol.ExitUsage, "%v", err)
836 return c.failErr(err)
837837 }
838838 repo, code := resolveRepo(c, path, policy.CanRead)
839839 if code >= 0 {
internal/control/sig.go +2 −2
@@ -45,12 +45,12 @@ func runPGPAdd(c *Ctx, args []string) int {
4545 }
4646 meta, err := sig.ParsePGPKey(raw)
4747 if err != nil {
48 return c.fail(protocol.ExitUsage, "%v", err)
48 return c.failErr(err)
4949 }
5050 uids, _ := json.Marshal(meta.Emails)
5151 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
5252 if errors.Is(err, store.ErrDuplicateKey) {
53 return c.fail(protocol.ExitUsage, "%v", err)
53 return c.failErr(err)
5454 }
5555 return c.fail(protocol.ExitFailure, "adding key: %v", err)
5656 }
internal/control/teams.go +2 −2
@@ -94,10 +94,10 @@ func runTeamCreate(c *Ctx, args []string) int {
9494 return code
9595 }
9696 if err := policy.ValidateName(args[1]); err != nil {
97 return c.fail(protocol.ExitUsage, "%v", err)
97 return c.failErr(err)
9898 }
9999 if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil {
100 return c.fail(protocol.ExitUsage, "%v", err)
100 return c.failErr(err)
101101 }
102102 return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) {
103103 fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1])
internal/control/token.go +2 −2
@@ -66,7 +66,7 @@ func runTokenCreate(c *Ctx, args []string) int {
6666 if ttl != "" {
6767 d, err := parseTTL(ttl)
6868 if err != nil {
69 return c.fail(protocol.ExitUsage, "%v", err)
69 return c.failErr(err)
7070 }
7171 t := time.Now().Add(d)
7272 expires = &t
@@ -78,7 +78,7 @@ func runTokenCreate(c *Ctx, args []string) int {
7878 // The gb_ prefix makes leaked tokens findable by secret scanners.
7979 token := "gb_" + raw
8080 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
81 return c.fail(protocol.ExitUsage, "%v", err)
81 return c.failErr(err)
8282 }
8383 type out struct {
8484 Name string `json:"name"`
internal/control/webhook.go +1 −1
@@ -63,7 +63,7 @@ func runWebhookAdd(c *Ctx, args []string) int {
6363 return code
6464 }
6565 if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil {
66 return c.fail(protocol.ExitUsage, "%v", err)
66 return c.failErr(err)
6767 }
6868 id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
6969 if err != nil {
internal/store/isinternal_test.go added +22
@@ -0,0 +1,22 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestIsInternal(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 _, err := s.DB.Exec("INSERT INTO no_such_table (x) VALUES (1)")
14 if err == nil || !IsInternal(err) {
15 t.Errorf("a SQLite error is internal: %v", err)
16 }
17 for _, e := range []error{ErrNotFound, ErrExists, ErrDuplicateKey, errors.New("name must be lowercase")} {
18 if IsInternal(e) {
19 t.Errorf("%v is not internal", e)
20 }
21 }
22}
internal/store/store.go +13 −1
@@ -2,6 +2,7 @@
22package store
33
44import (
5 "context"
56 "database/sql"
67 "embed"
78 "errors"
@@ -12,7 +13,7 @@ import (
1213 "strconv"
1314 "strings"
1415
15 _ "modernc.org/sqlite"
16 "modernc.org/sqlite"
1617)
1718
1819//go:embed migrations/*.sql
@@ -177,3 +178,14 @@ func (s *Store) migrateTo(target int) error {
177178 }
178179 return nil
179180}
181
182// IsInternal reports whether err is the database or the I/O beneath it
183// failing, as opposed to a sentinel or a message about the caller's
184// input. Callers map it to a failure exit rather than a usage error.
185func IsInternal(err error) bool {
186 var sqlErr *sqlite.Error
187 var pathErr *fs.PathError
188 return errors.As(err, &sqlErr) || errors.As(err, &pathErr) ||
189 errors.Is(err, sql.ErrTxDone) || errors.Is(err, sql.ErrConnDone) ||
190 errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
191}