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 | func main() { | 19 | func main() { |
| 20 | os.Args, noColor = stripNoColor(os.Args) | ||
| 20 | if err := newRoot().Execute(); err != nil { | 21 | if err := newRoot().Execute(); err != nil { |
| 21 | fmt.Fprintln(os.Stderr, "gitbay:", err) | 22 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| 22 | os.Exit(protocol.ExitUsage) | 23 | os.Exit(protocol.ExitUsage) |
cmd/gitbay/ssh.go +43 −41
| @@ -1,8 +1,6 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "text/tabwriter" | ||
| 5 | |||
| 6 | "encoding/json" | 4 | "encoding/json" |
| 7 | "fmt" | 5 | "fmt" |
| 8 | "golang.org/x/term" | 6 | "golang.org/x/term" |
| @@ -11,6 +9,7 @@ import ( | |||
| 11 | "os/exec" | 9 | "os/exec" |
| 12 | "path/filepath" | 10 | "path/filepath" |
| 13 | "regexp" | 11 | "regexp" |
| 12 | "slices" | ||
| 14 | "strconv" | 13 | "strconv" |
| 15 | "strings" | 14 | "strings" |
| 16 | 15 | ||
| @@ -109,10 +108,52 @@ func sshArgs(inst cliconfig.Instance) []string { | |||
| 109 | return append(args, inst.SSHOptions...) | 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 | // runSSH executes the server command over the system ssh binary, wiring | 142 | // runSSH executes the server command over the system ssh binary, wiring |
| 113 | // stdio through. It returns the remote exit code. | 143 | // stdio through. It returns the remote exit code. |
| 114 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { | 144 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { |
| 115 | args := sshArgs(t.inst) | 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 | quoted := make([]string, len(serverArgv)) | 157 | quoted := make([]string, len(serverArgv)) |
| 117 | for i, a := range serverArgv { | 158 | for i, a := range serverArgv { |
| 118 | quoted[i] = shellQuote(a) | 159 | quoted[i] = shellQuote(a) |
| @@ -123,15 +164,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | |||
| 123 | cmd.Stdin = stdin | 164 | cmd.Stdin = stdin |
| 124 | cmd.Stdout = os.Stdout | 165 | cmd.Stdout = os.Stdout |
| 125 | cmd.Stderr = os.Stderr | 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 | err := cmd.Run() | 167 | err := cmd.Run() |
| 132 | if tw != nil { | ||
| 133 | tw.Flush() | ||
| 134 | } | ||
| 135 | if err == nil { | 168 | if err == nil { |
| 136 | return 0 | 169 | return 0 |
| 137 | } | 170 | } |
| @@ -204,34 +237,3 @@ func withRepo(t target, args []string) ([]string, error) { | |||
| 204 | } | 237 | } |
| 205 | return append([]string{t.repo}, args...), nil | 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 | "golang.org/x/crypto/ssh" | 8 | "golang.org/x/crypto/ssh" |
| 9 | 9 | ||
| 10 | "gitbay.org/gitbay/internal/config" | 10 | "gitbay.org/gitbay/internal/config" |
| 11 | "gitbay.org/gitbay/internal/control" | ||
| 11 | "gitbay.org/gitbay/internal/protocol" | 12 | "gitbay.org/gitbay/internal/protocol" |
| 12 | "gitbay.org/gitbay/internal/sshd" | 13 | "gitbay.org/gitbay/internal/sshd" |
| 13 | ) | 14 | ) |
| @@ -93,7 +94,7 @@ func shellCmd() *cobra.Command { | |||
| 93 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") | 94 | fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n") |
| 94 | os.Exit(protocol.ExitUsage) | 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 | st.Close() | 98 | st.Close() |
| 98 | os.Exit(code) | 99 | os.Exit(code) |
| 99 | return nil | 100 | return nil |
e2e/readonly_test.go +55
| @@ -10,7 +10,9 @@ import ( | |||
| 10 | "regexp" | 10 | "regexp" |
| 11 | "strings" | 11 | "strings" |
| 12 | "testing" | 12 | "testing" |
| 13 | "unicode" | ||
| 13 | 14 | ||
| 15 | "golang.org/x/text/width" | ||
| 14 | _ "modernc.org/sqlite" | 16 | _ "modernc.org/sqlite" |
| 15 | 17 | ||
| 16 | "gitbay.org/gitbay/internal/control" | 18 | "gitbay.org/gitbay/internal/control" |
| @@ -168,6 +170,20 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 168 | } | 170 | } |
| 169 | // Reads whose subject legitimately does not exist in this fixture. | 171 | // Reads whose subject legitimately does not exist in this fixture. |
| 170 | notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true} | 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 | dbPath := filepath.Join(inst.root, "gitbay.db") | 188 | dbPath := filepath.Join(inst.root, "gitbay.db") |
| 173 | before := dbFingerprint(t, dbPath) | 189 | before := dbFingerprint(t, dbPath) |
| @@ -197,7 +213,46 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 197 | } | 213 | } |
| 198 | } | 214 | } |
| 199 | before = after | 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 | // dbFingerprint hashes every row of every table, per table. Columns that | 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 | return out.String(), errOut.String(), code | 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 | func TestControlPlaneOverBareSSH(t *testing.T) { | 222 | func TestControlPlaneOverBareSSH(t *testing.T) { |
| 199 | t.Parallel() | 223 | t.Parallel() |
| 200 | inst := startInstance(t) | 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 | golang.org/x/image v0.46.0 | 16 | golang.org/x/image v0.46.0 |
| 17 | golang.org/x/net v0.59.0 | 17 | golang.org/x/net v0.59.0 |
| 18 | golang.org/x/term v0.46.0 | 18 | golang.org/x/term v0.46.0 |
| 19 | golang.org/x/text v0.42.0 | ||
| 19 | modernc.org/sqlite v1.59.0 | 20 | modernc.org/sqlite v1.59.0 |
| 20 | ) | 21 | ) |
| 21 | 22 | ||
| @@ -34,7 +35,6 @@ require ( | |||
| 34 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 35 | github.com/spf13/pflag v1.0.9 // indirect | 36 | github.com/spf13/pflag v1.0.9 // indirect |
| 36 | golang.org/x/sys v0.48.0 // indirect | 37 | golang.org/x/sys v0.48.0 // indirect |
| 37 | golang.org/x/text v0.42.0 // indirect | ||
| 38 | modernc.org/libc v1.75.7 // indirect | 38 | modernc.org/libc v1.75.7 // indirect |
| 39 | modernc.org/mathutil v1.7.1 // indirect | 39 | modernc.org/mathutil v1.7.1 // indirect |
| 40 | modernc.org/memory v1.12.1 // indirect | 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 | ds = append(ds, adminUserRow(u)) | 128 | ds = append(ds, adminUserRow(u)) |
| 129 | } | 129 | } |
| 130 | return c.emitPage(p, ds, next, func(w io.Writer) { | 130 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 131 | tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN") | ||
| 131 | for _, d := range ds { | 132 | for _, d := range ds { |
| 132 | mark := "" | 133 | mark := "" |
| 133 | if d.Admin { | 134 | if d.Admin { |
| 134 | mark = "admin" | 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 | fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions) | 260 | fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions) |
| 259 | fmt.Fprintln(w, "keys:") | 261 | fmt.Fprintln(w, "keys:") |
| 262 | tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") | ||
| 260 | for _, k := range d.Keys { | 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 | fmt.Fprintln(w, "emails:") | 267 | fmt.Fprintln(w, "emails:") |
| 268 | te := c.table(w, "ADDRESS", "STATE") | ||
| 264 | for _, e := range d.Emails { | 269 | for _, e := range d.Emails { |
| 265 | state := "unverified" | 270 | state := "unverified" |
| 266 | if e.Verified { | 271 | if e.Verified { |
| 267 | state = "verified by " + e.VerifiedBy | 272 | state = "verified by " + e.VerifiedBy |
| 268 | } | 273 | } |
| 269 | mark := "" | 274 | cells := []cell{cRef(" " + e.Address), cState(state)} |
| 270 | if e.Primary { | 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 | fmt.Fprintln(w, "pgp keys:") | 281 | fmt.Fprintln(w, "pgp keys:") |
| 282 | tp := c.table(w, "FINGERPRINT") | ||
| 276 | for _, k := range d.PGPKeys { | 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 | fmt.Fprintln(w, "orgs:") | 287 | fmt.Fprintln(w, "orgs:") |
| 288 | to := c.table(w, "ORG", "ROLE") | ||
| 280 | for _, o := range d.Orgs { | 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 | fmt.Fprintln(w, "api tokens:") | 293 | fmt.Fprintln(w, "api tokens:") |
| 294 | tt := c.table(w, "NAME", "SCOPE", "LAST USED") | ||
| 284 | for _, t := range d.APITokens { | 295 | for _, t := range d.APITokens { |
| 285 | used := "" | 296 | used := "" |
| 286 | if t.LastUsedAt != nil { | 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 | ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) | 394 | ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) |
| 383 | } | 395 | } |
| 384 | return c.emitPage(p, ds, next, func(w io.Writer) { | 396 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 397 | tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") | ||
| 385 | for _, d := range ds { | 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 | if d.Archived { | 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 | return c.emit(d, func(w io.Writer) { | 531 | return c.emit(d, func(w io.Writer) { |
| 518 | fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n", | 532 | fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n", |
| 519 | queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h) | 533 | queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h) |
| 534 | tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") | ||
| 520 | for _, r := range runners { | 535 | for _, r := range runners { |
| 521 | scope := r.Scope | 536 | scope := r.Scope |
| 522 | if scope == "" { | 537 | if scope == "" { |
| @@ -526,8 +541,9 @@ func runAdminRunners(c *Ctx, args []string) int { | |||
| 526 | if r.BuildNumber != 0 { | 541 | if r.BuildNumber != 0 { |
| 527 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) | 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 | return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err) | 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 | return c.emit(rows, func(w io.Writer) { | 627 | return c.emit(rows, func(w io.Writer) { |
| 628 | tb := c.table(w, "!", "HEAD") | ||
| 612 | for _, r := range rows { | 629 | for _, r := range rows { |
| 613 | if r.Head == "" { | 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 | continue | 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 | package control | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | ||
| 5 | "io" | 4 | "io" |
| 6 | "strconv" | 5 | "strconv" |
| 7 | "strings" | 6 | "strings" |
| @@ -47,13 +46,15 @@ func runAudit(c *Ctx, args []string) int { | |||
| 47 | return c.fail(protocol.ExitFailure, "%v", err) | 46 | return c.fail(protocol.ExitFailure, "%v", err) |
| 48 | } | 47 | } |
| 49 | return c.emit(entries, func(w io.Writer) { | 48 | return c.emit(entries, func(w io.Writer) { |
| 49 | tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA") | ||
| 50 | for _, e := range entries { | 50 | for _, e := range entries { |
| 51 | actor := e.Actor | 51 | actor := e.Actor |
| 52 | if actor == "" { | 52 | if actor == "" { |
| 53 | actor = "-" | 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 | ds[i].Subject = subjects[ds[i].SHA] | 159 | ds[i].Subject = subjects[ds[i].SHA] |
| 160 | } | 160 | } |
| 161 | return c.emitPage(p, ds, next, func(w io.Writer) { | 161 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 162 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") | ||
| 162 | for _, d := range ds { | 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 | out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) | 261 | out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) |
| 260 | } | 262 | } |
| 261 | return c.emit(out, func(w io.Writer) { | 263 | return c.emit(out, func(w io.Writer) { |
| 264 | tb := c.table(w, "NAME", "WHEN") | ||
| 262 | for _, j := range out { | 265 | for _, j := range out { |
| 266 | when := "on push" | ||
| 263 | switch { | 267 | switch { |
| 264 | case j.Schedule != "": | 268 | case j.Schedule != "": |
| 265 | fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule) | 269 | when = "schedule " + j.Schedule |
| 266 | case j.Tags != "": | 270 | case j.Tags != "": |
| 267 | fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags) | 271 | when = "tags " + j.Tags |
| 268 | default: | ||
| 269 | fmt.Fprintf(w, "%s\ton push\n", j.Name) | ||
| 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 | return c.fail(protocol.ExitFailure, "%v", err) | 369 | return c.fail(protocol.ExitFailure, "%v", err) |
| 366 | } | 370 | } |
| 367 | return c.emit(names, func(w io.Writer) { | 371 | return c.emit(names, func(w io.Writer) { |
| 372 | tb := c.table(w, "NAME") | ||
| 368 | for _, n := range names { | 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 | Stdout io.Writer | 27 | Stdout io.Writer |
| 28 | Stderr io.Writer | 28 | Stderr io.Writer |
| 29 | JSON bool | 29 | JSON bool |
| 30 | // Term is the client's terminal, from GITBAY_TERM. The zero value | ||
| 31 | // is plain output. | ||
| 32 | Term Term | ||
| 30 | // ViaAPI marks requests arriving over HTTP, from the token API or | 33 | // ViaAPI marks requests arriving over HTTP, from the token API or |
| 31 | // the web. Every command runs there; nothing is held back for SSH | 34 | // the web. Every command runs there; nothing is held back for SSH |
| 32 | // any more (#234). The flag stays because the rate limiter and the | 35 | // any more (#234). The flag stays because the rate limiter and the |
| @@ -40,6 +43,9 @@ type Ctx struct { | |||
| 40 | // Cmd is the command being run, set by Dispatch, so a usage error can | 43 | // Cmd is the command being run, set by Dispatch, so a usage error can |
| 41 | // print the registered usage rather than a copy of it. | 44 | // print the registered usage rather than a copy of it. |
| 42 | Cmd Command | 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 | // Done, when the surface has one, closes when nobody is reading any | 49 | // Done, when the surface has one, closes when nobody is reading any |
| 44 | // more: the SSH channel closed or the HTTP request ended. A command | 50 | // more: the SSH channel closed or the HTTP request ended. A command |
| 45 | // that runs until something happens (build log --follow) stops on it. | 51 | // that runs until something happens (build log --follow) stops on it. |
| @@ -101,6 +107,21 @@ func Lookup(argv []string) (Command, []string, bool) { | |||
| 101 | // Dispatch runs argv for an authenticated session. The dispatcher — not the | 107 | // Dispatch runs argv for an authenticated session. The dispatcher — not the |
| 102 | // handlers — enforces key scope: control commands require a full-scope key. | 108 | // handlers — enforces key scope: control commands require a full-scope key. |
| 103 | func Dispatch(c *Ctx, argv []string) int { | 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 | if len(argv) == 0 { | 125 | if len(argv) == 0 { |
| 105 | return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") | 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 | c.JSON = true | 139 | c.JSON = true |
| 119 | continue | 140 | continue |
| 120 | } | 141 | } |
| 142 | if v, ok := strings.CutPrefix(a, "--term="); ok { | ||
| 143 | c.Term = ParseTerm(v) | ||
| 144 | continue | ||
| 145 | } | ||
| 121 | args = append(args, a) | 146 | args = append(args, a) |
| 122 | } | 147 | } |
| 148 | c.Argv = args | ||
| 123 | // A runner-scoped key reaches the runner protocol and nothing else, so | 149 | // A runner-scoped key reaches the runner protocol and nothing else, so |
| 124 | // the key a CI host holds cannot administer the instance. | 150 | // the key a CI host holds cannot administer the instance. |
| 125 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { | 151 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { |
| @@ -326,7 +352,13 @@ func runHelp(c *Ctx, args []string) int { | |||
| 326 | slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) | 352 | slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) }) |
| 327 | return c.emit(matched, func(w io.Writer) { | 353 | return c.emit(matched, func(w io.Writer) { |
| 328 | for _, e := range matched { | 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 | if prefix != "" { | 362 | if prefix != "" { |
| 331 | fmt.Fprintf(w, " %s\n", e.Usage) | 363 | fmt.Fprintf(w, " %s\n", e.Usage) |
| 332 | } | 364 | } |
internal/control/cursor.go +33 −1
| @@ -6,12 +6,26 @@ import ( | |||
| 6 | "fmt" | 6 | "fmt" |
| 7 | "io" | 7 | "io" |
| 8 | "reflect" | 8 | "reflect" |
| 9 | "regexp" | ||
| 9 | "strconv" | 10 | "strconv" |
| 10 | "strings" | 11 | "strings" |
| 11 | 12 | ||
| 12 | "gitbay.org/gitbay/internal/protocol" | 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 | // Cursor pagination. A cursor is opaque to clients: base64url of | 29 | // Cursor pagination. A cursor is opaque to clients: base64url of |
| 16 | // "<kind>:<key>", where key is the sort key of the last row of the | 30 | // "<kind>:<key>", where key is the sort key of the last row of the |
| 17 | // previous page. The kind keeps a cursor minted by one command from | 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 | return c.emit(out{items, next}, func(w io.Writer) { | 135 | return c.emit(out{items, next}, func(w io.Writer) { |
| 122 | plain(w) | 136 | plain(w) |
| 123 | if next != "" { | 137 | if next == "" { |
| 138 | return | ||
| 139 | } | ||
| 140 | if c.Term.Cols == 0 { | ||
| 124 | fmt.Fprintf(w, "next\t%s\n", next) | 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 | package control | 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 | func TestCursorRoundTrip(t *testing.T) { | 32 | func TestCursorRoundTrip(t *testing.T) { |
| 6 | cur := encodeCursor("issue", "42") | 33 | cur := encodeCursor("issue", "42") |
| @@ -37,3 +64,29 @@ func TestTrimPage(t *testing.T) { | |||
| 37 | t.Fatalf("unpaged: %v next=%q", items, next) | 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 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "strconv" | 7 | "strconv" |
| 8 | "strings" | ||
| 8 | 9 | ||
| 9 | "gitbay.org/gitbay/internal/buildinfo" | 10 | "gitbay.org/gitbay/internal/buildinfo" |
| 10 | "gitbay.org/gitbay/internal/gitutil" | 11 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -163,90 +164,135 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 163 | } | 164 | } |
| 164 | 165 | ||
| 165 | return c.emit(d, func(w io.Writer) { | 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 | if d.Unread > 0 { | 204 | if d.Unread > 0 { |
| 167 | fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) | 205 | fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) |
| 168 | } | 206 | } |
| 169 | fmt.Fprintln(w, "waiting on your review:") | 207 | itemHeader := []string{"REF", "TITLE", "AUTHOR"} |
| 170 | printDashboardItems(w, d.Reviews, "!") | 208 | section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!")) |
| 171 | fmt.Fprintln(w, "assigned to you:") | 209 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) |
| 172 | printDashboardItems(w, d.Assigned, "#") | 210 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) |
| 173 | fmt.Fprintln(w, "open merge requests:") | 211 | section("open issues:", itemHeader, itemRows(d.Issues, "#")) |
| 174 | printDashboardItems(w, d.MRs, "!") | 212 | |
| 175 | fmt.Fprintln(w, "open issues:") | 213 | pinnedRows := make([][]cell, len(d.Pinned)) |
| 176 | printDashboardItems(w, d.Issues, "#") | 214 | for i, p := range d.Pinned { |
| 177 | fmt.Fprintln(w, "pinned:") | 215 | cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)} |
| 178 | if len(d.Pinned) == 0 { | ||
| 179 | fmt.Fprintln(w, " none") | ||
| 180 | } | ||
| 181 | for _, p := range d.Pinned { | ||
| 182 | mark := "" | ||
| 183 | if p.Archived { | 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:") | 221 | section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows) |
| 189 | if len(d.Activity) == 0 { | 222 | |
| 190 | fmt.Fprintln(w, " none") | 223 | activityRows := make([][]cell, len(d.Activity)) |
| 191 | } | 224 | for i, e := range d.Activity { |
| 192 | for _, e := range d.Activity { | 225 | activityRows[i] = []cell{cAge(e.CreatedAt), cText(e.Actor), cText(e.Kind), cRef(e.Repo), cFlex(string(e.Data))} |
| 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") | ||
| 198 | } | 226 | } |
| 199 | for _, b := range d.Builds { | 227 | section("recent activity:", []string{"WHEN", "ACTOR", "KIND", "REPO", "DATA"}, activityRows) |
| 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) | 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 | if d.Server != nil { | 235 | if d.Server != nil { |
| 203 | fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) | 236 | fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) |
| 204 | } | 237 | } |
| 205 | if q := d.Queues; q != nil { | 238 | if q := d.Queues; q != nil { |
| 206 | fmt.Fprintln(w, "queues:") | 239 | fmt.Fprintln(w, "queues:") |
| 240 | |||
| 207 | fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed) | 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 | for _, it := range q.Webhooks.Items { | 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 | fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed) | 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 | for _, it := range q.Mail.Items { | 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 | // The device id, not the token: a token is never echoed. | 255 | // The device id, not the token: a token is never echoed. |
| 216 | fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed) | 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 | for _, it := range q.Push.Items { | 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 | fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors) | 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 | for _, it := range q.Mirrors.Items { | 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 | fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running) | 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 | for _, it := range q.Builds.Items { | 272 | for _, it := range q.Builds.Items { |
| 226 | since := it.StartedAt | 273 | since := it.StartedAt |
| 227 | if it.Status == "pending" { | 274 | if it.Status == "pending" { |
| 228 | since = it.CreatedAt | 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 | fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors) | 286 | fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors) |
| 287 | tde := c.table(w, "REPO", "ERROR") | ||
| 233 | for _, it := range q.Deps.Items { | 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 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further | 296 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further |
| 251 | // back. | 297 | // back. |
| 252 | const feedDefaultLimit = 50 | 298 | const feedDefaultLimit = 50 |
| @@ -292,8 +338,10 @@ func runFeed(c *Ctx, args []string) int { | |||
| 292 | }) | 338 | }) |
| 293 | ds := feedOutputs(events) | 339 | ds := feedOutputs(events) |
| 294 | return c.emitPage(p, ds, next, func(w io.Writer) { | 340 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 341 | tb := c.table(w, "WHEN", "ACTOR", "KIND", "REPO", "DATA") | ||
| 295 | for _, d := range ds { | 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 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label}) | 98 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label}) |
| 99 | } | 99 | } |
| 100 | return c.emit(ds, func(w io.Writer) { | 100 | return c.emit(ds, func(w io.Writer) { |
| 101 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL") | ||
| 101 | for _, d := range ds { | 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 | package control | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | ||
| 5 | "io" | 4 | "io" |
| 6 | "strings" | 5 | "strings" |
| 7 | 6 | ||
| @@ -75,9 +74,11 @@ func runExplore(c *Ctx, args []string) int { | |||
| 75 | } | 74 | } |
| 76 | ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) | 75 | ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) |
| 77 | return c.emitPage(p, ds, next, func(w io.Writer) { | 76 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 77 | tb := c.table(w, "PATH", "DESCRIPTION") | ||
| 78 | for _, d := range ds { | 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 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label}) | 83 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label}) |
| 84 | } | 84 | } |
| 85 | return c.emit(ds, func(w io.Writer) { | 85 | return c.emit(ds, func(w io.Writer) { |
| 86 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") | ||
| 86 | for _, d := range ds { | 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 | ds = append(ds, issueToOut(i, false)) | 204 | ds = append(ds, issueToOut(i, false)) |
| 205 | } | 205 | } |
| 206 | return c.emitPage(p, ds, next, func(w io.Writer) { | 206 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 207 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") | ||
| 207 | for _, d := range ds { | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 46 | return c.fail(protocol.ExitFailure, "%v", err) |
| 47 | } | 47 | } |
| 48 | return c.emit(labels, func(w io.Writer) { | 48 | return c.emit(labels, func(w io.Writer) { |
| 49 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") | ||
| 49 | for _, l := range labels { | 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 | ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems}) | 114 | ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems}) |
| 115 | } | 115 | } |
| 116 | return c.emit(ds, func(w io.Writer) { | 116 | return c.emit(ds, func(w io.Writer) { |
| 117 | tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS") | ||
| 117 | for _, d := range ds { | 118 | for _, d := range ds { |
| 118 | due := d.Due | 119 | due := d.Due |
| 119 | if due == "" { | 120 | if due == "" { |
| 120 | due = "-" | 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 | if d.Org { | 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 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | 243 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 242 | ts := IssueTemplates(dir, repo.DefaultBranch) | 244 | ts := IssueTemplates(dir, repo.DefaultBranch) |
| 243 | return c.emit(ts, func(w io.Writer) { | 245 | return c.emit(ts, func(w io.Writer) { |
| 246 | tb := c.table(w, "NAME") | ||
| 244 | for _, t := range ts { | 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 | ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError}) | 101 | ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError}) |
| 102 | } | 102 | } |
| 103 | return c.emit(ds, func(w io.Writer) { | 103 | return c.emit(ds, func(w io.Writer) { |
| 104 | tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS") | ||
| 104 | for _, d := range ds { | 105 | for _, d := range ds { |
| 105 | status := "ok" | 106 | status := "ok" |
| 106 | if d.Pending { | 107 | if d.Pending { |
| @@ -109,8 +110,9 @@ func runMirrorList(c *Ctx, args []string) int { | |||
| 109 | if d.LastError != "" { | 110 | if d.LastError != "" { |
| 110 | status = "error: " + d.LastError | 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 | ds = append(ds, o) | 499 | ds = append(ds, o) |
| 500 | } | 500 | } |
| 501 | return c.emitPage(p, ds, next, func(w io.Writer) { | 501 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 502 | tb := c.table(w, "!", "STATE", "TITLE", "REF") | ||
| 502 | for _, d := range ds { | 503 | for _, d := range ds { |
| 503 | stacked := "" | ||
| 504 | if d.StackedOn != nil { | ||
| 505 | stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number) | ||
| 506 | } | ||
| 507 | state := d.State | 504 | state := d.State |
| 508 | if d.Draft { | 505 | if d.Draft { |
| 509 | state = "draft" | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 1705 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1704 | } | 1706 | } |
| 1705 | return c.emit(revs, func(w io.Writer) { | 1707 | return c.emit(revs, func(w io.Writer) { |
| 1708 | tb := c.table(w, "REV", "SHA", "WHEN") | ||
| 1706 | for _, r := range revs { | 1709 | for _, r := range revs { |
| 1707 | mark := " " | 1710 | mark := " " |
| 1708 | if r.Current { | 1711 | if r.Current { |
| 1709 | mark = "*" | 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 | if len(revs) < 2 { | 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 | repo.Path(), mr.Number) | 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 | Token: ShortToken(d.Token), Added: d.CreatedAt}) | 303 | Token: ShortToken(d.Token), Added: d.CreatedAt}) |
| 304 | } | 304 | } |
| 305 | return c.emit(rows, func(w io.Writer) { | 305 | return c.emit(rows, func(w io.Writer) { |
| 306 | tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED") | ||
| 306 | for _, r := range rows { | 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 | ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt}) | 383 | ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt}) |
| 382 | } | 384 | } |
| 383 | return c.emitPage(p, ds, next, func(w io.Writer) { | 385 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 386 | tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") | ||
| 384 | for _, d := range ds { | 387 | for _, d := range ds { |
| 385 | mark := "*" | 388 | mark := "*" |
| 386 | if d.ReadAt != "" { | 389 | if d.ReadAt != "" { |
| 387 | mark = " " | 390 | mark = " " |
| 388 | } | 391 | } |
| 389 | fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n", | 392 | tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo), |
| 390 | mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path) | 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 | ds = append(ds, out{o.Username, o.Role}) | 87 | ds = append(ds, out{o.Username, o.Role}) |
| 88 | } | 88 | } |
| 89 | return c.emit(ds, func(w io.Writer) { | 89 | return c.emit(ds, func(w io.Writer) { |
| 90 | tb := c.table(w, "ORG", "ROLE") | ||
| 90 | for _, d := range ds { | 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 | }{org.Name, ms} | 124 | }{org.Name, ms} |
| 123 | return c.emit(d, func(w io.Writer) { | 125 | return c.emit(d, func(w io.Writer) { |
| 124 | fmt.Fprintf(w, "%s\n", d.Org) | 126 | fmt.Fprintf(w, "%s\n", d.Org) |
| 127 | tb := c.table(w, "USER", "ROLE") | ||
| 125 | for _, m := range ms { | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 128 | return c.fail(protocol.ExitFailure, "%v", err) |
| 129 | } | 129 | } |
| 130 | return c.emit(labels, func(w io.Writer) { | 130 | return c.emit(labels, func(w io.Writer) { |
| 131 | tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS") | ||
| 131 | for _, l := range labels { | 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 | list = append(list, out{d.Domain, state, d.VerifiedAt}) | 213 | list = append(list, out{d.Domain, state, d.VerifiedAt}) |
| 214 | } | 214 | } |
| 215 | return c.emit(list, func(w io.Writer) { | 215 | return c.emit(list, func(w io.Writer) { |
| 216 | tb := c.table(w, "DOMAIN", "STATE") | ||
| 216 | for _, d := range list { | 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 | d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA}) | 79 | d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA}) |
| 80 | } | 80 | } |
| 81 | return c.emit(d, func(w io.Writer) { | 81 | return c.emit(d, func(w io.Writer) { |
| 82 | tb := c.table(w, "KIND", "NAME", "SHA") | ||
| 82 | for _, ref := range d.Branches { | 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 | for _, ref := range d.Tags { | 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 | d.Entries = append(d.Entries, eo) | 285 | d.Entries = append(d.Entries, eo) |
| 284 | } | 286 | } |
| 285 | return c.emit(d, func(w io.Writer) { | 287 | return c.emit(d, func(w io.Writer) { |
| 288 | tb := c.table(w, "SHA", "SIZE", "NAME") | ||
| 286 | for _, e := range d.Entries { | 289 | for _, e := range d.Entries { |
| 287 | name := e.Name | 290 | name := e.Name |
| 288 | if e.Type == "tree" { | 291 | if e.Type == "tree" { |
| 289 | name += "/" | 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 | ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) | 62 | ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) |
| 63 | } | 63 | } |
| 64 | return c.emit(ds, func(w io.Writer) { | 64 | return c.emit(ds, func(w io.Writer) { |
| 65 | tb := c.table(w, "ADDRESS", "STATE") | ||
| 65 | for _, d := range ds { | 66 | for _, d := range ds { |
| 66 | state := "unverified" | 67 | state := "unverified" |
| 67 | if d.Verified { | 68 | if d.Verified { |
| 68 | state = "verified" | 69 | state = "verified" |
| 69 | } | 70 | } |
| 71 | cells := []cell{cRef(d.Address), cState(state)} | ||
| 70 | if d.Primary { | 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 | ds = append(ds, releaseToOut(r, false)) | 246 | ds = append(ds, releaseToOut(r, false)) |
| 247 | } | 247 | } |
| 248 | return c.emitPage(p, ds, next, func(w io.Writer) { | 248 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 249 | tb := c.table(w, "TAG", "TITLE", "ASSETS") | ||
| 249 | for _, d := range ds { | 250 | for _, d := range ds { |
| 250 | title := d.Title | 251 | title := d.Title |
| 251 | if title == d.Tag { | 252 | if title == d.Tag { |
| 252 | title = "" | 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 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) | 276 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) |
| 277 | } | 277 | } |
| 278 | return c.emitPage(p, ds, next, func(w io.Writer) { | 278 | return c.emitPage(p, ds, next, func(w io.Writer) { |
| 279 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") | ||
| 279 | for _, d := range ds { | 280 | for _, d := range ds { |
| 280 | mark := "" | 281 | cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} |
| 281 | if d.Archived { | 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 | ds = append(ds, out{e.Username, e.Role, e.Source}) | 613 | ds = append(ds, out{e.Username, e.Role, e.Source}) |
| 612 | } | 614 | } |
| 613 | return c.emit(ds, func(w io.Writer) { | 615 | return c.emit(ds, func(w io.Writer) { |
| 616 | tb := c.table(w, "USER", "ROLE", "SOURCE") | ||
| 614 | for _, d := range ds { | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 803 | return c.fail(protocol.ExitFailure, "%v", err) |
| 800 | } | 804 | } |
| 801 | return c.emit(topics, func(w io.Writer) { | 805 | return c.emit(topics, func(w io.Writer) { |
| 806 | tb := c.table(w, "TOPIC") | ||
| 802 | for _, t := range topics { | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 862 | return c.fail(protocol.ExitFailure, "%v", err) |
| 857 | } | 863 | } |
| 858 | return c.emit(now, func(w io.Writer) { | 864 | return c.emit(now, func(w io.Writer) { |
| 865 | tb := c.table(w, "TOPIC") | ||
| 859 | for _, t := range now { | 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 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) | 910 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) |
| 903 | } | 911 | } |
| 904 | return c.emit(ds, func(w io.Writer) { | 912 | return c.emit(ds, func(w io.Writer) { |
| 913 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") | ||
| 905 | for _, d := range ds { | 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 | return c.emit(out, func(w io.Writer) { | 1082 | return c.emit(out, func(w io.Writer) { |
| 1083 | tb := c.table(w, "PATH", "COUNT", "DESCRIPTION") | ||
| 1073 | for _, b := range out { | 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 | runners = []store.RepoRunner{} | 109 | runners = []store.RepoRunner{} |
| 110 | } | 110 | } |
| 111 | return c.emit(runners, func(w io.Writer) { | 111 | return c.emit(runners, func(w io.Writer) { |
| 112 | tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD") | ||
| 112 | for _, r := range runners { | 113 | for _, r := range runners { |
| 113 | seen := r.LastSeen | 114 | seen := r.LastSeen |
| 114 | if seen == "" { | 115 | if seen == "" { |
| @@ -118,8 +119,9 @@ func runRepoRunnerList(c *Ctx, args []string) int { | |||
| 118 | if r.BuildNumber != 0 { | 119 | if r.BuildNumber != 0 { |
| 119 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) | 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 | if err != nil { | 97 | if err != nil { |
| 98 | return c.fail(protocol.ExitFailure, "%v", err) | 98 | return c.fail(protocol.ExitFailure, "%v", err) |
| 99 | } | 99 | } |
| 100 | return c.emit(results, func(w io.Writer) { | 100 | return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) }) |
| 101 | for _, r := range results { | 101 | } |
| 102 | switch r.Kind { | 102 | |
| 103 | case "repo": | 103 | // writeSearchTable renders search results: a repo hit has no ref number or |
| 104 | fmt.Fprintf(w, "repo\t%s\t%s\n", r.Repo, r.Title) | 104 | // state, an issue/mr hit has both. Plain output keeps a repo row at 3 |
| 105 | default: | 105 | // cells, matching what it always printed. At a terminal the header still |
| 106 | fmt.Fprintf(w, "%s\t%s%s%d\t%s\t%s\n", r.Kind, r.Repo, | 106 | // reserves a STATE column, so a repo row gets an empty state cell there — |
| 107 | SearchMarker(r.Kind), r.Number, r.State, r.Title) | 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 | // SearchMarker is the sigil a result's number carries, shared with the web | 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 | ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) | 80 | ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) |
| 81 | } | 81 | } |
| 82 | return c.emit(ds, func(w io.Writer) { | 82 | return c.emit(ds, func(w io.Writer) { |
| 83 | tb := c.table(w, "FINGERPRINT", "EMAILS") | ||
| 83 | for _, d := range ds { | 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 | ds = append(ds, d) | 213 | ds = append(ds, d) |
| 212 | } | 214 | } |
| 213 | return c.emit(ds, func(w io.Writer) { | 215 | return c.emit(ds, func(w io.Writer) { |
| 216 | tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") | ||
| 214 | for _, d := range ds { | 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 | fmt.Fprintf(w, "%s\n", sn.Description) | 202 | fmt.Fprintf(w, "%s\n", sn.Description) |
| 203 | } | 203 | } |
| 204 | fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt) | 204 | fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt) |
| 205 | tb := c.table(w, "NAME", "SIZE") | ||
| 205 | for _, f := range files { | 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 | items = append(items, snippetOut(c, sn)) | 240 | items = append(items, snippetOut(c, sn)) |
| 239 | } | 241 | } |
| 240 | return c.emitPage(p, items, next, func(w io.Writer) { | 242 | return c.emitPage(p, items, next, func(w io.Writer) { |
| 243 | tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION") | ||
| 241 | for _, sn := range rows { | 244 | for _, sn := range rows { |
| 242 | names := "" | 245 | names := "" |
| 243 | for i, f := range sn.Files { | 246 | for i, f := range sn.Files { |
| @@ -246,8 +249,9 @@ func runSnippetList(c *Ctx, args []string) int { | |||
| 246 | } | 249 | } |
| 247 | names += f.Name | 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 | Statuses []out `json:"statuses"` | 118 | Statuses []out `json:"statuses"` |
| 119 | }{full, combinedOf(statuses), ds} | 119 | }{full, combinedOf(statuses), ds} |
| 120 | return c.emit(d, func(w io.Writer) { | 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 | for _, x := range ds { | 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 | names = append(names, t.Name) | 143 | names = append(names, t.Name) |
| 144 | } | 144 | } |
| 145 | return c.emit(names, func(w io.Writer) { | 145 | return c.emit(names, func(w io.Writer) { |
| 146 | tb := c.table(w, "TEAM") | ||
| 146 | for _, n := range names { | 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 | }{team.Name, members, grants} | 178 | }{team.Name, members, grants} |
| 177 | return c.emit(d, func(w io.Writer) { | 179 | return c.emit(d, func(w io.Writer) { |
| 178 | fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", ")) | 180 | fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", ")) |
| 181 | tb := c.table(w, "REPO", "ROLE") | ||
| 179 | for _, g := range grants { | 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 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) | 96 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) |
| 97 | } | 97 | } |
| 98 | return c.emit(ds, func(w io.Writer) { | 98 | return c.emit(ds, func(w io.Writer) { |
| 99 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") | ||
| 99 | for _, d := range ds { | 100 | for _, d := range ds { |
| 100 | exp := "never expires" | 101 | exp := "never expires" |
| 101 | if d.ExpiresAt != nil { | 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 | return c.fail(protocol.ExitFailure, "%v", err) | 33 | return c.fail(protocol.ExitFailure, "%v", err) |
| 34 | } | 34 | } |
| 35 | return c.emit(sessions, func(w io.Writer) { | 35 | return c.emit(sessions, func(w io.Writer) { |
| 36 | tb := c.table(w, "ID", "SINCE", "UNTIL") | ||
| 36 | for _, s := range sessions { | 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 | ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""}) | 90 | ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""}) |
| 91 | } | 91 | } |
| 92 | return c.emit(ds, func(w io.Writer) { | 92 | return c.emit(ds, func(w io.Writer) { |
| 93 | tb := c.table(w, "ID", "URL", "EVENTS") | ||
| 93 | for _, d := range ds { | 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 | rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError}) | 159 | rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError}) |
| 158 | } | 160 | } |
| 159 | return c.emit(rows, func(w io.Writer) { | 161 | return c.emit(rows, func(w io.Writer) { |
| 162 | tb := c.table(w, "ID", "EVENT", "URL", "STATUS") | ||
| 160 | for _, d := range rows { | 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 | if d.LastError != "" { | 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 | d.Pages = []string{} | 96 | d.Pages = []string{} |
| 97 | } | 97 | } |
| 98 | return c.emit(d, func(w io.Writer) { | 98 | return c.emit(d, func(w io.Writer) { |
| 99 | tb := c.table(w, "PAGE") | ||
| 99 | for _, p := range d.Pages { | 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 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { | 240 | func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { |
| 241 | defer ch.Close() | 241 | defer ch.Close() |
| 242 | var term control.Term | ||
| 242 | for req := range reqs { | 243 | for req := range reqs { |
| 243 | switch req.Type { | 244 | switch req.Type { |
| 244 | case "exec": | 245 | case "exec": |
| @@ -267,7 +268,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | |||
| 267 | } | 268 | } |
| 268 | close(done) | 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 | sendExit(ch, code) | 272 | sendExit(ch, code) |
| 272 | return | 273 | return |
| 273 | case "shell": | 274 | case "shell": |
| @@ -275,7 +276,13 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha | |||
| 275 | fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) | 276 | fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) |
| 276 | sendExit(ch, protocol.ExitUsage) | 277 | sendExit(ch, protocol.ExitUsage) |
| 277 | return | 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 | // Harmless; accept and ignore. | 286 | // Harmless; accept and ignore. |
| 280 | req.Reply(true, nil) | 287 | req.Reply(true, nil) |
| 281 | default: | 288 | default: |
| @@ -289,7 +296,7 @@ func sendExit(ch ssh.Channel, code int) { | |||
| 289 | ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) | 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 | ext := sconn.Permissions.Extensions | 300 | ext := sconn.Permissions.Extensions |
| 294 | if blob := ext["anon-key"]; blob != "" { | 301 | if blob := ext["anon-key"]; blob != "" { |
| 295 | return s.runAnonymous(ch, blob, cmdline) | 302 | return s.runAnonymous(ch, blob, cmdline) |
| @@ -302,7 +309,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, | |||
| 302 | return protocol.ExitDenied | 309 | return protocol.ExitDenied |
| 303 | } | 310 | } |
| 304 | _ = s.st.TouchSSHKey(keyID) | 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 | // runAnonymous handles a session from an unregistered key: the register | 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 | // Exec runs one SSH exec command line for an authenticated key. It is the | 339 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 333 | // single dispatch path shared by the embedded listener and the system-sshd | 340 | // single dispatch path shared by the embedded listener and the system-sshd |
| 334 | // forced command (gitbayd shell). | 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 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { | 343 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { |
| 337 | if user.Disabled { | 344 | if user.Disabled { |
| 338 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") | 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 | User: user, | 372 | User: user, |
| 366 | Scope: scope, | 373 | Scope: scope, |
| 367 | Source: source, | 374 | Source: source, |
| 375 | Term: term, | ||
| 368 | Store: st, | 376 | Store: st, |
| 369 | Cfg: cfg, | 377 | Cfg: cfg, |
| 370 | Stdin: stdin, | 378 | Stdin: stdin, |