CLI output: terminal tables !468
47 files changed, +1312 −184
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | 19 | func main() { |
| 20 | os.Args, noColor = stripNoColor(os.Args) | |
| 20 | 21 | if err := newRoot().Execute(); err != nil { |
| 21 | 22 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| 22 | 23 | os.Exit(protocol.ExitUsage) |
cmd/gitbay/ssh.go +43 −41
| @@ -1,8 +1,6 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "text/tabwriter" | |
| 5 | ||
| 6 | 4 | "encoding/json" |
| 7 | 5 | "fmt" |
| 8 | 6 | "golang.org/x/term" |
| @@ -11,6 +9,7 @@ import ( | ||
| 11 | 9 | "os/exec" |
| 12 | 10 | "path/filepath" |
| 13 | 11 | "regexp" |
| 12 | "slices" | |
| 14 | 13 | "strconv" |
| 15 | 14 | "strings" |
| 16 | 15 | |
| @@ -109,10 +108,52 @@ func sshArgs(inst cliconfig.Instance) []string { | ||
| 109 | 108 | return append(args, inst.SSHOptions...) |
| 110 | 109 | } |
| 111 | 110 | |
| 111 | // noColor is --no-color, stripped from argv in main. | |
| 112 | var noColor bool | |
| 113 | ||
| 114 | // termValue is GITBAY_TERM for this invocation: the terminal's width, | |
| 115 | // and whether colour is wanted. Empty when stdout is not a terminal, | |
| 116 | // so piped output stays the rows stock ssh prints. | |
| 117 | func termValue(isTerminal bool, cols int, env func(string) string) string { | |
| 118 | if !isTerminal || cols < 40 { | |
| 119 | return "" | |
| 120 | } | |
| 121 | v := strconv.Itoa(cols) | |
| 122 | if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" { | |
| 123 | v += ",color" | |
| 124 | } | |
| 125 | return v | |
| 126 | } | |
| 127 | ||
| 128 | // stripNoColor removes --no-color wherever it appears. | |
| 129 | func stripNoColor(args []string) ([]string, bool) { | |
| 130 | out := args[:0:0] | |
| 131 | found := false | |
| 132 | for _, a := range args { | |
| 133 | if a == "--no-color" { | |
| 134 | found = true | |
| 135 | continue | |
| 136 | } | |
| 137 | out = append(out, a) | |
| 138 | } | |
| 139 | return out, found | |
| 140 | } | |
| 141 | ||
| 112 | 142 | // runSSH executes the server command over the system ssh binary, wiring |
| 113 | 143 | // stdio through. It returns the remote exit code. |
| 114 | 144 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { |
| 115 | 145 | args := sshArgs(t.inst) |
| 146 | ||
| 147 | fd := int(os.Stdout.Fd()) | |
| 148 | cols := 0 | |
| 149 | isTTY := term.IsTerminal(fd) | |
| 150 | if isTTY { | |
| 151 | cols, _, _ = term.GetSize(fd) | |
| 152 | } | |
| 153 | if v := termValue(isTTY, cols, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") { | |
| 154 | serverArgv = append([]string{"--term=" + v}, serverArgv...) | |
| 155 | } | |
| 156 | ||
| 116 | 157 | quoted := make([]string, len(serverArgv)) |
| 117 | 158 | for i, a := range serverArgv { |
| 118 | 159 | quoted[i] = shellQuote(a) |
| @@ -123,15 +164,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | ||
| 123 | 164 | cmd.Stdin = stdin |
| 124 | 165 | cmd.Stdout = os.Stdout |
| 125 | 166 | cmd.Stderr = os.Stderr |
| 126 | var tw *tabwriter.Writer | |
| 127 | if alignColumns(serverArgv) { | |
| 128 | tw = tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0) | |
| 129 | cmd.Stdout = tw | |
| 130 | } | |
| 131 | 167 | err := cmd.Run() |
| 132 | if tw != nil { | |
| 133 | tw.Flush() | |
| 134 | } | |
| 135 | 168 | if err == nil { |
| 136 | 169 | return 0 |
| 137 | 170 | } |
| @@ -204,34 +237,3 @@ func withRepo(t target, args []string) ([]string, error) { | ||
| 204 | 237 | } |
| 205 | 238 | return append([]string{t.repo}, args...), nil |
| 206 | 239 | } |
| 207 | ||
| 208 | // listVerbs are the server commands whose plain output is one row per | |
| 209 | // item with tab-separated columns. | |
| 210 | var listVerbs = map[string]bool{ | |
| 211 | "list": true, "runners": true, "deliveries": true, "refs": true, | |
| 212 | "revisions": true, "threads": true, "bookmarks": true, "jobs": true, | |
| 213 | "prune": true, | |
| 214 | } | |
| 215 | ||
| 216 | // alignColumns reports whether a command's rows should be padded into | |
| 217 | // columns: a list command, printed for a person at a terminal. Piped | |
| 218 | // output keeps the server's tabs so cut and awk see the same bytes stock | |
| 219 | // ssh prints, and --json is never touched. | |
| 220 | func alignColumns(serverArgv []string) bool { | |
| 221 | verb := "" | |
| 222 | for _, a := range serverArgv { | |
| 223 | if strings.HasPrefix(a, "-") { | |
| 224 | break | |
| 225 | } | |
| 226 | if a == "--json" { | |
| 227 | return false | |
| 228 | } | |
| 229 | verb = a | |
| 230 | } | |
| 231 | for _, a := range serverArgv { | |
| 232 | if a == "--json" { | |
| 233 | return false | |
| 234 | } | |
| 235 | } | |
| 236 | return listVerbs[verb] && term.IsTerminal(int(os.Stdout.Fd())) | |
| 237 | } | |
cmd/gitbay/term_test.go added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func TestTermValue(t *testing.T) { | |
| 6 | env := func(m map[string]string) func(string) string { | |
| 7 | return func(k string) string { return m[k] } | |
| 8 | } | |
| 9 | cases := []struct { | |
| 10 | tty bool | |
| 11 | cols int | |
| 12 | env map[string]string | |
| 13 | noColor bool | |
| 14 | want string | |
| 15 | }{ | |
| 16 | {true, 120, nil, false, "120,color"}, | |
| 17 | {false, 120, nil, false, ""}, | |
| 18 | {true, 30, nil, false, ""}, | |
| 19 | {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"}, | |
| 20 | {true, 120, map[string]string{"TERM": "dumb"}, false, "120"}, | |
| 21 | {true, 120, nil, true, "120"}, | |
| 22 | } | |
| 23 | for _, c := range cases { | |
| 24 | noColor = c.noColor | |
| 25 | if got := termValue(c.tty, c.cols, env(c.env)); got != c.want { | |
| 26 | t.Errorf("%+v: got %q", c, got) | |
| 27 | } | |
| 28 | } | |
| 29 | noColor = false | |
| 30 | } | |
| 31 | ||
| 32 | func TestStripNoColor(t *testing.T) { | |
| 33 | args, ok := stripNoColor([]string{"gitbay", "issue", "list", "--no-color", "--state", "all"}) | |
| 34 | if !ok || len(args) != 5 || args[3] != "--state" { | |
| 35 | t.Errorf("got %v %v", args, ok) | |
| 36 | } | |
| 37 | } | |
cmd/gitbayd/system.go +2 −1
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "golang.org/x/crypto/ssh" |
| 9 | 9 | |
| 10 | 10 | "gitbay.org/gitbay/internal/config" |
| 11 | "gitbay.org/gitbay/internal/control" | |
| 11 | 12 | "gitbay.org/gitbay/internal/protocol" |
| 12 | 13 | "gitbay.org/gitbay/internal/sshd" |
| 13 | 14 | ) |
| @@ -93,7 +94,7 @@ func shellCmd() *cobra.Command { | ||
| 93 | 94 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") |
| 94 | 95 | os.Exit(protocol.ExitUsage) |
| 95 | 96 | } |
| 96 | code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil) | |
| 97 | code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil) | |
| 97 | 98 | st.Close() |
| 98 | 99 | os.Exit(code) |
| 99 | 100 | return nil |
e2e/readonly_test.go +55
| @@ -10,7 +10,9 @@ import ( | ||
| 10 | 10 | "regexp" |
| 11 | 11 | "strings" |
| 12 | 12 | "testing" |
| 13 | "unicode" | |
| 13 | 14 | |
| 15 | "golang.org/x/text/width" | |
| 14 | 16 | _ "modernc.org/sqlite" |
| 15 | 17 | |
| 16 | 18 | "gitbay.org/gitbay/internal/control" |
| @@ -168,6 +170,20 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 168 | 170 | } |
| 169 | 171 | // Reads whose subject legitimately does not exist in this fixture. |
| 170 | 172 | notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true} |
| 173 | // rawOutput prints content verbatim (a file, a log, a diff) and is | |
| 174 | // not fitted to the terminal. | |
| 175 | rawOutput := map[string]bool{ | |
| 176 | "repo download": true, | |
| 177 | "account export": true, | |
| 178 | "admin user show": true, // until the view layout (Part 3) | |
| 179 | "admin runners": true, // until the view layout (Part 3): the queue summary line | |
| 180 | "admin stats": true, // until the view layout (Part 3) | |
| 181 | "repo deps status": true, // until the view layout (Part 3) | |
| 182 | "release show": true, // until the view layout (Part 3) | |
| 183 | } | |
| 184 | // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not | |
| 185 | // an SGR sequence escaping into plain output. | |
| 186 | binaryOutput := map[string]bool{"repo download": true} | |
| 171 | 187 | |
| 172 | 188 | dbPath := filepath.Join(inst.root, "gitbay.db") |
| 173 | 189 | before := dbFingerprint(t, dbPath) |
| @@ -197,7 +213,46 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 197 | 213 | } |
| 198 | 214 | } |
| 199 | 215 | before = after |
| 216 | ||
| 217 | argv := append(append([]string{}, cmd.Path...), args...) | |
| 218 | plainOut, plainErrOut, plainCode := inst.sshTerm(t, aliceKey, "", argv...) | |
| 219 | if plainCode != 0 && !(plainCode == 3 && notFoundOK[path]) { | |
| 220 | t.Errorf("%s: --term= plain exit %d: %s", path, plainCode, strings.TrimSpace(plainErrOut)) | |
| 221 | } | |
| 222 | if !binaryOutput[path] && strings.Contains(plainOut, "\x1b") { | |
| 223 | t.Errorf("%s: SGR bytes in plain output", path) | |
| 224 | } | |
| 225 | termOut, termErrOut, termCode := inst.sshTerm(t, aliceKey, "60,color", argv...) | |
| 226 | if termCode != 0 && !(termCode == 3 && notFoundOK[path]) { | |
| 227 | t.Errorf("%s: --term=60,color exit %d: %s", path, termCode, strings.TrimSpace(termErrOut)) | |
| 228 | } | |
| 229 | if !rawOutput[path] { | |
| 230 | for _, line := range strings.Split(termOut, "\n") { | |
| 231 | if w := displayCells(stripSGRe2e(line)); w > 60 { | |
| 232 | t.Errorf("%s: line of %d cells at 60 columns: %q", path, w, line) | |
| 233 | break | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | func stripSGRe2e(s string) string { | |
| 241 | return regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(s, "") | |
| 242 | } | |
| 243 | ||
| 244 | func displayCells(s string) int { | |
| 245 | n := 0 | |
| 246 | for _, r := range s { | |
| 247 | switch { | |
| 248 | case unicode.In(r, unicode.Mn, unicode.Me): | |
| 249 | case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth: | |
| 250 | n += 2 | |
| 251 | default: | |
| 252 | n++ | |
| 253 | } | |
| 200 | 254 | } |
| 255 | return n | |
| 201 | 256 | } |
| 202 | 257 | |
| 203 | 258 | // dbFingerprint hashes every row of every table, per table. Columns that |
e2e/ssh_test.go +24
| @@ -195,6 +195,30 @@ func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) ( | ||
| 195 | 195 | return out.String(), errOut.String(), code |
| 196 | 196 | } |
| 197 | 197 | |
| 198 | // sshTerm is ssh with a leading --term=<v> on the command line, as the | |
| 199 | // CLI sends it at a terminal: OpenSSH's ControlMaster does not forward a | |
| 200 | // new session's SetEnv, so the term travels in argv instead. An empty | |
| 201 | // term sends nothing. | |
| 202 | func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int) { | |
| 203 | t.Helper() | |
| 204 | if term != "" { | |
| 205 | // "--" stops the local ssh client from parsing --term=... as one of | |
| 206 | // its own options; it is not part of the remote command line. | |
| 207 | args = append([]string{"--", "--term=" + term}, args...) | |
| 208 | } | |
| 209 | cmd := i.sshCmd(key, args...) | |
| 210 | var out, errOut strings.Builder | |
| 211 | cmd.Stdout, cmd.Stderr = &out, &errOut | |
| 212 | err := cmd.Run() | |
| 213 | code := 0 | |
| 214 | if ee, ok := err.(*exec.ExitError); ok { | |
| 215 | code = ee.ExitCode() | |
| 216 | } else if err != nil { | |
| 217 | t.Fatalf("ssh: %v", err) | |
| 218 | } | |
| 219 | return out.String(), errOut.String(), code | |
| 220 | } | |
| 221 | ||
| 198 | 222 | func TestControlPlaneOverBareSSH(t *testing.T) { |
| 199 | 223 | t.Parallel() |
| 200 | 224 | inst := startInstance(t) |
e2e/term_test.go added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "os/exec" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // GITBAY_TERM selects terminal output per session. Stock ssh without it | |
| 12 | // gets the plain rows scripts read. | |
| 13 | func TestTermEnvSelectsTerminalOutput(t *testing.T) { | |
| 14 | t.Parallel() | |
| 15 | inst := startInstance(t) | |
| 16 | key := inst.newKey(t, "alice") | |
| 17 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", | |
| 18 | "--email", "alice@example.test", "--verified") | |
| 19 | if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 { | |
| 20 | t.Fatalf("repo create: %d %s", code, errOut) | |
| 21 | } | |
| 22 | ||
| 23 | plain, _, _ := inst.sshTerm(t, key, "", "repo", "list") | |
| 24 | if strings.Contains(plain, "PATH") || !strings.Contains(plain, "alice/app\t") { | |
| 25 | t.Errorf("plain repo list: %q", plain) | |
| 26 | } | |
| 27 | term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list") | |
| 28 | if !strings.HasPrefix(term, "\x1b[2mPATH") { | |
| 29 | t.Errorf("terminal repo list: %q", term) | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | // The CLI shares one connection per instance. Each session's terminal | |
| 34 | // selection must reach the server on its own, not the one the master | |
| 35 | // session was opened with — which is why it travels as a leading | |
| 36 | // --term=<v> argument rather than SetEnv: OpenSSH's mux client does not | |
| 37 | // forward a new session's SetEnv onto an existing ControlMaster. | |
| 38 | func TestTermEnvOverMultiplexedSession(t *testing.T) { | |
| 39 | t.Parallel() | |
| 40 | inst := startInstance(t) | |
| 41 | key := inst.newKey(t, "alice") | |
| 42 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", | |
| 43 | "--email", "alice@example.test", "--verified") | |
| 44 | inst.ssh(t, key, "", "repo", "create", "alice/app") | |
| 45 | ||
| 46 | dir, err := os.MkdirTemp("", "gbmux") | |
| 47 | if err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | t.Cleanup(func() { os.RemoveAll(dir) }) | |
| 51 | sock := filepath.Join(dir, "cm") | |
| 52 | mux := func(term string) string { | |
| 53 | t.Helper() | |
| 54 | cmdArgs := []string{"repo", "list"} | |
| 55 | if term != "" { | |
| 56 | // "--" stops the local ssh client from parsing --term=... as one | |
| 57 | // of its own options; it is not part of the remote command line. | |
| 58 | cmdArgs = append([]string{"--", "--term=" + term}, cmdArgs...) | |
| 59 | } | |
| 60 | cmd := inst.sshCmd(key, cmdArgs...) | |
| 61 | opts := []string{"-o", "ControlMaster=auto", "-o", "ControlPath=" + sock, "-o", "ControlPersist=30"} | |
| 62 | for j, a := range cmd.Args { | |
| 63 | if a == "git@127.0.0.1" { | |
| 64 | cmd.Args = append(cmd.Args[:j:j], append(opts, cmd.Args[j:]...)...) | |
| 65 | break | |
| 66 | } | |
| 67 | } | |
| 68 | out, err := cmd.Output() | |
| 69 | if err != nil { | |
| 70 | t.Fatalf("ssh %s: %v", term, err) | |
| 71 | } | |
| 72 | return string(out) | |
| 73 | } | |
| 74 | t.Cleanup(func() { | |
| 75 | exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run() | |
| 76 | }) | |
| 77 | ||
| 78 | if out := mux("80"); !strings.HasPrefix(out, "PATH") { | |
| 79 | t.Fatalf("master session: %q", out) | |
| 80 | } | |
| 81 | if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2mPATH") { | |
| 82 | t.Errorf("second session kept the master's GITBAY_TERM: %q", out) | |
| 83 | } | |
| 84 | if out := mux(""); strings.Contains(out, "PATH") { | |
| 85 | t.Errorf("session without GITBAY_TERM got terminal output: %q", out) | |
| 86 | } | |
| 87 | } | |
go.mod +1 −1
| @@ -16,6 +16,7 @@ require ( | ||
| 16 | 16 | golang.org/x/image v0.46.0 |
| 17 | 17 | golang.org/x/net v0.59.0 |
| 18 | 18 | golang.org/x/term v0.46.0 |
| 19 | golang.org/x/text v0.42.0 | |
| 19 | 20 | modernc.org/sqlite v1.59.0 |
| 20 | 21 | ) |
| 21 | 22 | |
| @@ -34,7 +35,6 @@ require ( | ||
| 34 | 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 35 | 36 | github.com/spf13/pflag v1.0.9 // indirect |
| 36 | 37 | golang.org/x/sys v0.48.0 // indirect |
| 37 | golang.org/x/text v0.42.0 // indirect | |
| 38 | 38 | modernc.org/libc v1.75.7 // indirect |
| 39 | 39 | modernc.org/mathutil v1.7.1 // indirect |
| 40 | 40 | modernc.org/memory v1.12.1 // indirect |
internal/control/admin.go +33 −15
| @@ -128,13 +128,15 @@ func runAdminUserList(c *Ctx, args []string) int { | ||
| 128 | 128 | ds = append(ds, adminUserRow(u)) |
| 129 | 129 | } |
| 130 | 130 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 131 | tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN") | |
| 131 | 132 | for _, d := range ds { |
| 132 | 133 | mark := "" |
| 133 | 134 | if d.Admin { |
| 134 | 135 | mark = "admin" |
| 135 | 136 | } |
| 136 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen) | |
| 137 | tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen)) | |
| 137 | 138 | } |
| 139 | tb.flush() | |
| 138 | 140 | }) |
| 139 | 141 | } |
| 140 | 142 | |
| @@ -257,37 +259,47 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 257 | 259 | } |
| 258 | 260 | fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions) |
| 259 | 261 | fmt.Fprintln(w, "keys:") |
| 262 | tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") | |
| 260 | 263 | for _, k := range d.Keys { |
| 261 | fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt) | |
| 264 | tk.row(cRef(" "+k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt)) | |
| 262 | 265 | } |
| 266 | tk.flush() | |
| 263 | 267 | fmt.Fprintln(w, "emails:") |
| 268 | te := c.table(w, "ADDRESS", "STATE") | |
| 264 | 269 | for _, e := range d.Emails { |
| 265 | 270 | state := "unverified" |
| 266 | 271 | if e.Verified { |
| 267 | 272 | state = "verified by " + e.VerifiedBy |
| 268 | 273 | } |
| 269 | mark := "" | |
| 274 | cells := []cell{cRef(" " + e.Address), cState(state)} | |
| 270 | 275 | if e.Primary { |
| 271 | mark = "\tprimary" | |
| 276 | cells = append(cells, cText("primary")) | |
| 272 | 277 | } |
| 273 | fmt.Fprintf(w, " %s\t%s%s\n", e.Address, state, mark) | |
| 278 | te.row(cells...) | |
| 274 | 279 | } |
| 280 | te.flush() | |
| 275 | 281 | fmt.Fprintln(w, "pgp keys:") |
| 282 | tp := c.table(w, "FINGERPRINT") | |
| 276 | 283 | for _, k := range d.PGPKeys { |
| 277 | fmt.Fprintf(w, " %s\n", k.Fingerprint) | |
| 284 | tp.row(cRef(" " + k.Fingerprint)) | |
| 278 | 285 | } |
| 286 | tp.flush() | |
| 279 | 287 | fmt.Fprintln(w, "orgs:") |
| 288 | to := c.table(w, "ORG", "ROLE") | |
| 280 | 289 | for _, o := range d.Orgs { |
| 281 | fmt.Fprintf(w, " %s\t%s\n", o.Org, o.Role) | |
| 290 | to.row(cRef(" "+o.Org), cState(o.Role)) | |
| 282 | 291 | } |
| 292 | to.flush() | |
| 283 | 293 | fmt.Fprintln(w, "api tokens:") |
| 294 | tt := c.table(w, "NAME", "SCOPE", "LAST USED") | |
| 284 | 295 | for _, t := range d.APITokens { |
| 285 | 296 | used := "" |
| 286 | 297 | if t.LastUsedAt != nil { |
| 287 | used = t.LastUsedAt.UTC().Format(time.RFC3339) | |
| 298 | used = t.LastUsedAt.UTC().Format(time.RFC3339Nano) | |
| 288 | 299 | } |
| 289 | fmt.Fprintf(w, " %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used)) | |
| 300 | tt.row(cRef(" "+t.Name), cState(t.Scope), cAge(used)) | |
| 290 | 301 | } |
| 302 | tt.flush() | |
| 291 | 303 | }) |
| 292 | 304 | } |
| 293 | 305 | |
| @@ -382,13 +394,15 @@ func runAdminRepoList(c *Ctx, args []string) int { | ||
| 382 | 394 | ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) |
| 383 | 395 | } |
| 384 | 396 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 397 | tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") | |
| 385 | 398 | for _, d := range ds { |
| 386 | mark := "" | |
| 399 | cells := []cell{cRef(d.Path), cState(d.Visibility), cNum(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} | |
| 387 | 400 | if d.Archived { |
| 388 | mark = "\t[archived]" | |
| 401 | cells = append(cells, cText("[archived]")) | |
| 389 | 402 | } |
| 390 | fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark) | |
| 403 | tb.row(cells...) | |
| 391 | 404 | } |
| 405 | tb.flush() | |
| 392 | 406 | }) |
| 393 | 407 | } |
| 394 | 408 | |
| @@ -517,6 +531,7 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 517 | 531 | return c.emit(d, func(w io.Writer) { |
| 518 | 532 | fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n", |
| 519 | 533 | queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h) |
| 534 | tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") | |
| 520 | 535 | for _, r := range runners { |
| 521 | 536 | scope := r.Scope |
| 522 | 537 | if scope == "" { |
| @@ -526,8 +541,9 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 526 | 541 | if r.BuildNumber != 0 { |
| 527 | 542 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) |
| 528 | 543 | } |
| 529 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held) | |
| 544 | tb.row(cText(r.Username), cFlex(r.Fingerprint), cAge(r.LastSeen), cText(scope), cText(held)) | |
| 530 | 545 | } |
| 546 | tb.flush() | |
| 531 | 547 | }) |
| 532 | 548 | } |
| 533 | 549 | |
| @@ -609,12 +625,14 @@ func runAdminMRPrune(c *Ctx, args []string) int { | ||
| 609 | 625 | return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err) |
| 610 | 626 | } |
| 611 | 627 | return c.emit(rows, func(w io.Writer) { |
| 628 | tb := c.table(w, "!", "HEAD") | |
| 612 | 629 | for _, r := range rows { |
| 613 | 630 | if r.Head == "" { |
| 614 | fmt.Fprintf(w, "!%d\talready gone\n", r.Number) | |
| 631 | tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cText("already gone")) | |
| 615 | 632 | continue |
| 616 | 633 | } |
| 617 | fmt.Fprintf(w, "!%d\t%s\n", r.Number, r.Head) | |
| 634 | tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head)) | |
| 618 | 635 | } |
| 636 | tb.flush() | |
| 619 | 637 | }) |
| 620 | 638 | } |
internal/control/audit.go +3 −2
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 5 | 4 | "io" |
| 6 | 5 | "strconv" |
| 7 | 6 | "strings" |
| @@ -47,13 +46,15 @@ func runAudit(c *Ctx, args []string) int { | ||
| 47 | 46 | return c.fail(protocol.ExitFailure, "%v", err) |
| 48 | 47 | } |
| 49 | 48 | return c.emit(entries, func(w io.Writer) { |
| 49 | tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA") | |
| 50 | 50 | for _, e := range entries { |
| 51 | 51 | actor := e.Actor |
| 52 | 52 | if actor == "" { |
| 53 | 53 | actor = "-" |
| 54 | 54 | } |
| 55 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data) | |
| 55 | tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data)) | |
| 56 | 56 | } |
| 57 | tb.flush() | |
| 57 | 58 | }) |
| 58 | 59 | } |
| 59 | 60 | |
internal/control/build.go +12 −6
| @@ -159,9 +159,11 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 159 | 159 | ds[i].Subject = subjects[ds[i].SHA] |
| 160 | 160 | } |
| 161 | 161 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 162 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") | |
| 162 | 163 | for _, d := range ds { |
| 163 | fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.Subject) | |
| 164 | tb.row(cRef(fmt.Sprintf("%d", d.Number)), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) | |
| 164 | 165 | } |
| 166 | tb.flush() | |
| 165 | 167 | }) |
| 166 | 168 | } |
| 167 | 169 | |
| @@ -259,16 +261,18 @@ func runBuildJobs(c *Ctx, args []string) int { | ||
| 259 | 261 | out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) |
| 260 | 262 | } |
| 261 | 263 | return c.emit(out, func(w io.Writer) { |
| 264 | tb := c.table(w, "NAME", "WHEN") | |
| 262 | 265 | for _, j := range out { |
| 266 | when := "on push" | |
| 263 | 267 | switch { |
| 264 | 268 | case j.Schedule != "": |
| 265 | fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule) | |
| 269 | when = "schedule " + j.Schedule | |
| 266 | 270 | case j.Tags != "": |
| 267 | fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags) | |
| 268 | default: | |
| 269 | fmt.Fprintf(w, "%s\ton push\n", j.Name) | |
| 271 | when = "tags " + j.Tags | |
| 270 | 272 | } |
| 273 | tb.row(cRef(j.Name), cText(when)) | |
| 271 | 274 | } |
| 275 | tb.flush() | |
| 272 | 276 | }) |
| 273 | 277 | } |
| 274 | 278 | |
| @@ -365,9 +369,11 @@ func runSecretList(c *Ctx, args []string) int { | ||
| 365 | 369 | return c.fail(protocol.ExitFailure, "%v", err) |
| 366 | 370 | } |
| 367 | 371 | return c.emit(names, func(w io.Writer) { |
| 372 | tb := c.table(w, "NAME") | |
| 368 | 373 | for _, n := range names { |
| 369 | fmt.Fprintln(w, n) | |
| 374 | tb.row(cRef(n)) | |
| 370 | 375 | } |
| 376 | tb.flush() | |
| 371 | 377 | }) |
| 372 | 378 | } |
| 373 | 379 | |
internal/control/control.go +33 −1
| @@ -27,6 +27,9 @@ type Ctx struct { | ||
| 27 | 27 | Stdout io.Writer |
| 28 | 28 | Stderr io.Writer |
| 29 | 29 | JSON bool |
| 30 | // Term is the client's terminal, from GITBAY_TERM. The zero value | |
| 31 | // is plain output. | |
| 32 | Term Term | |
| 30 | 33 | // ViaAPI marks requests arriving over HTTP, from the token API or |
| 31 | 34 | // the web. Every command runs there; nothing is held back for SSH |
| 32 | 35 | // any more (#234). The flag stays because the rate limiter and the |
| @@ -40,6 +43,9 @@ type Ctx struct { | ||
| 40 | 43 | // Cmd is the command being run, set by Dispatch, so a usage error can |
| 41 | 44 | // print the registered usage rather than a copy of it. |
| 42 | 45 | Cmd Command |
| 46 | // Argv is the command's arguments after its path, global flags | |
| 47 | // removed, so output can print a command to run next. | |
| 48 | Argv []string | |
| 43 | 49 | // Done, when the surface has one, closes when nobody is reading any |
| 44 | 50 | // more: the SSH channel closed or the HTTP request ended. A command |
| 45 | 51 | // that runs until something happens (build log --follow) stops on it. |
| @@ -101,6 +107,21 @@ func Lookup(argv []string) (Command, []string, bool) { | ||
| 101 | 107 | // Dispatch runs argv for an authenticated session. The dispatcher — not the |
| 102 | 108 | // handlers — enforces key scope: control commands require a full-scope key. |
| 103 | 109 | func Dispatch(c *Ctx, argv []string) int { |
| 110 | if len(argv) == 0 { | |
| 111 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") | |
| 112 | } | |
| 113 | // A leading --term=<v> selects terminal output for this session, the | |
| 114 | // same as GITBAY_TERM. It must come off before Lookup: Lookup matches | |
| 115 | // argv against a command's Path, and a --term= in front would never | |
| 116 | // match one. | |
| 117 | for len(argv) > 0 { | |
| 118 | v, ok := strings.CutPrefix(argv[0], "--term=") | |
| 119 | if !ok { | |
| 120 | break | |
| 121 | } | |
| 122 | c.Term = ParseTerm(v) | |
| 123 | argv = argv[1:] | |
| 124 | } | |
| 104 | 125 | if len(argv) == 0 { |
| 105 | 126 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") |
| 106 | 127 | } |
| @@ -118,8 +139,13 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 118 | 139 | c.JSON = true |
| 119 | 140 | continue |
| 120 | 141 | } |
| 142 | if v, ok := strings.CutPrefix(a, "--term="); ok { | |
| 143 | c.Term = ParseTerm(v) | |
| 144 | continue | |
| 145 | } | |
| 121 | 146 | args = append(args, a) |
| 122 | 147 | } |
| 148 | c.Argv = args | |
| 123 | 149 | // A runner-scoped key reaches the runner protocol and nothing else, so |
| 124 | 150 | // the key a CI host holds cannot administer the instance. |
| 125 | 151 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { |
| @@ -326,7 +352,13 @@ func runHelp(c *Ctx, args []string) int { | ||
| 326 | 352 | slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) |
| 327 | 353 | return c.emit(matched, func(w io.Writer) { |
| 328 | 354 | for _, e := range matched { |
| 329 | fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary) | |
| 355 | summary := e.Summary | |
| 356 | if c.Term.Cols > 0 { | |
| 357 | if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 { | |
| 358 | summary = clip(summary, avail) | |
| 359 | } | |
| 360 | } | |
| 361 | fmt.Fprintf(w, "%-24s %s\n", e.Path, summary) | |
| 330 | 362 | if prefix != "" { |
| 331 | 363 | fmt.Fprintf(w, " %s\n", e.Usage) |
| 332 | 364 | } |
internal/control/cursor.go +33 −1
| @@ -6,12 +6,26 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | 8 | "reflect" |
| 9 | "regexp" | |
| 9 | 10 | "strconv" |
| 10 | 11 | "strings" |
| 11 | 12 | |
| 12 | 13 | "gitbay.org/gitbay/internal/protocol" |
| 13 | 14 | ) |
| 14 | 15 | |
| 16 | // bareWord matches arguments that need no quoting for the server-side | |
| 17 | // POSIX tokenizer. | |
| 18 | var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`) | |
| 19 | ||
| 20 | // shellWord quotes one argument the way the gitbay CLI does, so a | |
| 21 | // printed command can be pasted. | |
| 22 | func shellWord(arg string) string { | |
| 23 | if arg != "" && bareWord.MatchString(arg) { | |
| 24 | return arg | |
| 25 | } | |
| 26 | return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" | |
| 27 | } | |
| 28 | ||
| 15 | 29 | // Cursor pagination. A cursor is opaque to clients: base64url of |
| 16 | 30 | // "<kind>:<key>", where key is the sort key of the last row of the |
| 17 | 31 | // previous page. The kind keeps a cursor minted by one command from |
| @@ -120,8 +134,26 @@ func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer)) | ||
| 120 | 134 | } |
| 121 | 135 | return c.emit(out{items, next}, func(w io.Writer) { |
| 122 | 136 | plain(w) |
| 123 | if next != "" { | |
| 137 | if next == "" { | |
| 138 | return | |
| 139 | } | |
| 140 | if c.Term.Cols == 0 { | |
| 124 | 141 | fmt.Fprintf(w, "next\t%s\n", next) |
| 142 | return | |
| 143 | } | |
| 144 | var again []string | |
| 145 | for i := 0; i < len(c.Argv); i++ { | |
| 146 | if c.Argv[i] == "--cursor" { | |
| 147 | i++ | |
| 148 | continue | |
| 149 | } | |
| 150 | again = append(again, c.Argv[i]) | |
| 151 | } | |
| 152 | cmd := []string{"gitbay", joinPath(c.Cmd.Path)} | |
| 153 | for _, a := range again { | |
| 154 | cmd = append(cmd, shellWord(a)) | |
| 125 | 155 | } |
| 156 | cmd = append(cmd, "--cursor", next) | |
| 157 | fmt.Fprintf(c.Stderr, "more: %s\n", strings.Join(cmd, " ")) | |
| 126 | 158 | }) |
| 127 | 159 | } |
internal/control/cursor_test.go +54 −1
| @@ -1,6 +1,33 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | import "testing" | |
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func TestShellWord(t *testing.T) { | |
| 13 | tests := []struct { | |
| 14 | input string | |
| 15 | want string | |
| 16 | }{ | |
| 17 | {"plain", "plain"}, | |
| 18 | {"foo-bar", "foo-bar"}, | |
| 19 | {"123", "123"}, | |
| 20 | {"foo bar", "'foo bar'"}, | |
| 21 | {"it's", "'it'\\''s'"}, | |
| 22 | {"a'b'c", "'a'\\''b'\\''c'"}, | |
| 23 | {"", "''"}, | |
| 24 | } | |
| 25 | for _, tt := range tests { | |
| 26 | if got := shellWord(tt.input); got != tt.want { | |
| 27 | t.Errorf("shellWord(%q) = %q, want %q", tt.input, got, tt.want) | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 4 | 31 | |
| 5 | 32 | func TestCursorRoundTrip(t *testing.T) { |
| 6 | 33 | cur := encodeCursor("issue", "42") |
| @@ -37,3 +64,29 @@ func TestTrimPage(t *testing.T) { | ||
| 37 | 64 | t.Fatalf("unpaged: %v next=%q", items, next) |
| 38 | 65 | } |
| 39 | 66 | } |
| 67 | ||
| 68 | func TestEmitPageHintsTheNextPageAtATerminal(t *testing.T) { | |
| 69 | st, repo, uid := newQueueTestRepo(t) | |
| 70 | for i := 0; i < 3; i++ { | |
| 71 | if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil { | |
| 72 | t.Fatal(err) | |
| 73 | } | |
| 74 | } | |
| 75 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 76 | c.Term = Term{Cols: 100} | |
| 77 | if code := Dispatch(c, []string{"build", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK { | |
| 78 | t.Fatalf("exit %d: %s", code, errOut) | |
| 79 | } | |
| 80 | if strings.Contains(c.Stdout.(*bytes.Buffer).String(), "next\t") { | |
| 81 | t.Errorf("cursor row on stdout at a terminal") | |
| 82 | } | |
| 83 | stderr := errOut.String() | |
| 84 | want := "more: gitbay build list " + repo.Path() + " --limit 2 --cursor " | |
| 85 | if !strings.Contains(stderr, want) { | |
| 86 | t.Errorf("stderr = %q, want %q…", stderr, want) | |
| 87 | } | |
| 88 | // Ensure no double space in the output. | |
| 89 | if strings.Contains(stderr, " ") { | |
| 90 | t.Errorf("stderr contains double space: %q", stderr) | |
| 91 | } | |
| 92 | } | |
internal/control/dashboard.go +93 −45
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | 7 | "strconv" |
| 8 | "strings" | |
| 8 | 9 | |
| 9 | 10 | "gitbay.org/gitbay/internal/buildinfo" |
| 10 | 11 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -163,90 +164,135 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 163 | 164 | } |
| 164 | 165 | |
| 165 | 166 | return c.emit(d, func(w io.Writer) { |
| 167 | section := func(title string, header []string, rows [][]cell) { | |
| 168 | if c.Term.Cols > 0 { | |
| 169 | fmt.Fprintln(w, c.Term.paint(sgrBold, title)) | |
| 170 | } else { | |
| 171 | fmt.Fprintln(w, title) | |
| 172 | } | |
| 173 | if len(rows) == 0 { | |
| 174 | fmt.Fprintln(w, " none") | |
| 175 | return | |
| 176 | } | |
| 177 | if c.Term.Cols == 0 { | |
| 178 | for _, r := range rows { | |
| 179 | parts := make([]string, len(r)) | |
| 180 | for i, cl := range r { | |
| 181 | parts[i] = cl.s | |
| 182 | if cl.kind == kindAge { | |
| 183 | parts[i] = stamp(cl.s) | |
| 184 | } | |
| 185 | } | |
| 186 | fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t")) | |
| 187 | } | |
| 188 | return | |
| 189 | } | |
| 190 | tb := c.table(w, header...) | |
| 191 | for _, r := range rows { | |
| 192 | tb.row(r...) | |
| 193 | } | |
| 194 | tb.flush() | |
| 195 | } | |
| 196 | itemRows := func(items []DashboardItem, marker string) [][]cell { | |
| 197 | rows := make([][]cell, len(items)) | |
| 198 | for i, item := range items { | |
| 199 | rows[i] = []cell{cRef(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number)), cFlex(item.Title), cText(item.Author)} | |
| 200 | } | |
| 201 | return rows | |
| 202 | } | |
| 203 | ||
| 166 | 204 | if d.Unread > 0 { |
| 167 | 205 | fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) |
| 168 | 206 | } |
| 169 | fmt.Fprintln(w, "waiting on your review:") | |
| 170 | printDashboardItems(w, d.Reviews, "!") | |
| 171 | fmt.Fprintln(w, "assigned to you:") | |
| 172 | printDashboardItems(w, d.Assigned, "#") | |
| 173 | fmt.Fprintln(w, "open merge requests:") | |
| 174 | printDashboardItems(w, d.MRs, "!") | |
| 175 | fmt.Fprintln(w, "open issues:") | |
| 176 | printDashboardItems(w, d.Issues, "#") | |
| 177 | fmt.Fprintln(w, "pinned:") | |
| 178 | if len(d.Pinned) == 0 { | |
| 179 | fmt.Fprintln(w, " none") | |
| 180 | } | |
| 181 | for _, p := range d.Pinned { | |
| 182 | mark := "" | |
| 207 | itemHeader := []string{"REF", "TITLE", "AUTHOR"} | |
| 208 | section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!")) | |
| 209 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) | |
| 210 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) | |
| 211 | section("open issues:", itemHeader, itemRows(d.Issues, "#")) | |
| 212 | ||
| 213 | pinnedRows := make([][]cell, len(d.Pinned)) | |
| 214 | for i, p := range d.Pinned { | |
| 215 | cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} | |
| 183 | 216 | if p.Archived { |
| 184 | mark = "\t[archived]" | |
| 217 | cells = append(cells, cText("[archived]")) | |
| 185 | 218 | } |
| 186 | fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark) | |
| 219 | pinnedRows[i] = cells | |
| 187 | 220 | } |
| 188 | fmt.Fprintln(w, "recent activity:") | |
| 189 | if len(d.Activity) == 0 { | |
| 190 | fmt.Fprintln(w, " none") | |
| 191 | } | |
| 192 | for _, e := range d.Activity { | |
| 193 | fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data)) | |
| 194 | } | |
| 195 | fmt.Fprintln(w, "builds:") | |
| 196 | if len(d.Builds) == 0 { | |
| 197 | fmt.Fprintln(w, " none") | |
| 221 | section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows) | |
| 222 | ||
| 223 | activityRows := make([][]cell, len(d.Activity)) | |
| 224 | for i, e := range d.Activity { | |
| 225 | activityRows[i] = []cell{cAge(e.CreatedAt), cText(e.Actor), cText(e.Kind), cRef(e.Repo), cFlex(string(e.Data))} | |
| 198 | 226 | } |
| 199 | for _, b := range d.Builds { | |
| 200 | fmt.Fprintf(w, " %s\t%d\t%s\t%s\t%.10s\t%s\n", b.Repo, b.Number, b.Job, b.Status, b.SHA, b.Ref) | |
| 227 | section("recent activity:", []string{"WHEN", "ACTOR", "KIND", "REPO", "DATA"}, activityRows) | |
| 228 | ||
| 229 | buildRows := make([][]cell, len(d.Builds)) | |
| 230 | for i, b := range d.Builds { | |
| 231 | buildRows[i] = []cell{cRef(b.Repo), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} | |
| 201 | 232 | } |
| 233 | section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) | |
| 234 | ||
| 202 | 235 | if d.Server != nil { |
| 203 | 236 | fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) |
| 204 | 237 | } |
| 205 | 238 | if q := d.Queues; q != nil { |
| 206 | 239 | fmt.Fprintln(w, "queues:") |
| 240 | ||
| 207 | 241 | fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed) |
| 242 | twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR") | |
| 208 | 243 | for _, it := range q.Webhooks.Items { |
| 209 | fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Repo, it.URL, it.Attempts, it.LastError) | |
| 244 | twh.row(cRef(" "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError)) | |
| 210 | 245 | } |
| 246 | twh.flush() | |
| 247 | ||
| 211 | 248 | fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed) |
| 249 | tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR") | |
| 212 | 250 | for _, it := range q.Mail.Items { |
| 213 | fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError) | |
| 251 | tma.row(cRef(" "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError)) | |
| 214 | 252 | } |
| 253 | tma.flush() | |
| 254 | ||
| 215 | 255 | // The device id, not the token: a token is never echoed. |
| 216 | 256 | fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed) |
| 257 | tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR") | |
| 217 | 258 | for _, it := range q.Push.Items { |
| 218 | fmt.Fprintf(w, " device %d\t%s\tattempts %d\t%s\n", it.DeviceID, it.Title, it.Attempts, it.LastError) | |
| 259 | tpu.row(cRef(fmt.Sprintf(" device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError)) | |
| 219 | 260 | } |
| 261 | tpu.flush() | |
| 262 | ||
| 220 | 263 | fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors) |
| 264 | tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR") | |
| 221 | 265 | for _, it := range q.Mirrors.Items { |
| 222 | fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError) | |
| 266 | tmi.row(cRef(" "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError)) | |
| 223 | 267 | } |
| 268 | tmi.flush() | |
| 269 | ||
| 224 | 270 | fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running) |
| 271 | tbq := c.table(w, "REPO", "#", "JOB", "STATUS") | |
| 225 | 272 | for _, it := range q.Builds.Items { |
| 226 | 273 | since := it.StartedAt |
| 227 | 274 | if it.Status == "pending" { |
| 228 | 275 | since = it.CreatedAt |
| 229 | 276 | } |
| 230 | fmt.Fprintf(w, " %s\t%d\t%s\t%s since %s\n", it.Repo, it.Number, it.Job, it.Status, since) | |
| 277 | if c.Term.Cols == 0 { | |
| 278 | since = stamp(since) | |
| 279 | } else { | |
| 280 | since = relAge(since, termNow()) | |
| 281 | } | |
| 282 | tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since))) | |
| 231 | 283 | } |
| 284 | tbq.flush() | |
| 285 | ||
| 232 | 286 | fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors) |
| 287 | tde := c.table(w, "REPO", "ERROR") | |
| 233 | 288 | for _, it := range q.Deps.Items { |
| 234 | fmt.Fprintf(w, " %s\t%s\n", it.Repo, it.LastError) | |
| 289 | tde.row(cRef(" "+it.Repo), cText(it.LastError)) | |
| 235 | 290 | } |
| 291 | tde.flush() | |
| 236 | 292 | } |
| 237 | 293 | }) |
| 238 | 294 | } |
| 239 | 295 | |
| 240 | func printDashboardItems(w io.Writer, items []DashboardItem, marker string) { | |
| 241 | if len(items) == 0 { | |
| 242 | fmt.Fprintln(w, " none") | |
| 243 | return | |
| 244 | } | |
| 245 | for _, item := range items { | |
| 246 | fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author) | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | 296 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further |
| 251 | 297 | // back. |
| 252 | 298 | const feedDefaultLimit = 50 |
| @@ -292,8 +338,10 @@ func runFeed(c *Ctx, args []string) int { | ||
| 292 | 338 | }) |
| 293 | 339 | ds := feedOutputs(events) |
| 294 | 340 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 341 | tb := c.table(w, "WHEN", "ACTOR", "KIND", "REPO", "DATA") | |
| 295 | 342 | for _, d := range ds { |
| 296 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data)) | |
| 343 | tb.row(cAge(d.CreatedAt), cText(d.Actor), cText(d.Kind), cRef(d.Repo), cFlex(string(d.Data))) | |
| 297 | 344 | } |
| 345 | tb.flush() | |
| 298 | 346 | }) |
| 299 | 347 | } |
internal/control/deploykey.go +3 −1
| @@ -98,9 +98,11 @@ func runDeployKeyList(c *Ctx, args []string) int { | ||
| 98 | 98 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label}) |
| 99 | 99 | } |
| 100 | 100 | return c.emit(ds, func(w io.Writer) { |
| 101 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL") | |
| 101 | 102 | for _, d := range ds { |
| 102 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode, d.Label) | |
| 103 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label)) | |
| 103 | 104 | } |
| 105 | tb.flush() | |
| 104 | 106 | }) |
| 105 | 107 | } |
| 106 | 108 | |
internal/control/explore.go +3 −2
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 5 | 4 | "io" |
| 6 | 5 | "strings" |
| 7 | 6 | |
| @@ -75,9 +74,11 @@ func runExplore(c *Ctx, args []string) int { | ||
| 75 | 74 | } |
| 76 | 75 | ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) |
| 77 | 76 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 77 | tb := c.table(w, "PATH", "DESCRIPTION") | |
| 78 | 78 | for _, d := range ds { |
| 79 | fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Description) | |
| 79 | tb.row(cRef(d.Path), cFlex(d.Description)) | |
| 80 | 80 | } |
| 81 | tb.flush() | |
| 81 | 82 | }) |
| 82 | 83 | } |
| 83 | 84 | |
internal/control/identity.go +3 −1
| @@ -83,9 +83,11 @@ func runKeysList(c *Ctx, args []string) int { | ||
| 83 | 83 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label}) |
| 84 | 84 | } |
| 85 | 85 | return c.emit(ds, func(w io.Writer) { |
| 86 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") | |
| 86 | 87 | for _, d := range ds { |
| 87 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope, d.Label) | |
| 88 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label)) | |
| 88 | 89 | } |
| 90 | tb.flush() | |
| 89 | 91 | }) |
| 90 | 92 | } |
| 91 | 93 | |
internal/control/issue.go +3 −1
| @@ -204,9 +204,11 @@ func runIssueList(c *Ctx, args []string) int { | ||
| 204 | 204 | ds = append(ds, issueToOut(i, false)) |
| 205 | 205 | } |
| 206 | 206 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 207 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") | |
| 207 | 208 | for _, d := range ds { |
| 208 | fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author) | |
| 209 | tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author)) | |
| 209 | 210 | } |
| 211 | tb.flush() | |
| 210 | 212 | }) |
| 211 | 213 | } |
| 212 | 214 | |
internal/control/label.go +7 −1
| @@ -46,9 +46,15 @@ func runLabelList(c *Ctx, args []string) int { | ||
| 46 | 46 | return c.fail(protocol.ExitFailure, "%v", err) |
| 47 | 47 | } |
| 48 | 48 | return c.emit(labels, func(w io.Writer) { |
| 49 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") | |
| 49 | 50 | for _, l := range labels { |
| 50 | fmt.Fprintf(w, "%s\t%s\t%d\t%d%s\n", l.Name, l.Color, l.Issues, l.MRs, map[bool]string{true: "\torg"}[l.Org]) | |
| 51 | cells := []cell{cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)} | |
| 52 | if l.Org { | |
| 53 | cells = append(cells, cText("org")) | |
| 54 | } | |
| 55 | tb.row(cells...) | |
| 51 | 56 | } |
| 57 | tb.flush() | |
| 52 | 58 | }) |
| 53 | 59 | } |
| 54 | 60 | |
internal/control/milestone.go +8 −4
| @@ -114,17 +114,19 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int { | ||
| 114 | 114 | ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems}) |
| 115 | 115 | } |
| 116 | 116 | return c.emit(ds, func(w io.Writer) { |
| 117 | tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS") | |
| 117 | 118 | for _, d := range ds { |
| 118 | 119 | due := d.Due |
| 119 | 120 | if due == "" { |
| 120 | 121 | due = "-" |
| 121 | 122 | } |
| 122 | mark := "" | |
| 123 | cells := []cell{cRef(d.Title), cState(d.State), cText("due " + due), cText(fmt.Sprintf("%d open, %d closed", d.Open, d.Closed))} | |
| 123 | 124 | if d.Org { |
| 124 | mark = "\torg" | |
| 125 | cells = append(cells, cText("org")) | |
| 125 | 126 | } |
| 126 | fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed%s\n", d.Title, d.State, due, d.Open, d.Closed, mark) | |
| 127 | tb.row(cells...) | |
| 127 | 128 | } |
| 129 | tb.flush() | |
| 128 | 130 | }) |
| 129 | 131 | } |
| 130 | 132 | |
| @@ -241,9 +243,11 @@ func runIssueTemplates(c *Ctx, args []string) int { | ||
| 241 | 243 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 242 | 244 | ts := IssueTemplates(dir, repo.DefaultBranch) |
| 243 | 245 | return c.emit(ts, func(w io.Writer) { |
| 246 | tb := c.table(w, "NAME") | |
| 244 | 247 | for _, t := range ts { |
| 245 | fmt.Fprintln(w, t.Name) | |
| 248 | tb.row(cRef(t.Name)) | |
| 246 | 249 | } |
| 250 | tb.flush() | |
| 247 | 251 | }) |
| 248 | 252 | } |
| 249 | 253 | |
internal/control/mirrorcmd.go +3 −1
| @@ -101,6 +101,7 @@ func runMirrorList(c *Ctx, args []string) int { | ||
| 101 | 101 | ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError}) |
| 102 | 102 | } |
| 103 | 103 | return c.emit(ds, func(w io.Writer) { |
| 104 | tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS") | |
| 104 | 105 | for _, d := range ds { |
| 105 | 106 | status := "ok" |
| 106 | 107 | if d.Pending { |
| @@ -109,8 +110,9 @@ func runMirrorList(c *Ctx, args []string) int { | ||
| 109 | 110 | if d.LastError != "" { |
| 110 | 111 | status = "error: " + d.LastError |
| 111 | 112 | } |
| 112 | fmt.Fprintf(w, "%d\t%s\t%s\tlast %s\t%s\n", d.ID, d.Direction, d.URL, orDash(d.LastSync), status) | |
| 113 | tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.Direction), cText(d.URL), cText("last "+orDash(d.LastSync)), cState(status)) | |
| 113 | 114 | } |
| 115 | tb.flush() | |
| 114 | 116 | }) |
| 115 | 117 | } |
| 116 | 118 | |
internal/control/mr.go +11 −7
| @@ -499,17 +499,19 @@ func runMRList(c *Ctx, args []string) int { | ||
| 499 | 499 | ds = append(ds, o) |
| 500 | 500 | } |
| 501 | 501 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 502 | tb := c.table(w, "!", "STATE", "TITLE", "REF") | |
| 502 | 503 | for _, d := range ds { |
| 503 | stacked := "" | |
| 504 | if d.StackedOn != nil { | |
| 505 | stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number) | |
| 506 | } | |
| 507 | 504 | state := d.State |
| 508 | 505 | if d.Draft { |
| 509 | 506 | state = "draft" |
| 510 | 507 | } |
| 511 | fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, state, d.Title, d.Source, d.TargetRef, stacked) | |
| 508 | cells := []cell{cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(fmt.Sprintf("%s -> %s", d.Source, d.TargetRef))} | |
| 509 | if d.StackedOn != nil { | |
| 510 | cells = append(cells, cText(fmt.Sprintf("stacked on !%d", d.StackedOn.Number))) | |
| 511 | } | |
| 512 | tb.row(cells...) | |
| 512 | 513 | } |
| 514 | tb.flush() | |
| 513 | 515 | }) |
| 514 | 516 | } |
| 515 | 517 | |
| @@ -1703,15 +1705,17 @@ func runMRRevisions(c *Ctx, args []string) int { | ||
| 1703 | 1705 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1704 | 1706 | } |
| 1705 | 1707 | return c.emit(revs, func(w io.Writer) { |
| 1708 | tb := c.table(w, "REV", "SHA", "WHEN") | |
| 1706 | 1709 | for _, r := range revs { |
| 1707 | 1710 | mark := " " |
| 1708 | 1711 | if r.Current { |
| 1709 | 1712 | mark = "*" |
| 1710 | 1713 | } |
| 1711 | fmt.Fprintf(w, "%s v%d\t%.10s\t%s\n", mark, r.N, r.SHA, r.CreatedAt) | |
| 1714 | tb.row(cRef(fmt.Sprintf("%s v%d", mark, r.N)), cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt)) | |
| 1712 | 1715 | } |
| 1716 | tb.flush() | |
| 1713 | 1717 | if len(revs) < 2 { |
| 1714 | fmt.Fprintf(w, "\nonly one revision; %s!%d has not been pushed to since it was opened\n", | |
| 1718 | fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n", | |
| 1715 | 1719 | repo.Path(), mr.Number) |
| 1716 | 1720 | } |
| 1717 | 1721 | }) |
internal/control/notifications.go +7 −3
| @@ -303,9 +303,11 @@ func runNotificationsDeviceList(c *Ctx, args []string) int { | ||
| 303 | 303 | Token: ShortToken(d.Token), Added: d.CreatedAt}) |
| 304 | 304 | } |
| 305 | 305 | return c.emit(rows, func(w io.Writer) { |
| 306 | tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED") | |
| 306 | 307 | for _, r := range rows { |
| 307 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added) | |
| 308 | tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added)) | |
| 308 | 309 | } |
| 310 | tb.flush() | |
| 309 | 311 | }) |
| 310 | 312 | } |
| 311 | 313 | |
| @@ -381,14 +383,16 @@ func runNotificationsList(c *Ctx, args []string) int { | ||
| 381 | 383 | ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt}) |
| 382 | 384 | } |
| 383 | 385 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 386 | tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") | |
| 384 | 387 | for _, d := range ds { |
| 385 | 388 | mark := "*" |
| 386 | 389 | if d.ReadAt != "" { |
| 387 | 390 | mark = " " |
| 388 | 391 | } |
| 389 | fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n", | |
| 390 | mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path) | |
| 392 | tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo), | |
| 393 | cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path)) | |
| 391 | 394 | } |
| 395 | tb.flush() | |
| 392 | 396 | }) |
| 393 | 397 | } |
| 394 | 398 | |
internal/control/org.go +6 −2
| @@ -87,9 +87,11 @@ func runOrgList(c *Ctx, args []string) int { | ||
| 87 | 87 | ds = append(ds, out{o.Username, o.Role}) |
| 88 | 88 | } |
| 89 | 89 | return c.emit(ds, func(w io.Writer) { |
| 90 | tb := c.table(w, "ORG", "ROLE") | |
| 90 | 91 | for _, d := range ds { |
| 91 | fmt.Fprintf(w, "%s\t%s\n", d.Org, d.Role) | |
| 92 | tb.row(cRef(d.Org), cState(d.Role)) | |
| 92 | 93 | } |
| 94 | tb.flush() | |
| 93 | 95 | }) |
| 94 | 96 | } |
| 95 | 97 | |
| @@ -122,9 +124,11 @@ func runOrgShow(c *Ctx, args []string) int { | ||
| 122 | 124 | }{org.Name, ms} |
| 123 | 125 | return c.emit(d, func(w io.Writer) { |
| 124 | 126 | fmt.Fprintf(w, "%s\n", d.Org) |
| 127 | tb := c.table(w, "USER", "ROLE") | |
| 125 | 128 | for _, m := range ms { |
| 126 | fmt.Fprintf(w, " %s\t%s\n", m.User, m.Role) | |
| 129 | tb.row(cRef(" "+m.User), cState(m.Role)) | |
| 127 | 130 | } |
| 131 | tb.flush() | |
| 128 | 132 | }) |
| 129 | 133 | } |
| 130 | 134 | |
internal/control/orglabel.go +3 −1
| @@ -128,9 +128,11 @@ func runOrgLabelList(c *Ctx, args []string) int { | ||
| 128 | 128 | return c.fail(protocol.ExitFailure, "%v", err) |
| 129 | 129 | } |
| 130 | 130 | return c.emit(labels, func(w io.Writer) { |
| 131 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") | |
| 131 | 132 | for _, l := range labels { |
| 132 | fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", l.Name, l.Color, l.Issues, l.MRs) | |
| 133 | tb.row(cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)) | |
| 133 | 134 | } |
| 135 | tb.flush() | |
| 134 | 136 | }) |
| 135 | 137 | } |
| 136 | 138 | |
internal/control/pagescmd.go +3 −1
| @@ -213,8 +213,10 @@ func runDomainList(c *Ctx, args []string) int { | ||
| 213 | 213 | list = append(list, out{d.Domain, state, d.VerifiedAt}) |
| 214 | 214 | } |
| 215 | 215 | return c.emit(list, func(w io.Writer) { |
| 216 | tb := c.table(w, "DOMAIN", "STATE") | |
| 216 | 217 | for _, d := range list { |
| 217 | fmt.Fprintf(w, "%s\t%s\n", d.Domain, d.State) | |
| 218 | tb.row(cRef(d.Domain), cState(d.State)) | |
| 218 | 219 | } |
| 220 | tb.flush() | |
| 219 | 221 | }) |
| 220 | 222 | } |
internal/control/read.go +7 −3
| @@ -79,12 +79,14 @@ func runRepoRefs(c *Ctx, args []string) int { | ||
| 79 | 79 | d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA}) |
| 80 | 80 | } |
| 81 | 81 | return c.emit(d, func(w io.Writer) { |
| 82 | tb := c.table(w, "KIND", "NAME", "SHA") | |
| 82 | 83 | for _, ref := range d.Branches { |
| 83 | fmt.Fprintf(w, "branch\t%s\t%.10s\n", ref.Name, ref.SHA) | |
| 84 | tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 84 | 85 | } |
| 85 | 86 | for _, ref := range d.Tags { |
| 86 | fmt.Fprintf(w, "tag\t%s\t%.10s\n", ref.Name, ref.SHA) | |
| 87 | tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 87 | 88 | } |
| 89 | tb.flush() | |
| 88 | 90 | }) |
| 89 | 91 | } |
| 90 | 92 | |
| @@ -283,13 +285,15 @@ func runRepoTree(c *Ctx, args []string) int { | ||
| 283 | 285 | d.Entries = append(d.Entries, eo) |
| 284 | 286 | } |
| 285 | 287 | return c.emit(d, func(w io.Writer) { |
| 288 | tb := c.table(w, "SHA", "SIZE", "NAME") | |
| 286 | 289 | for _, e := range d.Entries { |
| 287 | 290 | name := e.Name |
| 288 | 291 | if e.Type == "tree" { |
| 289 | 292 | name += "/" |
| 290 | 293 | } |
| 291 | fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name) | |
| 294 | tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name)) | |
| 292 | 295 | } |
| 296 | tb.flush() | |
| 293 | 297 | }) |
| 294 | 298 | } |
| 295 | 299 | |
internal/control/register.go +5 −2
| @@ -62,16 +62,19 @@ func runEmailList(c *Ctx, args []string) int { | ||
| 62 | 62 | ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) |
| 63 | 63 | } |
| 64 | 64 | return c.emit(ds, func(w io.Writer) { |
| 65 | tb := c.table(w, "ADDRESS", "STATE") | |
| 65 | 66 | for _, d := range ds { |
| 66 | 67 | state := "unverified" |
| 67 | 68 | if d.Verified { |
| 68 | 69 | state = "verified" |
| 69 | 70 | } |
| 71 | cells := []cell{cRef(d.Address), cState(state)} | |
| 70 | 72 | if d.Primary { |
| 71 | state += "\tprimary" | |
| 73 | cells = append(cells, cText("primary")) | |
| 72 | 74 | } |
| 73 | fmt.Fprintf(w, "%s\t%s\n", d.Address, state) | |
| 75 | tb.row(cells...) | |
| 74 | 76 | } |
| 77 | tb.flush() | |
| 75 | 78 | }) |
| 76 | 79 | } |
| 77 | 80 | |
internal/control/release.go +3 −1
| @@ -246,13 +246,15 @@ func runReleaseList(c *Ctx, args []string) int { | ||
| 246 | 246 | ds = append(ds, releaseToOut(r, false)) |
| 247 | 247 | } |
| 248 | 248 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 249 | tb := c.table(w, "TAG", "TITLE", "ASSETS") | |
| 249 | 250 | for _, d := range ds { |
| 250 | 251 | title := d.Title |
| 251 | 252 | if title == d.Tag { |
| 252 | 253 | title = "" |
| 253 | 254 | } |
| 254 | fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, title, len(d.Assets)) | |
| 255 | tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets)))) | |
| 255 | 256 | } |
| 257 | tb.flush() | |
| 256 | 258 | }) |
| 257 | 259 | } |
| 258 | 260 | |
internal/control/repo.go +20 −8
| @@ -276,13 +276,15 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 276 | 276 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) |
| 277 | 277 | } |
| 278 | 278 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 279 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") | |
| 279 | 280 | for _, d := range ds { |
| 280 | mark := "" | |
| 281 | cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} | |
| 281 | 282 | if d.Archived { |
| 282 | mark = "\t[archived]" | |
| 283 | cells = append(cells, cText("[archived]")) | |
| 283 | 284 | } |
| 284 | fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark) | |
| 285 | tb.row(cells...) | |
| 285 | 286 | } |
| 287 | tb.flush() | |
| 286 | 288 | }) |
| 287 | 289 | } |
| 288 | 290 | |
| @@ -611,9 +613,11 @@ func runAccessList(c *Ctx, args []string) int { | ||
| 611 | 613 | ds = append(ds, out{e.Username, e.Role, e.Source}) |
| 612 | 614 | } |
| 613 | 615 | return c.emit(ds, func(w io.Writer) { |
| 616 | tb := c.table(w, "USER", "ROLE", "SOURCE") | |
| 614 | 617 | for _, d := range ds { |
| 615 | fmt.Fprintf(w, "%s\t%s\tvia %s\n", d.User, d.Role, d.Source) | |
| 618 | tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source)) | |
| 616 | 619 | } |
| 620 | tb.flush() | |
| 617 | 621 | }) |
| 618 | 622 | } |
| 619 | 623 | |
| @@ -799,9 +803,11 @@ func runTopicsList(c *Ctx, args []string) int { | ||
| 799 | 803 | return c.fail(protocol.ExitFailure, "%v", err) |
| 800 | 804 | } |
| 801 | 805 | return c.emit(topics, func(w io.Writer) { |
| 806 | tb := c.table(w, "TOPIC") | |
| 802 | 807 | for _, t := range topics { |
| 803 | fmt.Fprintln(w, t) | |
| 808 | tb.row(cRef(t)) | |
| 804 | 809 | } |
| 810 | tb.flush() | |
| 805 | 811 | }) |
| 806 | 812 | } |
| 807 | 813 | |
| @@ -856,9 +862,11 @@ func editTopics(c *Ctx, args []string, add bool) int { | ||
| 856 | 862 | return c.fail(protocol.ExitFailure, "%v", err) |
| 857 | 863 | } |
| 858 | 864 | return c.emit(now, func(w io.Writer) { |
| 865 | tb := c.table(w, "TOPIC") | |
| 859 | 866 | for _, t := range now { |
| 860 | fmt.Fprintln(w, t) | |
| 867 | tb.row(cRef(t)) | |
| 861 | 868 | } |
| 869 | tb.flush() | |
| 862 | 870 | }) |
| 863 | 871 | } |
| 864 | 872 | |
| @@ -902,9 +910,11 @@ func runRepoSearch(c *Ctx, args []string) int { | ||
| 902 | 910 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) |
| 903 | 911 | } |
| 904 | 912 | return c.emit(ds, func(w io.Writer) { |
| 913 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") | |
| 905 | 914 | for _, d := range ds { |
| 906 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description) | |
| 915 | tb.row(cRef(d.Path), cState(d.Visibility), cFlex(d.Description)) | |
| 907 | 916 | } |
| 917 | tb.flush() | |
| 908 | 918 | }) |
| 909 | 919 | } |
| 910 | 920 | |
| @@ -1070,9 +1080,11 @@ func runRepoBookmarks(c *Ctx, args []string) int { | ||
| 1070 | 1080 | }) |
| 1071 | 1081 | } |
| 1072 | 1082 | return c.emit(out, func(w io.Writer) { |
| 1083 | tb := c.table(w, "PATH", "COUNT", "DESCRIPTION") | |
| 1073 | 1084 | for _, b := range out { |
| 1074 | fmt.Fprintf(w, "%s\t%d\t%s\n", b.Path, b.Bookmarks, b.Description) | |
| 1085 | tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description)) | |
| 1075 | 1086 | } |
| 1087 | tb.flush() | |
| 1076 | 1088 | }) |
| 1077 | 1089 | } |
| 1078 | 1090 | |
internal/control/runnerrepo.go +3 −1
| @@ -109,6 +109,7 @@ func runRepoRunnerList(c *Ctx, args []string) int { | ||
| 109 | 109 | runners = []store.RepoRunner{} |
| 110 | 110 | } |
| 111 | 111 | return c.emit(runners, func(w io.Writer) { |
| 112 | tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD") | |
| 112 | 113 | for _, r := range runners { |
| 113 | 114 | seen := r.LastSeen |
| 114 | 115 | if seen == "" { |
| @@ -118,8 +119,9 @@ func runRepoRunnerList(c *Ctx, args []string) int { | ||
| 118 | 119 | if r.BuildNumber != 0 { |
| 119 | 120 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) |
| 120 | 121 | } |
| 121 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held) | |
| 122 | tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held)) | |
| 122 | 123 | } |
| 124 | tb.flush() | |
| 123 | 125 | }) |
| 124 | 126 | } |
| 125 | 127 | |
internal/control/search.go +21 −9
| @@ -97,17 +97,29 @@ func runSearch(c *Ctx, args []string) int { | ||
| 97 | 97 | if err != nil { |
| 98 | 98 | return c.fail(protocol.ExitFailure, "%v", err) |
| 99 | 99 | } |
| 100 | return c.emit(results, func(w io.Writer) { | |
| 101 | for _, r := range results { | |
| 102 | switch r.Kind { | |
| 103 | case "repo": | |
| 104 | fmt.Fprintf(w, "repo\t%s\t%s\n", r.Repo, r.Title) | |
| 105 | default: | |
| 106 | fmt.Fprintf(w, "%s\t%s%s%d\t%s\t%s\n", r.Kind, r.Repo, | |
| 107 | SearchMarker(r.Kind), r.Number, r.State, r.Title) | |
| 100 | return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) }) | |
| 101 | } | |
| 102 | ||
| 103 | // writeSearchTable renders search results: a repo hit has no ref number or | |
| 104 | // state, an issue/mr hit has both. Plain output keeps a repo row at 3 | |
| 105 | // cells, matching what it always printed. At a terminal the header still | |
| 106 | // reserves a STATE column, so a repo row gets an empty state cell there — | |
| 107 | // otherwise its title would render under STATE instead of TITLE. | |
| 108 | func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) { | |
| 109 | tb := c.table(w, "KIND", "REF", "STATE", "TITLE") | |
| 110 | for _, r := range results { | |
| 111 | switch r.Kind { | |
| 112 | case "repo": | |
| 113 | if c.Term.Cols > 0 { | |
| 114 | tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title)) | |
| 115 | } else { | |
| 116 | tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title)) | |
| 108 | 117 | } |
| 118 | default: | |
| 119 | tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title)) | |
| 109 | 120 | } |
| 110 | }) | |
| 121 | } | |
| 122 | tb.flush() | |
| 111 | 123 | } |
| 112 | 124 | |
| 113 | 125 | // SearchMarker is the sigil a result's number carries, shared with the web |
internal/control/search_test.go added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // A repo hit has no state, an issue/mr hit does. At a terminal both must | |
| 10 | // still put TITLE under the TITLE header, not have the repo's title slide | |
| 11 | // left under STATE. | |
| 12 | func TestSearchTableAlignsTitleAtTerminal(t *testing.T) { | |
| 13 | results := []SearchResult{ | |
| 14 | {Kind: "repo", Repo: "alice/webapp", Title: "a web application"}, | |
| 15 | {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"}, | |
| 16 | } | |
| 17 | var b bytes.Buffer | |
| 18 | writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results) | |
| 19 | ||
| 20 | lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") | |
| 21 | if len(lines) != 3 { | |
| 22 | t.Fatalf("want header + 2 rows, got %d lines:\n%s", len(lines), b.String()) | |
| 23 | } | |
| 24 | header, repoRow, issueRow := lines[0], lines[1], lines[2] | |
| 25 | titleAt := strings.Index(header, "TITLE") | |
| 26 | repoTitleAt := strings.Index(repoRow, "a web application") | |
| 27 | issueTitleAt := strings.Index(issueRow, "memory leak") | |
| 28 | if titleAt < 0 || repoTitleAt < 0 || issueTitleAt < 0 { | |
| 29 | t.Fatalf("columns not found:\n%s", b.String()) | |
| 30 | } | |
| 31 | if repoTitleAt != issueTitleAt { | |
| 32 | t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String()) | |
| 33 | } | |
| 34 | if repoTitleAt != titleAt { | |
| 35 | t.Errorf("title not under TITLE header: header at %d, repo row at %d\n%s", titleAt, repoTitleAt, b.String()) | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | // Plain output has no header to align to, so a repo row stays 3 cells — | |
| 40 | // bytes must not change from before the terminal fix. | |
| 41 | func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) { | |
| 42 | results := []SearchResult{ | |
| 43 | {Kind: "repo", Repo: "alice/webapp", Title: "a web application"}, | |
| 44 | {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"}, | |
| 45 | } | |
| 46 | var b bytes.Buffer | |
| 47 | writeSearchTable(&Ctx{}, &b, results) | |
| 48 | ||
| 49 | want := "repo\talice/webapp\ta web application\n" + | |
| 50 | "issue\talice/webapp#4\topen\tmemory leak\n" | |
| 51 | if b.String() != want { | |
| 52 | t.Errorf("plain:\n%q\nwant\n%q", b.String(), want) | |
| 53 | } | |
| 54 | } | |
internal/control/sig.go +7 −2
| @@ -80,9 +80,11 @@ func runPGPList(c *Ctx, args []string) int { | ||
| 80 | 80 | ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) |
| 81 | 81 | } |
| 82 | 82 | return c.emit(ds, func(w io.Writer) { |
| 83 | tb := c.table(w, "FINGERPRINT", "EMAILS") | |
| 83 | 84 | for _, d := range ds { |
| 84 | fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails) | |
| 85 | tb.row(cRef(d.Fingerprint), cText(d.Emails)) | |
| 85 | 86 | } |
| 87 | tb.flush() | |
| 86 | 88 | }) |
| 87 | 89 | } |
| 88 | 90 | |
| @@ -211,9 +213,12 @@ func runRepoLog(c *Ctx, args []string) int { | ||
| 211 | 213 | ds = append(ds, d) |
| 212 | 214 | } |
| 213 | 215 | return c.emit(ds, func(w io.Writer) { |
| 216 | tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") | |
| 214 | 217 | for _, d := range ds { |
| 215 | fmt.Fprintf(w, "%.10s %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail) | |
| 218 | tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject), | |
| 219 | cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail))) | |
| 216 | 220 | } |
| 221 | tb.flush() | |
| 217 | 222 | }) |
| 218 | 223 | } |
| 219 | 224 | |
internal/control/snippet.go +6 −2
| @@ -202,9 +202,11 @@ func runSnippetShow(c *Ctx, args []string) int { | ||
| 202 | 202 | fmt.Fprintf(w, "%s\n", sn.Description) |
| 203 | 203 | } |
| 204 | 204 | fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt) |
| 205 | tb := c.table(w, "NAME", "SIZE") | |
| 205 | 206 | for _, f := range files { |
| 206 | fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size) | |
| 207 | tb.row(cRef(" "+f.Name), cText(fmt.Sprintf("%d bytes", f.Size))) | |
| 207 | 208 | } |
| 209 | tb.flush() | |
| 208 | 210 | }) |
| 209 | 211 | } |
| 210 | 212 | |
| @@ -238,6 +240,7 @@ func runSnippetList(c *Ctx, args []string) int { | ||
| 238 | 240 | items = append(items, snippetOut(c, sn)) |
| 239 | 241 | } |
| 240 | 242 | return c.emitPage(p, items, next, func(w io.Writer) { |
| 243 | tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION") | |
| 241 | 244 | for _, sn := range rows { |
| 242 | 245 | names := "" |
| 243 | 246 | for i, f := range sn.Files { |
| @@ -246,8 +249,9 @@ func runSnippetList(c *Ctx, args []string) int { | ||
| 246 | 249 | } |
| 247 | 250 | names += f.Name |
| 248 | 251 | } |
| 249 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description) | |
| 252 | tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description)) | |
| 250 | 253 | } |
| 254 | tb.flush() | |
| 251 | 255 | }) |
| 252 | 256 | } |
| 253 | 257 | |
internal/control/status.go +4 −2
| @@ -118,10 +118,12 @@ func runStatusList(c *Ctx, args []string) int { | ||
| 118 | 118 | Statuses []out `json:"statuses"` |
| 119 | 119 | }{full, combinedOf(statuses), ds} |
| 120 | 120 | return c.emit(d, func(w io.Writer) { |
| 121 | fmt.Fprintf(w, "combined\t%s\t%.10s\n", orNone(d.Combined), d.SHA) | |
| 121 | tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION") | |
| 122 | tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA))) | |
| 122 | 123 | for _, x := range ds { |
| 123 | fmt.Fprintf(w, "%s\t%s\t%s\n", x.Context, x.State, x.Description) | |
| 124 | tb.row(cText(x.Context), cState(x.State), cFlex(x.Description)) | |
| 124 | 125 | } |
| 126 | tb.flush() | |
| 125 | 127 | }) |
| 126 | 128 | } |
| 127 | 129 | |
internal/control/table.go added +200
| @@ -0,0 +1,200 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "io" | |
| 5 | "strconv" | |
| 6 | "strings" | |
| 7 | ) | |
| 8 | ||
| 9 | type cellKind int | |
| 10 | ||
| 11 | const ( | |
| 12 | kindText cellKind = iota | |
| 13 | kindFlex | |
| 14 | kindRef | |
| 15 | kindState | |
| 16 | kindAge | |
| 17 | kindNum | |
| 18 | ) | |
| 19 | ||
| 20 | // cell is one column of a table row. The kind decides colour, time | |
| 21 | // format, and whether the column may be clipped to fit the terminal. | |
| 22 | type cell struct { | |
| 23 | kind cellKind | |
| 24 | s string | |
| 25 | } | |
| 26 | ||
| 27 | func cRef(s string) cell { return cell{kindRef, s} } | |
| 28 | func cState(s string) cell { return cell{kindState, s} } | |
| 29 | func cText(s string) cell { return cell{kindText, s} } | |
| 30 | func cFlex(s string) cell { return cell{kindFlex, s} } | |
| 31 | func cAge(ts string) cell { return cell{kindAge, ts} } | |
| 32 | func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} } | |
| 33 | ||
| 34 | // table is a list command's rows. Plain, each row is written as it | |
| 35 | // comes, tab-separated with no header. At a terminal rows are held | |
| 36 | // until flush, then written under a header, padded, and fitted to the | |
| 37 | // width. | |
| 38 | type table struct { | |
| 39 | term Term | |
| 40 | w io.Writer | |
| 41 | header []string | |
| 42 | rows [][]cell | |
| 43 | } | |
| 44 | ||
| 45 | func (c *Ctx) table(w io.Writer, header ...string) *table { | |
| 46 | return &table{term: c.Term, w: w, header: header} | |
| 47 | } | |
| 48 | ||
| 49 | func (t *table) row(cs ...cell) { | |
| 50 | if t.term.Cols == 0 { | |
| 51 | parts := make([]string, len(cs)) | |
| 52 | for i, c := range cs { | |
| 53 | if c.kind == kindAge { | |
| 54 | parts[i] = stamp(c.s) | |
| 55 | } else { | |
| 56 | parts[i] = c.s | |
| 57 | } | |
| 58 | } | |
| 59 | io.WriteString(t.w, strings.Join(parts, "\t")+"\n") | |
| 60 | return | |
| 61 | } | |
| 62 | now := termNow() | |
| 63 | for i := range cs { | |
| 64 | if cs[i].kind == kindAge { | |
| 65 | cs[i].s = relAge(cs[i].s, now) | |
| 66 | } | |
| 67 | } | |
| 68 | t.rows = append(t.rows, cs) | |
| 69 | } | |
| 70 | ||
| 71 | func (t *table) flush() { | |
| 72 | if t.term.Cols == 0 || len(t.rows) == 0 { | |
| 73 | return | |
| 74 | } | |
| 75 | // The column count is never smaller than the longest row: a row with | |
| 76 | // more cells than the header has still gets every cell rendered, the | |
| 77 | // header just shows blank above the ones it doesn't name. | |
| 78 | n := len(t.header) | |
| 79 | for _, r := range t.rows { | |
| 80 | n = max(n, len(r)) | |
| 81 | } | |
| 82 | widths := make([]int, n) | |
| 83 | for i, h := range t.header { | |
| 84 | widths[i] = cells(h) | |
| 85 | } | |
| 86 | for _, r := range t.rows { | |
| 87 | for i := 0; i < len(r); i++ { | |
| 88 | widths[i] = max(widths[i], cells(r[i].s)) | |
| 89 | } | |
| 90 | } | |
| 91 | t.fit(widths) | |
| 92 | ||
| 93 | var b strings.Builder | |
| 94 | line := make([]string, n) | |
| 95 | for i := range line { | |
| 96 | if i < len(t.header) { | |
| 97 | line[i] = t.header[i] | |
| 98 | } | |
| 99 | } | |
| 100 | b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n") | |
| 101 | for _, r := range t.rows { | |
| 102 | for i := 0; i < n; i++ { | |
| 103 | s := "" | |
| 104 | if i < len(r) { | |
| 105 | s = clip(r[i].s, widths[i]) | |
| 106 | } | |
| 107 | line[i] = s | |
| 108 | } | |
| 109 | b.WriteString(t.joinRow(r, line, widths) + "\n") | |
| 110 | } | |
| 111 | io.WriteString(t.w, b.String()) | |
| 112 | } | |
| 113 | ||
| 114 | // fit shrinks columns until a row fits the terminal: the flexible | |
| 115 | // column first, down to 8 cells, then the other text columns from the | |
| 116 | // right, down to 8 each. | |
| 117 | func (t *table) fit(widths []int) { | |
| 118 | total := func() int { | |
| 119 | s := 2 * (len(widths) - 1) | |
| 120 | for _, w := range widths { | |
| 121 | s += w | |
| 122 | } | |
| 123 | return s | |
| 124 | } | |
| 125 | kinds := make([]cellKind, len(widths)) | |
| 126 | for i := range widths { | |
| 127 | for _, r := range t.rows { | |
| 128 | if i < len(r) { | |
| 129 | kinds[i] = r[i].kind | |
| 130 | break | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | shrink := func(i int) { | |
| 135 | if over := total() - t.term.Cols; over > 0 && widths[i] > 8 { | |
| 136 | widths[i] = max(8, widths[i]-over) | |
| 137 | } | |
| 138 | } | |
| 139 | for i, k := range kinds { | |
| 140 | if k == kindFlex { | |
| 141 | shrink(i) | |
| 142 | } | |
| 143 | } | |
| 144 | for i := len(kinds) - 1; i >= 0; i-- { | |
| 145 | if kinds[i] == kindText { | |
| 146 | shrink(i) | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | // join pads every column but the last and separates them by two spaces. | |
| 152 | func (t *table) join(line []string, widths []int) string { | |
| 153 | var b strings.Builder | |
| 154 | for i, s := range line { | |
| 155 | if i > 0 { | |
| 156 | b.WriteString(" ") | |
| 157 | } | |
| 158 | if i == len(line)-1 { | |
| 159 | b.WriteString(s) | |
| 160 | } else { | |
| 161 | b.WriteString(pad(s, widths[i])) | |
| 162 | } | |
| 163 | } | |
| 164 | return b.String() | |
| 165 | } | |
| 166 | ||
| 167 | // joinRow is join with state cells coloured after padding, so the | |
| 168 | // SGR bytes never count against the width. | |
| 169 | func (t *table) joinRow(r []cell, line []string, widths []int) string { | |
| 170 | var b strings.Builder | |
| 171 | for i, s := range line { | |
| 172 | if i > 0 { | |
| 173 | b.WriteString(" ") | |
| 174 | } | |
| 175 | padding := "" | |
| 176 | if i < len(line)-1 { | |
| 177 | padding = strings.Repeat(" ", max(0, widths[i]-cells(s))) | |
| 178 | } | |
| 179 | if i < len(r) && r[i].kind == kindState { | |
| 180 | s = t.term.paint(stateColor(s), s) | |
| 181 | } | |
| 182 | b.WriteString(s + padding) | |
| 183 | } | |
| 184 | return b.String() | |
| 185 | } | |
| 186 | ||
| 187 | // stripSGR removes SGR sequences, for tests and width checks. | |
| 188 | func stripSGR(s string) string { | |
| 189 | var b strings.Builder | |
| 190 | for i := 0; i < len(s); i++ { | |
| 191 | if s[i] == 0x1b { | |
| 192 | if j := strings.IndexByte(s[i:], 'm'); j >= 0 { | |
| 193 | i += j | |
| 194 | continue | |
| 195 | } | |
| 196 | } | |
| 197 | b.WriteByte(s[i]) | |
| 198 | } | |
| 199 | return b.String() | |
| 200 | } | |
internal/control/table_test.go added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ) | |
| 9 | ||
| 10 | func fixtureTable(c *Ctx, w *bytes.Buffer) { | |
| 11 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") | |
| 12 | tb.row(cRef("#252"), cState("open"), cFlex("Dependency updates available for every module"), cText("gitbay-bot")) | |
| 13 | tb.row(cRef("#12"), cState("closed"), cFlex("Android app"), cText("cmc")) | |
| 14 | tb.flush() | |
| 15 | } | |
| 16 | ||
| 17 | func TestTablePlainIsTabs(t *testing.T) { | |
| 18 | var b bytes.Buffer | |
| 19 | fixtureTable(&Ctx{}, &b) | |
| 20 | want := "#252\topen\tDependency updates available for every module\tgitbay-bot\n" + | |
| 21 | "#12\tclosed\tAndroid app\tcmc\n" | |
| 22 | if b.String() != want { | |
| 23 | t.Errorf("plain:\n%q\nwant\n%q", b.String(), want) | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | func TestTableTerminalFits(t *testing.T) { | |
| 28 | var b bytes.Buffer | |
| 29 | fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b) | |
| 30 | want := "# STATE TITLE AUTHOR\n" + | |
| 31 | "#252 open Dependency up… gitbay-bot\n" + | |
| 32 | "#12 closed Android app cmc\n" | |
| 33 | if b.String() != want { | |
| 34 | t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want) | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | func TestTableColourOnlyAddsSGR(t *testing.T) { | |
| 39 | var mono, colour bytes.Buffer | |
| 40 | fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono) | |
| 41 | fixtureTable(&Ctx{Term: Term{Cols: 40, Color: true}}, &colour) | |
| 42 | if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) { | |
| 43 | t.Errorf("open not green: %q", colour.String()) | |
| 44 | } | |
| 45 | if !strings.HasPrefix(colour.String(), sgrDim) { | |
| 46 | t.Errorf("header not dim: %q", colour.String()) | |
| 47 | } | |
| 48 | if stripSGR(colour.String()) != mono.String() { | |
| 49 | t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String()) | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | func TestTableAgesAndPlainStamps(t *testing.T) { | |
| 54 | termNow = func() time.Time { return time.Date(2026, 9, 23, 12, 0, 1, 0, time.UTC) } | |
| 55 | t.Cleanup(func() { termNow = time.Now }) | |
| 56 | var plain, term bytes.Buffer | |
| 57 | for _, c := range []struct { | |
| 58 | ctx *Ctx | |
| 59 | w *bytes.Buffer | |
| 60 | }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} { | |
| 61 | tb := c.ctx.table(c.w, "#", "UPDATED") | |
| 62 | tb.row(cRef("#1"), cAge("2026-09-23T10:00:00.123Z")) | |
| 63 | tb.flush() | |
| 64 | } | |
| 65 | if plain.String() != "#1\t2026-09-23T10:00:00Z\n" { | |
| 66 | t.Errorf("plain = %q", plain.String()) | |
| 67 | } | |
| 68 | if term.String() != "# UPDATED\n#1 2h ago\n" { | |
| 69 | t.Errorf("term = %q", term.String()) | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // A row may carry a cell beyond what the header names — repo list's | |
| 74 | // trailing [archived] marker, only present on some rows. flush must | |
| 75 | // still render it, not silently drop it because it falls past | |
| 76 | // len(header). | |
| 77 | func TestTableKeepsCellsBeyondTheHeader(t *testing.T) { | |
| 78 | var b bytes.Buffer | |
| 79 | tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "PATH", "VISIBILITY", "DESCRIPTION") | |
| 80 | tb.row(cRef("a/x"), cState("public"), cFlex("one")) | |
| 81 | tb.row(cRef("a/y"), cState("public"), cFlex("two"), cText("[archived]")) | |
| 82 | tb.flush() | |
| 83 | ||
| 84 | out := b.String() | |
| 85 | if !strings.Contains(out, "[archived]") { | |
| 86 | t.Fatalf("archived marker dropped:\n%s", out) | |
| 87 | } | |
| 88 | lines := strings.Split(strings.TrimRight(out, "\n"), "\n") | |
| 89 | if len(lines) != 3 { | |
| 90 | t.Fatalf("want 3 lines, got %d:\n%s", len(lines), out) | |
| 91 | } | |
| 92 | header, row1, row2 := lines[0], lines[1], lines[2] | |
| 93 | descAt := strings.Index(header, "DESCRIPTION") | |
| 94 | oneAt := strings.Index(row1, "one") | |
| 95 | twoAt := strings.Index(row2, "two") | |
| 96 | if descAt < 0 || oneAt < 0 || twoAt < 0 { | |
| 97 | t.Fatalf("columns not found:\n%s", out) | |
| 98 | } | |
| 99 | if descAt != oneAt || descAt != twoAt { | |
| 100 | t.Errorf("DESCRIPTION column not aligned: header at %d, row1 at %d, row2 at %d\n%s", descAt, oneAt, twoAt, out) | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | func TestTableEmptyPrintsNothing(t *testing.T) { | |
| 105 | var b bytes.Buffer | |
| 106 | (&Ctx{Term: Term{Cols: 80}}).table(&b, "#").flush() | |
| 107 | if b.Len() != 0 { | |
| 108 | t.Errorf("empty table printed %q", b.String()) | |
| 109 | } | |
| 110 | } | |
internal/control/teams.go +6 −2
| @@ -143,9 +143,11 @@ func runTeamList(c *Ctx, args []string) int { | ||
| 143 | 143 | names = append(names, t.Name) |
| 144 | 144 | } |
| 145 | 145 | return c.emit(names, func(w io.Writer) { |
| 146 | tb := c.table(w, "TEAM") | |
| 146 | 147 | for _, n := range names { |
| 147 | fmt.Fprintln(w, n) | |
| 148 | tb.row(cRef(n)) | |
| 148 | 149 | } |
| 150 | tb.flush() | |
| 149 | 151 | }) |
| 150 | 152 | } |
| 151 | 153 | |
| @@ -176,9 +178,11 @@ func runTeamShow(c *Ctx, args []string) int { | ||
| 176 | 178 | }{team.Name, members, grants} |
| 177 | 179 | return c.emit(d, func(w io.Writer) { |
| 178 | 180 | fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", ")) |
| 181 | tb := c.table(w, "REPO", "ROLE") | |
| 179 | 182 | for _, g := range grants { |
| 180 | fmt.Fprintf(w, "%s\t%s\n", g.RepoPath, g.Role) | |
| 183 | tb.row(cRef(g.RepoPath), cState(g.Role)) | |
| 181 | 184 | } |
| 185 | tb.flush() | |
| 182 | 186 | }) |
| 183 | 187 | } |
| 184 | 188 | |
internal/control/term.go added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "strconv" | |
| 6 | "strings" | |
| 7 | "time" | |
| 8 | "unicode" | |
| 9 | "unicode/utf8" | |
| 10 | ||
| 11 | "golang.org/x/text/width" | |
| 12 | ) | |
| 13 | ||
| 14 | // Term is what the client said about its terminal (GITBAY_TERM). The | |
| 15 | // zero value is plain output: tab-separated rows, no header, no colour, | |
| 16 | // which is what stock ssh, the API and the web get. | |
| 17 | type Term struct { | |
| 18 | Cols int | |
| 19 | Color bool | |
| 20 | } | |
| 21 | ||
| 22 | // ParseTerm reads "<cols>[,color]". Anything else, or a width outside | |
| 23 | // 40 to 1000, is plain output. | |
| 24 | func ParseTerm(v string) Term { | |
| 25 | cols, opt, hasOpt := strings.Cut(v, ",") | |
| 26 | n, err := strconv.Atoi(cols) | |
| 27 | if err != nil || n < 40 || n > 1000 { | |
| 28 | return Term{} | |
| 29 | } | |
| 30 | switch { | |
| 31 | case !hasOpt: | |
| 32 | return Term{Cols: n} | |
| 33 | case opt == "color": | |
| 34 | return Term{Cols: n, Color: true} | |
| 35 | } | |
| 36 | return Term{} | |
| 37 | } | |
| 38 | ||
| 39 | const ( | |
| 40 | sgrReset = "\x1b[0m" | |
| 41 | sgrBold = "\x1b[1m" | |
| 42 | sgrDim = "\x1b[2m" | |
| 43 | sgrUnderline = "\x1b[4m" | |
| 44 | sgrRed = "\x1b[31m" | |
| 45 | sgrGreen = "\x1b[32m" | |
| 46 | sgrMagenta = "\x1b[35m" | |
| 47 | ) | |
| 48 | ||
| 49 | // paint wraps s in an SGR sequence when colour is on. | |
| 50 | func (t Term) paint(sgr, s string) string { | |
| 51 | if !t.Color || sgr == "" || s == "" { | |
| 52 | return s | |
| 53 | } | |
| 54 | return sgr + s + sgrReset | |
| 55 | } | |
| 56 | ||
| 57 | // stateColor maps a state word to the web's state tokens: --ok green, | |
| 58 | // --done magenta, --bad red, --neutral dim. | |
| 59 | func stateColor(s string) string { | |
| 60 | switch s { | |
| 61 | case "open", "success", "approved", "active": | |
| 62 | return sgrGreen | |
| 63 | case "merged": | |
| 64 | return sgrMagenta | |
| 65 | case "failed", "failure", "error", "changes requested": | |
| 66 | return sgrRed | |
| 67 | case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled": | |
| 68 | return sgrDim | |
| 69 | } | |
| 70 | return "" | |
| 71 | } | |
| 72 | ||
| 73 | // cells is the width of s in terminal cells: SGR sequences and | |
| 74 | // combining marks take none, East Asian wide and fullwidth runes two. | |
| 75 | func cells(s string) int { | |
| 76 | n := 0 | |
| 77 | for i := 0; i < len(s); { | |
| 78 | if s[i] == 0x1b { | |
| 79 | j := strings.IndexByte(s[i:], 'm') | |
| 80 | if j < 0 { | |
| 81 | break | |
| 82 | } | |
| 83 | i += j + 1 | |
| 84 | continue | |
| 85 | } | |
| 86 | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 87 | i += size | |
| 88 | n += runeCells(r) | |
| 89 | } | |
| 90 | return n | |
| 91 | } | |
| 92 | ||
| 93 | func runeCells(r rune) int { | |
| 94 | if unicode.In(r, unicode.Mn, unicode.Me) || r == '' { | |
| 95 | return 0 | |
| 96 | } | |
| 97 | switch width.LookupRune(r).Kind() { | |
| 98 | case width.EastAsianWide, width.EastAsianFullwidth: | |
| 99 | return 2 | |
| 100 | } | |
| 101 | return 1 | |
| 102 | } | |
| 103 | ||
| 104 | // clip cuts s to at most w cells, ending in "…" when anything was cut. | |
| 105 | // s must carry no SGR sequences: colour goes on after clipping. | |
| 106 | func clip(s string, w int) string { | |
| 107 | if cells(s) <= w { | |
| 108 | return s | |
| 109 | } | |
| 110 | var b strings.Builder | |
| 111 | used := 0 | |
| 112 | for _, r := range s { | |
| 113 | rc := runeCells(r) | |
| 114 | if used+rc > w-1 { | |
| 115 | break | |
| 116 | } | |
| 117 | b.WriteRune(r) | |
| 118 | used += rc | |
| 119 | } | |
| 120 | return b.String() + "…" | |
| 121 | } | |
| 122 | ||
| 123 | // pad right-pads s with spaces to w cells. | |
| 124 | func pad(s string, w int) string { | |
| 125 | return s + strings.Repeat(" ", max(0, w-cells(s))) | |
| 126 | } | |
| 127 | ||
| 128 | // termNow is the clock ages are measured against; tests pin it. | |
| 129 | var termNow = time.Now | |
| 130 | ||
| 131 | // parseStamp reads a stored timestamp: RFC3339 as the store writes it, | |
| 132 | // or SQLite's datetime() form. | |
| 133 | func parseStamp(s string) (time.Time, bool) { | |
| 134 | for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} { | |
| 135 | if t, err := time.Parse(layout, s); err == nil { | |
| 136 | return t.UTC(), true | |
| 137 | } | |
| 138 | } | |
| 139 | return time.Time{}, false | |
| 140 | } | |
| 141 | ||
| 142 | // stamp is a stored timestamp in plain output: RFC3339 to the second. | |
| 143 | func stamp(s string) string { | |
| 144 | t, ok := parseStamp(s) | |
| 145 | if !ok { | |
| 146 | return s | |
| 147 | } | |
| 148 | return t.Format("2006-01-02T15:04:05Z") | |
| 149 | } | |
| 150 | ||
| 151 | // relAge is a stored timestamp as a table shows it at a terminal. | |
| 152 | func relAge(s string, now time.Time) string { | |
| 153 | t, ok := parseStamp(s) | |
| 154 | if !ok { | |
| 155 | return s | |
| 156 | } | |
| 157 | d := max(now.Sub(t), 0) | |
| 158 | switch { | |
| 159 | case d < time.Minute: | |
| 160 | return "just now" | |
| 161 | case d < time.Hour: | |
| 162 | return fmt.Sprintf("%dm ago", int(d/time.Minute)) | |
| 163 | case d < 24*time.Hour: | |
| 164 | return fmt.Sprintf("%dh ago", int(d/time.Hour)) | |
| 165 | case d < 14*24*time.Hour: | |
| 166 | return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) | |
| 167 | case d < 56*24*time.Hour: | |
| 168 | return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour))) | |
| 169 | } | |
| 170 | return t.Format("2006-01-02") | |
| 171 | } | |
internal/control/term_test.go added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func TestParseTerm(t *testing.T) { | |
| 9 | cases := map[string]Term{ | |
| 10 | "120": {Cols: 120}, | |
| 11 | "120,color": {Cols: 120, Color: true}, | |
| 12 | "40": {Cols: 40}, | |
| 13 | "39": {}, | |
| 14 | "": {}, | |
| 15 | "abc": {}, | |
| 16 | "80,blink": {}, | |
| 17 | "80,": {}, | |
| 18 | "5000": {}, | |
| 19 | } | |
| 20 | for in, want := range cases { | |
| 21 | if got := ParseTerm(in); got != want { | |
| 22 | t.Errorf("ParseTerm(%q) = %+v, want %+v", in, got, want) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | func TestCells(t *testing.T) { | |
| 28 | cases := map[string]int{ | |
| 29 | "abc": 3, | |
| 30 | "日本": 4, | |
| 31 | "é": 1, | |
| 32 | "é": 1, | |
| 33 | "\x1b[32mopen\x1b[0m": 4, | |
| 34 | "": 0, | |
| 35 | } | |
| 36 | for in, want := range cases { | |
| 37 | if got := cells(in); got != want { | |
| 38 | t.Errorf("cells(%q) = %d, want %d", in, got, want) | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | func TestClip(t *testing.T) { | |
| 44 | if got := clip("Dependency updates available", 14); got != "Dependency up…" { | |
| 45 | t.Errorf("clip = %q", got) | |
| 46 | } | |
| 47 | if got := clip("short", 14); got != "short" { | |
| 48 | t.Errorf("clip = %q", got) | |
| 49 | } | |
| 50 | if got := clip("日本語のタイトル", 7); got != "日本語…" { | |
| 51 | t.Errorf("clip wide = %q", got) | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | func TestStampAndRelAge(t *testing.T) { | |
| 56 | if got := stamp("2026-09-23T23:26:00.570Z"); got != "2026-09-23T23:26:00Z" { | |
| 57 | t.Errorf("stamp = %q", got) | |
| 58 | } | |
| 59 | if got := stamp("2026-09-23 23:26:00"); got != "2026-09-23T23:26:00Z" { | |
| 60 | t.Errorf("stamp sqlite = %q", got) | |
| 61 | } | |
| 62 | if got := stamp("garbage"); got != "garbage" { | |
| 63 | t.Errorf("stamp garbage = %q", got) | |
| 64 | } | |
| 65 | now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) | |
| 66 | cases := map[string]string{ | |
| 67 | "2026-09-23T11:59:30Z": "just now", | |
| 68 | "2026-09-23T11:55:00Z": "5m ago", | |
| 69 | "2026-09-23T10:00:00Z": "2h ago", | |
| 70 | "2026-09-20T12:00:00Z": "3d ago", | |
| 71 | "2026-09-02T12:00:00Z": "3w ago", | |
| 72 | "2026-06-01T12:00:00Z": "2026-06-01", | |
| 73 | "2026-09-24T12:00:00Z": "just now", | |
| 74 | "not a time": "not a time", | |
| 75 | } | |
| 76 | for in, want := range cases { | |
| 77 | if got := relAge(in, now); got != want { | |
| 78 | t.Errorf("relAge(%q) = %q, want %q", in, got, want) | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
internal/control/token.go +9 −2
| @@ -96,13 +96,20 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 96 | 96 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) |
| 97 | 97 | } |
| 98 | 98 | return c.emit(ds, func(w io.Writer) { |
| 99 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") | |
| 99 | 100 | for _, d := range ds { |
| 100 | 101 | exp := "never expires" |
| 101 | 102 | if d.ExpiresAt != nil { |
| 102 | exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339) | |
| 103 | ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano) | |
| 104 | if c.Term.Cols == 0 { | |
| 105 | exp = "expires " + stamp(ts) | |
| 106 | } else { | |
| 107 | exp = "expires " + relAge(ts, termNow()) | |
| 108 | } | |
| 103 | 109 | } |
| 104 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp) | |
| 110 | tb.row(cRef(d.Name), cState(d.Scope), cText(exp)) | |
| 105 | 111 | } |
| 112 | tb.flush() | |
| 106 | 113 | }) |
| 107 | 114 | } |
| 108 | 115 | |
internal/control/web.go +9 −1
| @@ -33,9 +33,17 @@ func runWebSessionsList(c *Ctx, args []string) int { | ||
| 33 | 33 | return c.fail(protocol.ExitFailure, "%v", err) |
| 34 | 34 | } |
| 35 | 35 | return c.emit(sessions, func(w io.Writer) { |
| 36 | tb := c.table(w, "ID", "SINCE", "UNTIL") | |
| 36 | 37 | for _, s := range sessions { |
| 37 | fmt.Fprintf(w, "%s\tsince %s\tuntil %s\n", s.ID, s.CreatedAt, s.ExpiresAt) | |
| 38 | since, until := s.CreatedAt, s.ExpiresAt | |
| 39 | if c.Term.Cols == 0 { | |
| 40 | since, until = stamp(since), stamp(until) | |
| 41 | } else { | |
| 42 | since, until = relAge(since, termNow()), relAge(until, termNow()) | |
| 43 | } | |
| 44 | tb.row(cRef(s.ID), cText("since "+since), cText("until "+until)) | |
| 38 | 45 | } |
| 46 | tb.flush() | |
| 39 | 47 | }) |
| 40 | 48 | } |
| 41 | 49 | |
internal/control/webhook.go +9 −4
| @@ -90,9 +90,11 @@ func runWebhookList(c *Ctx, args []string) int { | ||
| 90 | 90 | ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""}) |
| 91 | 91 | } |
| 92 | 92 | return c.emit(ds, func(w io.Writer) { |
| 93 | tb := c.table(w, "ID", "URL", "EVENTS") | |
| 93 | 94 | for _, d := range ds { |
| 94 | fmt.Fprintf(w, "%d\t%s\t%s\n", d.ID, d.URL, d.Events) | |
| 95 | tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events)) | |
| 95 | 96 | } |
| 97 | tb.flush() | |
| 96 | 98 | }) |
| 97 | 99 | } |
| 98 | 100 | |
| @@ -157,13 +159,16 @@ func runWebhookDeliveries(c *Ctx, args []string) int { | ||
| 157 | 159 | rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError}) |
| 158 | 160 | } |
| 159 | 161 | return c.emit(rows, func(w io.Writer) { |
| 162 | tb := c.table(w, "ID", "EVENT", "URL", "STATUS") | |
| 160 | 163 | for _, d := range rows { |
| 161 | extra := "" | |
| 164 | cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL), | |
| 165 | cState(fmt.Sprintf("%s (%d attempts)", d.Status, d.Attempts))} | |
| 162 | 166 | if d.LastError != "" { |
| 163 | extra = "\t" + d.LastError | |
| 167 | cells = append(cells, cText(d.LastError)) | |
| 164 | 168 | } |
| 165 | fmt.Fprintf(w, "%d\t%s\t%s\t%s (%d attempts)%s\n", d.ID, d.Event, d.URL, d.Status, d.Attempts, extra) | |
| 169 | tb.row(cells...) | |
| 166 | 170 | } |
| 171 | tb.flush() | |
| 167 | 172 | }) |
| 168 | 173 | } |
| 169 | 174 | |
internal/control/wiki.go +3 −1
| @@ -96,9 +96,11 @@ func runWikiList(c *Ctx, args []string) int { | ||
| 96 | 96 | d.Pages = []string{} |
| 97 | 97 | } |
| 98 | 98 | return c.emit(d, func(w io.Writer) { |
| 99 | tb := c.table(w, "PAGE") | |
| 99 | 100 | for _, p := range d.Pages { |
| 100 | fmt.Fprintln(w, p) | |
| 101 | tb.row(cRef(p)) | |
| 101 | 102 | } |
| 103 | tb.flush() | |
| 102 | 104 | }) |
| 103 | 105 | } |
| 104 | 106 | |
internal/sshd/sshd.go +13 −5
| @@ -239,6 +239,7 @@ func (s *Server) handleConn(c *conn) { | ||
| 239 | 239 | |
| 240 | 240 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { |
| 241 | 241 | defer ch.Close() |
| 242 | var term control.Term | |
| 242 | 243 | for req := range reqs { |
| 243 | 244 | switch req.Type { |
| 244 | 245 | case "exec": |
| @@ -267,7 +268,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | ||
| 267 | 268 | } |
| 268 | 269 | close(done) |
| 269 | 270 | }() |
| 270 | code := s.runExec(sconn, ch, payload.Command, done) | |
| 271 | code := s.runExec(sconn, ch, term, payload.Command, done) | |
| 271 | 272 | sendExit(ch, code) |
| 272 | 273 | return |
| 273 | 274 | case "shell": |
| @@ -275,7 +276,13 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | ||
| 275 | 276 | fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) |
| 276 | 277 | sendExit(ch, protocol.ExitUsage) |
| 277 | 278 | return |
| 278 | case "pty-req", "env": | |
| 279 | case "env": | |
| 280 | var kv struct{ Name, Value string } | |
| 281 | if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" { | |
| 282 | term = control.ParseTerm(kv.Value) | |
| 283 | } | |
| 284 | req.Reply(true, nil) | |
| 285 | case "pty-req": | |
| 279 | 286 | // Harmless; accept and ignore. |
| 280 | 287 | req.Reply(true, nil) |
| 281 | 288 | default: |
| @@ -289,7 +296,7 @@ func sendExit(ch ssh.Channel, code int) { | ||
| 289 | 296 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) |
| 290 | 297 | } |
| 291 | 298 | |
| 292 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, done <-chan struct{}) int { | |
| 299 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int { | |
| 293 | 300 | ext := sconn.Permissions.Extensions |
| 294 | 301 | if blob := ext["anon-key"]; blob != "" { |
| 295 | 302 | return s.runAnonymous(ch, blob, cmdline) |
| @@ -302,7 +309,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, | ||
| 302 | 309 | return protocol.ExitDenied |
| 303 | 310 | } |
| 304 | 311 | _ = s.st.TouchSSHKey(keyID) |
| 305 | return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], cmdline, ch, ch, ch.Stderr(), done, s.stopping) | |
| 312 | return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], term, cmdline, ch, ch, ch.Stderr(), done, s.stopping) | |
| 306 | 313 | } |
| 307 | 314 | |
| 308 | 315 | // runAnonymous handles a session from an unregistered key: the register |
| @@ -332,7 +339,7 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | ||
| 332 | 339 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 333 | 340 | // single dispatch path shared by the embedded listener and the system-sshd |
| 334 | 341 | // forced command (gitbayd shell). |
| 335 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string, | |
| 342 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string, | |
| 336 | 343 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { |
| 337 | 344 | if user.Disabled { |
| 338 | 345 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") |
| @@ -365,6 +372,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cm | ||
| 365 | 372 | User: user, |
| 366 | 373 | Scope: scope, |
| 367 | 374 | Source: source, |
| 375 | Term: term, | |
| 368 | 376 | Store: st, |
| 369 | 377 | Cfg: cfg, |
| 370 | 378 | Stdin: stdin, |