internal/control/status.go

169 lines · 5794 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strings"
  7
  8	"gitbay.org/gitbay/internal/gitutil"
  9	"gitbay.org/gitbay/internal/policy"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14func init() {
 15	register(Command{Path: []string{"status", "set"},
 16		Summary: "report a commit status (CI)",
 17		Usage:   "status set <owner/name> <sha> --context <c> --state pending|success|failure|error [--description <d>] [--url <u>]",
 18		Flags: []Flag{
 19			{"--context", "<c>", "the check this status reports for; ci/ is reserved for the instance's builds", ""},
 20			{"--state", "pending|success|failure|error", "the check's outcome", ""},
 21			{"--description", "<d>", "short text shown beside the state", ""},
 22			{"--url", "<u>", "link to the check's own output", ""},
 23		},
 24		Examples: []string{
 25			"status set krz/gitbay a1b2c3d --context ext/lint --state success",
 26		},
 27		Run: runStatusSet})
 28	register(Command{Path: []string{"status", "list"},
 29		Summary:  "statuses on a commit",
 30		Usage:    "status list <owner/name> <sha>",
 31		Examples: []string{"status list krz/gitbay a1b2c3d"},
 32		ReadOnly: true, Run: runStatusList})
 33}
 34
 35var validStatusState = map[string]bool{"pending": true, "success": true, "failure": true, "error": true}
 36
 37func runStatusSet(c *Ctx, args []string) int {
 38	var path, sha, context, state, description, url string
 39	rest := args
 40	for i := 0; i < len(rest); i++ {
 41		switch rest[i] {
 42		case "--context", "--state", "--description", "--url":
 43			if i+1 >= len(rest) {
 44				return c.fail(protocol.ExitUsage, "%s requires a value", rest[i])
 45			}
 46			v := rest[i+1]
 47			switch rest[i] {
 48			case "--context":
 49				context = v
 50			case "--state":
 51				state = v
 52			case "--description":
 53				description = v
 54			case "--url":
 55				url = v
 56			}
 57			i++
 58		default:
 59			if path == "" {
 60				path = rest[i]
 61			} else if sha == "" {
 62				sha = rest[i]
 63			} else {
 64				return c.fail(protocol.ExitUsage, "unexpected argument %q", rest[i])
 65			}
 66		}
 67	}
 68	if path == "" || sha == "" || context == "" || !validStatusState[state] {
 69		return c.usage()
 70	}
 71	// ci/<job> statuses are the build subsystem's: queued, reused,
 72	// skipped and finished by the server itself. A writer who could post
 73	// one could mark ci/test green on their own head before, or instead
 74	// of, the build (#258). Case-folded, so CI/test is no way around it.
 75	if strings.HasPrefix(strings.ToLower(context), "ci/") {
 76		return c.fail(protocol.ExitDenied, "the ci/ prefix is reserved for the instance's builds; report under another name, such as ext/%s",
 77			strings.TrimPrefix(strings.ToLower(context), "ci/"))
 78	}
 79	if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
 80		return c.fail(protocol.ExitUsage, "--url must be http(s)")
 81	}
 82	// Reporting a status is a write: CI identities need write access (an
 83	// API token with full scope, or an account grant).
 84	repo, code := resolveRepo(c, path, policy.CanWrite)
 85	if code >= 0 {
 86		return code
 87	}
 88	if code := refuseArchived(c, repo); code >= 0 {
 89		return code
 90	}
 91	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 92	full, err := gitutil.ResolveRef(dir, sha)
 93	if err != nil {
 94		return c.fail(protocol.ExitNotFound, "no commit %s in %s", sha, repo.Path())
 95	}
 96	if err := c.Store.SetCommitStatus(repo.ID, full, context, state, description, url, c.User.ID); err != nil {
 97		return c.fail(protocol.ExitFailure, "%v", err)
 98	}
 99	c.Store.RecordEvent(repo.ID, c.User.ID, "status",
100		fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state))
101	TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full)
102	return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) {
103		fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state)
104	})
105}
106
107func runStatusList(c *Ctx, args []string) int {
108	if len(args) != 2 {
109		return c.usage()
110	}
111	repo, code := resolveRepo(c, args[0], policy.CanRead)
112	if code >= 0 {
113		return code
114	}
115	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
116	full, err := gitutil.ResolveRef(dir, args[1])
117	if err != nil {
118		return c.fail(protocol.ExitNotFound, "no commit %s in %s", args[1], repo.Path())
119	}
120	statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
121	if err != nil {
122		return c.fail(protocol.ExitFailure, "%v", err)
123	}
124	type out struct {
125		Context     string `json:"context"`
126		State       string `json:"state"`
127		Description string `json:"description,omitempty"`
128		URL         string `json:"url,omitempty"`
129		Creator     string `json:"creator,omitempty"`
130	}
131	var ds []out
132	for _, s := range statuses {
133		ds = append(ds, out{s.Context, s.State, s.Description, s.TargetURL, s.Creator})
134	}
135	d := struct {
136		SHA      string `json:"sha"`
137		Combined string `json:"combined"`
138		Statuses []out  `json:"statuses"`
139	}{full, combinedOf(statuses), ds}
140	return c.emitView(d, func(w io.Writer) {
141		tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION")
142		tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA)))
143		for _, x := range ds {
144			tb.row(cText(x.Context), cState(x.State), cFlex(x.Description))
145		}
146		tb.flush()
147	}, func() screen {
148		combined := []cell{cGlyph(d.Combined), cState(orNone(d.Combined))}
149		statuses := section{title: "Statuses", n: len(ds)}
150		for _, x := range ds {
151			statuses.rows = append(statuses.rows, rowOf(cGlyph(x.State), cText(x.Context), cFlex(x.Description), cMeta(x.Creator)))
152		}
153		return screen{fields: []field{
154			{"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA))}},
155			{"Combined", combined},
156		}, sections: []section{statuses}, actions: []action{
157			{"Read", []string{"repo", "commit", repo.Path(), fmt.Sprintf("%.10s", d.SHA)}},
158		}}
159	})
160}
161
162func combinedOf(statuses []store.CommitStatus) string { return store.CombinedStatus(statuses) }
163
164func orNone(s string) string {
165	if s == "" {
166		return "no statuses"
167	}
168	return s
169}