CLI output: show views and the pager !469

merged merged by cmc on 2026-09-24 15:08 UTC · krz/gitbay:cli-output-views into main

43 files changed, +1848 −220

Layout: unified · split

cmd/gitbay/main.go +1 −1
@@ -290,7 +290,7 @@ func runPass(o passOpts, args []string) int {
290290 stdin = r
291291 }
292292 }
293 return runSSH(t, append(o.server, args...), stdin)
293 return runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args))
294294}
295295
296296func isEmptyReader(r io.Reader) bool {
cmd/gitbay/ssh.go +63 −1
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strconv"
1414 "strings"
15 "syscall"
1516
1617 "gitbay.org/gitbay/internal/cliconfig"
1718 "gitbay.org/gitbay/internal/protocol"
@@ -139,9 +140,40 @@ func stripNoColor(args []string) ([]string, bool) {
139140 return out, found
140141}
141142
143// pagerArgv is the pager to run long output through: GITBAY_PAGER, then
144// PAGER, then less. An empty GITBAY_PAGER turns paging off.
145func pagerArgv(env func(string) (string, bool)) []string {
146 if v, ok := env("GITBAY_PAGER"); ok {
147 return strings.Fields(v)
148 }
149 if v, ok := env("PAGER"); ok && v != "" {
150 return strings.Fields(v)
151 }
152 return []string{"less"}
153}
154
155// pages reports whether a command's output goes through the pager at a
156// terminal: views, diffs and logs, never a follow or JSON.
157func pages(server, args []string) bool {
158 if len(server) == 0 || slices.Contains(args, "--json") || slices.Contains(args, "--follow") {
159 return false
160 }
161 switch server[len(server)-1] {
162 case "show", "diff", "log":
163 return true
164 }
165 return false
166}
167
142168// runSSH executes the server command over the system ssh binary, wiring
143// stdio through. It returns the remote exit code.
169// stdio through, with no pager. It returns the remote exit code.
144170func runSSH(t target, serverArgv []string, stdin io.Reader) int {
171 return runSSHPaged(t, serverArgv, stdin, false)
172}
173
174// runSSHPaged is runSSH with output optionally run through the pager when
175// stdout is a terminal and page is true. It returns the remote exit code.
176func runSSHPaged(t target, serverArgv []string, stdin io.Reader, page bool) int {
145177 args := sshArgs(t.inst)
146178
147179 fd := int(os.Stdout.Fd())
@@ -164,11 +196,41 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
164196 cmd.Stdin = stdin
165197 cmd.Stdout = os.Stdout
166198 cmd.Stderr = os.Stderr
199
200 var pager *exec.Cmd
201 var pw io.WriteCloser
202 if page && isTTY {
203 if argv := pagerArgv(os.LookupEnv); len(argv) > 0 {
204 pager = exec.Command(toolpath.Look(argv[0]), argv[1:]...)
205 pager.Stdout, pager.Stderr = os.Stdout, os.Stderr
206 if _, ok := os.LookupEnv("LESS"); !ok {
207 pager.Env = append(os.Environ(), "LESS=FRX")
208 }
209 if w, err := pager.StdinPipe(); err == nil && pager.Start() == nil {
210 pw = w
211 cmd.Stdout = pw
212 } else {
213 pager = nil
214 }
215 }
216 }
217
167218 err := cmd.Run()
219 if pager != nil {
220 pw.Close()
221 pager.Wait()
222 }
168223 if err == nil {
169224 return 0
170225 }
171226 if ee, ok := err.(*exec.ExitError); ok {
227 if pager != nil {
228 if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() && ws.Signal() == syscall.SIGPIPE {
229 // The user quit the pager before ssh finished writing;
230 // that is not a failure of the command itself.
231 return 0
232 }
233 }
172234 code := ee.ExitCode()
173235 if code == 255 { // ssh-level failure (connection, auth, host key)
174236 fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+
cmd/gitbay/term_test.go +42 −1
@@ -1,6 +1,9 @@
11package main
22
3import "testing"
3import (
4 "strings"
5 "testing"
6)
47
58func TestTermValue(t *testing.T) {
69 env := func(m map[string]string) func(string) string {
@@ -35,3 +38,41 @@ func TestStripNoColor(t *testing.T) {
3538 t.Errorf("got %v %v", args, ok)
3639 }
3740}
41
42func TestPagerArgv(t *testing.T) {
43 env := func(m map[string]string) func(string) (string, bool) {
44 return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
45 }
46 cases := []struct {
47 env map[string]string
48 want string
49 }{
50 {nil, "less"},
51 {map[string]string{"PAGER": "more -s"}, "more -s"},
52 {map[string]string{"PAGER": "more", "GITBAY_PAGER": "bat -p"}, "bat -p"},
53 {map[string]string{"PAGER": "more", "GITBAY_PAGER": ""}, ""},
54 }
55 for _, c := range cases {
56 if got := strings.Join(pagerArgv(env(c.env)), " "); got != c.want {
57 t.Errorf("%v: got %q want %q", c.env, got, c.want)
58 }
59 }
60}
61
62func TestPages(t *testing.T) {
63 yes := [][]string{{"issue", "show"}, {"mr", "diff"}, {"build", "log"}, {"repo", "log"}}
64 for _, s := range yes {
65 if !pages(s, nil) {
66 t.Errorf("%v should page", s)
67 }
68 }
69 if pages([]string{"build", "log"}, []string{"--follow"}) {
70 t.Error("build log --follow must not page")
71 }
72 if pages([]string{"issue", "show"}, []string{"--json"}) {
73 t.Error("--json must not page")
74 }
75 if pages([]string{"issue", "list"}, nil) {
76 t.Error("list must not page")
77 }
78}
e2e/adminusers_test.go +1 −1
@@ -165,7 +165,7 @@ func TestAdminUserListAndShow(t *testing.T) {
165165 t.Fatalf("unknown user: exit %d", code)
166166 }
167167 // Plain output carries the same facts.
168 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.Contains(out, "alice\tactive") ||
168 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.HasPrefix(out, "alice active\n") ||
169169 !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") {
170170 t.Fatalf("plain show:\n%s", out)
171171 }
e2e/gates_test.go +2 −1
@@ -3,6 +3,7 @@ package e2e
33import (
44 "os"
55 "path/filepath"
6 "regexp"
67 "strings"
78 "testing"
89)
@@ -88,7 +89,7 @@ func TestMergeGatesVisible(t *testing.T) {
8889 if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) {
8990 t.Fatalf("gates after approval:\n%s", out)
9091 }
91 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !strings.Contains(out, "gates: met; fast-forward possible") {
92 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !regexp.MustCompile(`gates\s+met; fast-forward possible`).MatchString(out) {
9293 t.Fatalf("text gates line: %s", out)
9394 }
9495 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
e2e/import_test.go +2 −1
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7 "regexp"
78 "strings"
89 "testing"
910)
@@ -63,7 +64,7 @@ func TestRepoImport(t *testing.T) {
6364 t.Fatalf("imported HEAD = %s", head)
6465 }
6566 showOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/mirror")
66 if !strings.Contains(showOut, "private") || !strings.Contains(showOut, "default: trunk") {
67 if !strings.Contains(showOut, "private") || !regexp.MustCompile(`default branch\s+trunk`).MatchString(showOut) {
6768 t.Fatalf("repo show after import: %s", showOut)
6869 }
6970
e2e/maint_test.go +2 −1
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7 "regexp"
78 "strings"
89 "testing"
910)
@@ -39,7 +40,7 @@ func TestAdminGCAndStats(t *testing.T) {
3940
4041 // Stats: counts and per-repo disk usage.
4142 out := inst.admin(t, "admin", "stats")
42 if !strings.Contains(out, "repos 2") || !strings.Contains(out, "issues 1 (1 open)") ||
43 if !regexp.MustCompile(`repos\s+2`).MatchString(out) || !strings.Contains(out, "1 (1 open)") ||
4344 !strings.Contains(out, "alice/app") || !strings.Contains(out, "alice/other") {
4445 t.Fatalf("stats output: %s", out)
4546 }
e2e/mr_test.go +1 −1
@@ -282,7 +282,7 @@ func TestMergeRequests(t *testing.T) {
282282 }
283283 // So does mr show, human and JSON.
284284 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1")
285 if code != 0 || !strings.Contains(showOut, "commit: ") {
285 if code != 0 || !strings.Contains(showOut, "add feature (amended)") {
286286 t.Fatalf("mr show missing commits: %d\n%s", code, showOut)
287287 }
288288 showJSON, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json")
e2e/pages_test.go +2 −1
@@ -8,6 +8,7 @@ import (
88 "net/http"
99 "os"
1010 "path/filepath"
11 "regexp"
1112 "strings"
1213 "sync/atomic"
1314 "testing"
@@ -244,7 +245,7 @@ func TestPages(t *testing.T) {
244245 }
245246 // repo show lists it; removal stops serving.
246247 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site")
247 if !strings.Contains(out, "pages domains: docs.example.org") {
248 if !regexp.MustCompile(`pages domains\s+docs\.example\.org`).MatchString(out) {
248249 t.Fatalf("repo show missing domains:\n%s", out)
249250 }
250251 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "remove", "alice/site", "docs.example.org"); code != 0 {
e2e/readonly_test.go +2 −7
@@ -173,13 +173,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
173173 // rawOutput prints content verbatim (a file, a log, a diff) and is
174174 // not fitted to the terminal.
175175 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)
176 "repo download": true,
177 "account export": true,
183178 }
184179 // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not
185180 // an SGR sequence escaping into plain output.
e2e/reap_test.go +3 −2
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10 "regexp"
1011 "strings"
1112 "testing"
1213 "time"
@@ -111,7 +112,7 @@ func TestAdminRunners(t *testing.T) {
111112 t.Fatal("runner next failed")
112113 }
113114 out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners")
114 if !strings.HasPrefix(out, "queue: 0 pending; last 24h: 0 claimed") || !strings.Contains(out, "\nci\t") ||
115 if !regexp.MustCompile(`pending\s+0`).MatchString(out) || !regexp.MustCompile(`claimed 24h\s+0`).MatchString(out) || !strings.Contains(out, "\nci\t") ||
115116 !strings.Contains(out, "\talice/app\tidle") {
116117 t.Fatalf("idle runner row:\n%s", out)
117118 }
@@ -142,7 +143,7 @@ func TestAdminRunners(t *testing.T) {
142143 t.Fatal("runner done failed")
143144 }
144145 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") ||
145 !strings.HasPrefix(out, "queue: 0 pending; last 24h: 1 claimed, wait avg ") {
146 !regexp.MustCompile(`pending\s+0`).MatchString(out) || !regexp.MustCompile(`claimed 24h\s+1`).MatchString(out) {
146147 t.Fatalf("runner still holds a build after done:\n%s", out)
147148 }
148149 // Host-local, the same read.
e2e/release_test.go +2 −2
@@ -49,14 +49,14 @@ func TestReleases(t *testing.T) {
4949 t.Fatalf("release edit: %s", errOut)
5050 }
5151 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0")
52 if !strings.Contains(out, "the **rebuilt** notes") || !strings.Contains(out, "First light") {
52 if !strings.Contains(out, "the rebuilt notes") || !strings.Contains(out, "First light") {
5353 t.Fatalf("edit lost a field:\n%s", out)
5454 }
5555 if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0", "--title", "'Second light'"); code != 0 {
5656 t.Fatal("title edit failed")
5757 }
5858 out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0")
59 if !strings.Contains(out, "Second light") || !strings.Contains(out, "the **rebuilt** notes") {
59 if !strings.Contains(out, "Second light") || !strings.Contains(out, "the rebuilt notes") {
6060 t.Fatalf("title edit lost notes:\n%s", out)
6161 }
6262 if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v9.9", "--title", "x"); code != 3 {
e2e/reposhowstate_test.go +2 −1
@@ -1,6 +1,7 @@
11package e2e
22
33import (
4 "regexp"
45 "strings"
56 "testing"
67)
@@ -54,7 +55,7 @@ func TestRepoShowCarriesViewerState(t *testing.T) {
5455 }
5556
5657 // Plain output carries the same.
57 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !strings.Contains(out, "watch: watching") ||
58 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !regexp.MustCompile(`watch\s+watching`).MatchString(out) ||
5859 !strings.Contains(out, "bookmarked") {
5960 t.Errorf("plain output lacks the state:\n%s", out)
6061 }
internal/control/admin.go +67 −44
@@ -249,57 +249,71 @@ func runAdminUserShow(c *Ctx, args []string) int {
249249 }
250250
251251 return c.emit(d, func(w io.Writer) {
252 fmt.Fprintf(w, "%s\t%s", d.Username, d.State)
252 admin := ""
253253 if d.Admin {
254 fmt.Fprint(w, "\tadmin")
254 admin = "yes"
255255 }
256 fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt)
257 if d.LastSeen != "" {
258 fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen)
259 }
260 fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
261 fmt.Fprintln(w, "keys:")
262 tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED")
263 for _, k := range d.Keys {
264 tk.row(cRef(" "+k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt))
265 }
266 tk.flush()
267 fmt.Fprintln(w, "emails:")
268 te := c.table(w, "ADDRESS", "STATE")
269 for _, e := range d.Emails {
270 state := "unverified"
271 if e.Verified {
272 state = "verified by " + e.VerifiedBy
256 v := c.view(w)
257 v.title(d.Username, "", d.State)
258 v.fields(
259 "admin", admin,
260 "created", c.when(d.CreatedAt),
261 "last seen", c.when(d.LastSeen),
262 "repos", fmt.Sprintf("%d", d.Repos),
263 "web sessions", fmt.Sprintf("%d", d.WebSessions),
264 )
265 if len(d.Keys) > 0 {
266 v.section("keys")
267 tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED")
268 for _, k := range d.Keys {
269 tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt))
273270 }
274 cells := []cell{cRef(" " + e.Address), cState(state)}
275 if e.Primary {
276 cells = append(cells, cText("primary"))
271 tk.flush()
272 }
273 if len(d.Emails) > 0 {
274 v.section("emails")
275 te := c.table(w, "ADDRESS", "STATE")
276 for _, e := range d.Emails {
277 state := "unverified"
278 if e.Verified {
279 state = "verified by " + e.VerifiedBy
280 }
281 cells := []cell{cRef(e.Address), cState(state)}
282 if e.Primary {
283 cells = append(cells, cText("primary"))
284 }
285 te.row(cells...)
277286 }
278 te.row(cells...)
287 te.flush()
279288 }
280 te.flush()
281 fmt.Fprintln(w, "pgp keys:")
282 tp := c.table(w, "FINGERPRINT")
283 for _, k := range d.PGPKeys {
284 tp.row(cRef(" " + k.Fingerprint))
289 if len(d.PGPKeys) > 0 {
290 v.section("pgp keys")
291 tp := c.table(w, "FINGERPRINT")
292 for _, k := range d.PGPKeys {
293 tp.row(cFlex(k.Fingerprint))
294 }
295 tp.flush()
285296 }
286 tp.flush()
287 fmt.Fprintln(w, "orgs:")
288 to := c.table(w, "ORG", "ROLE")
289 for _, o := range d.Orgs {
290 to.row(cRef(" "+o.Org), cState(o.Role))
297 if len(d.Orgs) > 0 {
298 v.section("orgs")
299 to := c.table(w, "ORG", "ROLE")
300 for _, o := range d.Orgs {
301 to.row(cRef(o.Org), cState(o.Role))
302 }
303 to.flush()
291304 }
292 to.flush()
293 fmt.Fprintln(w, "api tokens:")
294 tt := c.table(w, "NAME", "SCOPE", "LAST USED")
295 for _, t := range d.APITokens {
296 used := ""
297 if t.LastUsedAt != nil {
298 used = t.LastUsedAt.UTC().Format(time.RFC3339Nano)
305 if len(d.APITokens) > 0 {
306 v.section("api tokens")
307 tt := c.table(w, "NAME", "SCOPE", "LAST USED")
308 for _, t := range d.APITokens {
309 used := ""
310 if t.LastUsedAt != nil {
311 used = t.LastUsedAt.UTC().Format(time.RFC3339Nano)
312 }
313 tt.row(cRef(t.Name), cState(t.Scope), cAge(used))
299314 }
300 tt.row(cRef(" "+t.Name), cState(t.Scope), cAge(used))
315 tt.flush()
301316 }
302 tt.flush()
303317 })
304318}
305319
@@ -529,8 +543,17 @@ func runAdminRunners(c *Ctx, args []string) int {
529543 }
530544 d := map[string]any{"queue": queue, "runners": runners}
531545 return c.emit(d, func(w io.Writer) {
532 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
533 queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
546 v := c.view(w)
547 v.fields(
548 "pending", fmt.Sprintf("%d", queue.Pending),
549 "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h),
550 "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS),
551 "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS),
552 "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
553 )
554 if len(runners) > 0 {
555 v.section("runners")
556 }
534557 tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD")
535558 for _, r := range runners {
536559 scope := r.Scope
internal/control/adminhost.go +18 −6
@@ -302,12 +302,24 @@ func runAdminStats(c *Ctx, args []string) int {
302302 d.DBBytes = fi.Size()
303303 }
304304 return c.emit(d, func(w io.Writer) {
305 fmt.Fprintf(w, "users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n",
306 counts.Users, counts.Orgs, counts.Repos,
307 counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs)
308 fmt.Fprintf(w, "database %s · repositories %s · lfs %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes), humanBytes(d.LFSBytes))
309 for _, r := range d.Repos {
310 fmt.Fprintf(w, "%s\t%s\n", r.Path, humanBytes(r.Bytes))
305 v := c.view(w)
306 v.fields(
307 "users", fmt.Sprintf("%d", counts.Users),
308 "orgs", fmt.Sprintf("%d", counts.Orgs),
309 "repos", fmt.Sprintf("%d", counts.Repos),
310 "issues", fmt.Sprintf("%d (%d open)", counts.Issues, counts.OpenIssues),
311 "MRs", fmt.Sprintf("%d (%d open)", counts.MRs, counts.OpenMRs),
312 "database", humanBytes(d.DBBytes),
313 "repositories", humanBytes(d.RepoBytes),
314 "lfs", humanBytes(d.LFSBytes),
315 )
316 if len(d.Repos) > 0 {
317 io.WriteString(w, "\n")
318 tb := c.table(w, "PATH", "BYTES")
319 for _, r := range d.Repos {
320 tb.row(cRef(r.Path), cText(humanBytes(r.Bytes)))
321 }
322 tb.flush()
311323 }
312324 })
313325}
internal/control/build.go +10 −6
@@ -183,17 +183,21 @@ func buildSubjects(c *Ctx, repo store.Repo, ds []BuildOut) map[string]string {
183183}
184184
185185func runBuildShow(c *Ctx, args []string) int {
186 _, b, code := buildRef(c, args)
186 repo, b, code := buildRef(c, args)
187187 if code >= 0 {
188188 return code
189189 }
190190 d := buildToOut(b)
191191 return c.emit(d, func(w io.Writer) {
192 fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt)
193 if d.FinishedAt != "" {
194 fmt.Fprintf(w, ", finished %s", d.FinishedAt)
195 }
196 fmt.Fprintln(w)
192 v := c.view(w)
193 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
194 v.fields(
195 "sha", fmt.Sprintf("%.10s", d.SHA),
196 "ref", d.Ref,
197 "queued", c.when(d.CreatedAt),
198 "finished", c.when(d.FinishedAt),
199 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
200 )
197201 })
198202}
199203
internal/control/deps.go +18 −8
@@ -87,7 +87,8 @@ func runDepsStatus(c *Ctx, args []string) int {
8787 check, err := c.Store.DepCheckFor(repo.ID)
8888 if errors.Is(err, store.ErrNotFound) {
8989 return c.emit(map[string]any{"enabled": false}, func(w io.Writer) {
90 fmt.Fprintf(w, "dependency checks are off for %s (repo deps enable %s)\n", repo.Path(), repo.Path())
90 v := c.view(w)
91 v.fields("checks", fmt.Sprintf("off (repo deps enable %s)", repo.Path()))
9192 })
9293 }
9394 if err != nil {
@@ -103,15 +104,24 @@ func runDepsStatus(c *Ctx, args []string) int {
103104 out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest})
104105 }
105106 return c.emit(out, func(w io.Writer) {
106 fmt.Fprintf(w, "checks on, last %s\n", orDash(check.LastCheck))
107 if check.LastError != "" {
108 fmt.Fprintf(w, "last error: %s\n", check.LastError)
109 }
107 tracked := ""
110108 if check.IssueNumber != 0 {
111 fmt.Fprintf(w, "tracked in #%d\n", check.IssueNumber)
109 tracked = fmt.Sprintf("#%d", check.IssueNumber)
112110 }
113 for _, b := range out.Behind {
114 fmt.Fprintf(w, "%s\t%s\t%s\t-> %s\n", b.Ecosystem, b.Name, b.Current, b.Latest)
111 v := c.view(w)
112 v.fields(
113 "checks", "on",
114 "last check", c.when(check.LastCheck),
115 "last error", check.LastError,
116 "tracked in", tracked,
117 )
118 if len(out.Behind) > 0 {
119 io.WriteString(w, "\n")
120 tb := c.table(w, "ECOSYSTEM", "NAME", "CURRENT", "LATEST")
121 for _, b := range out.Behind {
122 tb.row(cText(b.Ecosystem), cRef(b.Name), cText(b.Current), cText(b.Latest))
123 }
124 tb.flush()
115125 }
116126 })
117127}
internal/control/issue.go +25 −12
@@ -226,23 +226,36 @@ func runIssueShow(c *Ctx, args []string) int {
226226 }
227227 var cs []commentOut
228228 for _, cm := range comments {
229 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
229 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind})
230230 }
231231 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
232 _ = repo
233232 return c.emit(d, func(w io.Writer) {
234 fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author)
235 if len(d.Labels) > 0 {
236 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
237 }
238 if len(d.Assignees) > 0 {
239 fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", "))
240 }
241 if d.Body != "" {
242 fmt.Fprintf(w, "\n%s\n", d.Body)
233 v := c.view(w)
234 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
235 v.fields(
236 "author", d.Author+", "+c.when(d.CreatedAt),
237 "assignees", strings.Join(d.Assignees, ", "),
238 "labels", strings.Join(d.Labels, ", "),
239 "milestone", d.Milestone,
240 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
241 )
242 v.body(d.Body, d.BodyFormat)
243 events := false
244 for _, cm := range cs {
245 if cm.Kind != "system" {
246 continue
247 }
248 if !events {
249 io.WriteString(w, "\n")
250 events = true
251 }
252 v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
243253 }
244254 for _, cm := range cs {
245 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
255 if cm.Kind == "system" {
256 continue
257 }
258 v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
246259 }
247260 })
248261}
internal/control/mr.go +120 −40
@@ -558,7 +558,7 @@ func runMRShow(c *Ctx, args []string) int {
558558 }
559559 var cs []commentOut
560560 for _, cm := range comments {
561 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
561 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind})
562562 }
563563 var rs []ReviewOut
564564 counts := ReviewersWhoCount(c.Store, repo, reviews)
@@ -602,75 +602,155 @@ func runMRShow(c *Ctx, args []string) int {
602602 if d.Draft {
603603 state = "draft"
604604 }
605 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, state, d.Author, d.Source, d.TargetRef, d.HeadSHA)
606 if len(d.Labels) > 0 {
607 fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", "))
608 }
609 if len(d.ReviewRequests) > 0 {
610 fmt.Fprintf(w, "reviewers: %s\n", strings.Join(d.ReviewRequests, ", "))
611 }
605 v := c.view(w)
606 v.title(fmt.Sprintf("!%d", d.Number), d.Title, state)
607
608 stackedOn, stacked := "", ""
612609 if d.StackedOn != nil {
613 fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title)
610 stackedOn = fmt.Sprintf("!%d %s", d.StackedOn.Number, d.StackedOn.Title)
614611 }
615612 for _, k := range d.Stacked {
616 fmt.Fprintf(w, "stacked: !%d %s\n", k.Number, k.Title)
613 if stacked != "" {
614 stacked += ", "
615 }
616 stacked += fmt.Sprintf("!%d %s", k.Number, k.Title)
617617 }
618 merged, closed, superseded := "", "", ""
618619 if d.MergedAt != "" {
619 fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy))
620 merged = c.when(d.MergedAt) + byWhom(d.MergedBy)
620621 }
621622 if d.ClosedAt != "" {
622 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
623 closed = c.when(d.ClosedAt) + byWhom(d.ClosedBy)
623624 }
624625 if d.SupersededBy != 0 {
625 fmt.Fprintf(w, "superseded by: !%d\n", d.SupersededBy)
626 superseded = fmt.Sprintf("!%d", d.SupersededBy)
627 }
628 gates := ""
629 if g := d.Gates; g != nil {
630 ff := "fast-forward possible"
631 if !g.FastForward {
632 ff = "fast-forward not possible"
633 }
634 if len(g.Unmet) == 0 {
635 gates = "met; " + ff
636 } else {
637 gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
638 }
639 }
640 unresolved := ""
641 if d.UnresolvedThreads > 0 {
642 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
643 }
644 // One fields call for every one-row fact, including the unmet
645 // gates and the commit/check/review that only has a single row
646 // to show: separate calls each compute their own key width, so
647 // keeping them in one call keeps every key aligned.
648 kv := []string{
649 "author", d.Author + ", " + c.when(d.CreatedAt),
650 "source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef),
651 "head", fmt.Sprintf("%.10s", d.HeadSHA),
652 "milestone", d.Milestone,
653 "labels", strings.Join(d.Labels, ", "),
654 "reviewers", strings.Join(d.ReviewRequests, ", "),
655 "stacked on", stackedOn,
656 "stacked", stacked,
657 "merged", merged,
658 "closed", closed,
659 "superseded by", superseded,
660 "unresolved threads", unresolved,
661 "gates", gates,
626662 }
627 if d.Body != "" {
628 fmt.Fprintf(w, "\n%s\n", d.Body)
663 if g := d.Gates; g != nil {
664 for _, u := range g.Unmet {
665 kv = append(kv, "unmet", u)
666 }
629667 }
630 for _, cm := range commits {
631 fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
668 if len(commits) == 1 {
669 kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject))
632670 }
633 for _, x := range checks {
671 if len(checks) == 1 {
672 x := checks[0]
634673 dur := ""
635674 if x.Duration != "" {
636675 dur = " in " + x.Duration
637676 }
638 fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur)
677 kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur))
639678 }
640 if d.UnresolvedThreads > 0 {
641 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
679 if len(rs) == 1 {
680 kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt))
642681 }
643 if g := d.Gates; g != nil {
644 ff := "fast-forward possible"
645 if !g.FastForward {
646 ff = "fast-forward not possible"
682 kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
683 v.fields(kv...)
684
685 v.body(d.Body, d.BodyFormat)
686
687 if len(commits) > 1 {
688 io.WriteString(w, "\n")
689 tb := c.table(w, "SHA", "SUBJECT")
690 for _, cm := range commits {
691 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
647692 }
648 if len(g.Unmet) == 0 {
649 fmt.Fprintf(w, "gates: met; %s\n", ff)
650 } else {
651 fmt.Fprintf(w, "gates: %d unmet; %s\n", len(g.Unmet), ff)
652 for _, u := range g.Unmet {
653 fmt.Fprintf(w, "gate: %s\n", u)
693 tb.flush()
694 }
695
696 if len(checks) > 1 {
697 v.section("check")
698 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
699 for _, x := range checks {
700 tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt)))
701 }
702 tb.flush()
703 }
704
705 if len(rs) > 1 {
706 v.section("review")
707 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
708 for _, r := range rs {
709 verdict := r.Verdict
710 if r.Stale {
711 verdict += " (stale)"
712 }
713 if !r.Counts {
714 verdict += " (advisory)"
654715 }
716 tb.row(cText(r.Reviewer), cState(verdict), cText(c.when(r.CreatedAt)))
655717 }
718 tb.flush()
656719 }
657 for _, r := range rs {
658 stale := ""
659 if r.Stale {
660 stale = " (stale)"
720
721 events := false
722 for _, cm := range cs {
723 if cm.Kind != "system" {
724 continue
661725 }
662 advisory := ""
663 if !r.Counts {
664 advisory = " (advisory: no write access)"
726 if !events {
727 io.WriteString(w, "\n")
728 events = true
665729 }
666 fmt.Fprintf(w, "review: %s %s%s%s at %s\n", r.Reviewer, r.Verdict, stale, advisory, r.CreatedAt)
730 v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
667731 }
668732 for _, cm := range cs {
669 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
733 if cm.Kind == "system" {
734 continue
735 }
736 v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
670737 }
671738 })
672739}
673740
741// reviewLine renders one review as fields prose: "reviewer verdict
742// (stale) (advisory) at when".
743func reviewLine(r ReviewOut) string {
744 s := r.Reviewer + " " + r.Verdict
745 if r.Stale {
746 s += " (stale)"
747 }
748 if !r.Counts {
749 s += " (advisory: no write access)"
750 }
751 return s
752}
753
674754func runMRDiff(c *Ctx, args []string) int {
675755 repo, mr, code := mrRef(c, args, policy.CanRead)
676756 if code >= 0 {
internal/control/notifications.go +6 −1
@@ -198,7 +198,12 @@ func emitNotificationSettings(c *Ctx) int {
198198 }
199199 return "off"
200200 }
201 fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push))
201 v := c.view(w)
202 v.fields(
203 "mail", onOff(mail),
204 "watch", onOff(watch),
205 "push", onOff(push),
206 )
202207 })
203208}
204209
internal/control/org.go +9 −5
@@ -123,12 +123,16 @@ func runOrgShow(c *Ctx, args []string) int {
123123 Members []memberOut `json:"members"`
124124 }{org.Name, ms}
125125 return c.emit(d, func(w io.Writer) {
126 fmt.Fprintf(w, "%s\n", d.Org)
127 tb := c.table(w, "USER", "ROLE")
128 for _, m := range ms {
129 tb.row(cRef(" "+m.User), cState(m.Role))
126 v := c.view(w)
127 v.title(d.Org, "", "")
128 if len(ms) > 0 {
129 io.WriteString(w, "\n")
130 tb := c.table(w, "USER", "ROLE")
131 for _, m := range ms {
132 tb.row(cRef(m.User), cState(m.Role))
133 }
134 tb.flush()
130135 }
131 tb.flush()
132136 })
133137}
134138
internal/control/profile.go +39 −20
@@ -240,31 +240,50 @@ func ActivityWindow() string {
240240
241241func emitProfile(c *Ctx, d ProfileOut) int {
242242 return c.emit(d, func(w io.Writer) {
243 fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
244 if d.Description != "" {
245 fmt.Fprintf(w, "%s\n", d.Description)
246 }
247 if d.Website != "" {
248 fmt.Fprintf(w, "%s\n", d.Website)
249 }
250 for _, l := range d.Links {
251 fmt.Fprintf(w, "link\t%s\t%s\n", l.Label, l.URL)
252 }
253 for _, m := range d.Orgs {
254 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
243 activity := ""
244 if d.ActivityTotal > 0 {
245 activity = fmt.Sprintf("%d in the last year", d.ActivityTotal)
255246 }
256 for _, m := range d.Members {
257 fmt.Fprintf(w, "member\t%s\t%s\n", m.Name, m.Role)
247 v := c.view(w)
248 v.title(d.Name, d.Description, d.Kind)
249 v.fields(
250 "website", d.Website,
251 "url", c.siteURL(d.Name),
252 "activity", activity,
253 )
254 if len(d.Links) > 0 {
255 v.section("link")
256 tb := c.table(w, "LINK", "URL")
257 for _, l := range d.Links {
258 tb.row(cText(l.Label), cFlex(l.URL))
259 }
260 tb.flush()
258261 }
259 for _, r := range d.Repos {
260 fmt.Fprintf(w, "repo\t%s\t%s\t%s\n", r.Path, r.Visibility, r.Description)
262 if len(d.Orgs) > 0 {
263 v.section("org")
264 tb := c.table(w, "ORG", "ROLE")
265 for _, m := range d.Orgs {
266 tb.row(cRef(m.Name), cState(m.Role))
267 }
268 tb.flush()
261269 }
262 if d.ActivityTotal > 0 {
263 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
270 if len(d.Members) > 0 {
271 v.section("member")
272 tb := c.table(w, "MEMBER", "ROLE")
273 for _, m := range d.Members {
274 tb.row(cRef(m.Name), cState(m.Role))
275 }
276 tb.flush()
264277 }
265 if d.About != "" {
266 fmt.Fprintf(w, "\n%s\n", d.About)
278 if len(d.Repos) > 0 {
279 v.section("repo")
280 tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION")
281 for _, r := range d.Repos {
282 tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description))
283 }
284 tb.flush()
267285 }
286 v.body(d.About, d.AboutFormat)
268287 })
269288}
270289
internal/control/release.go +16 −5
@@ -265,12 +265,23 @@ func runReleaseShow(c *Ctx, args []string) int {
265265 }
266266 d := releaseToOut(rel, true)
267267 return c.emit(d, func(w io.Writer) {
268 fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt)
269 if d.Notes != "" {
270 fmt.Fprintf(w, "\n%s\n", d.Notes)
268 title := d.Title
269 if title == d.Tag {
270 title = ""
271271 }
272 for _, a := range d.Assets {
273 fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256)
272 v := c.view(w)
273 v.title(d.Tag, title, "")
274 v.fields(
275 "author", d.Author+", "+c.when(d.CreatedAt),
276 )
277 v.body(d.Notes, d.NotesFormat)
278 if len(d.Assets) > 0 {
279 io.WriteString(w, "\n")
280 tb := c.table(w, "NAME", "SIZE", "SHA256")
281 for _, a := range d.Assets {
282 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256))
283 }
284 tb.flush()
274285 }
275286 })
276287}
internal/control/repo.go +43 −37
@@ -8,6 +8,7 @@ import (
88 "path"
99 "path/filepath"
1010 "slices"
11 "strconv"
1112 "strings"
1213
1314 "gitbay.org/gitbay/internal/gitutil"
@@ -361,44 +362,41 @@ func runRepoShow(c *Ctx, args []string) int {
361362 }
362363 }
363364 return c.emit(d, func(w io.Writer) {
364 line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
365 if d.Archived {
366 line += "\t[archived]"
367 }
368 fmt.Fprintln(w, line)
369 if d.Description != "" {
370 fmt.Fprintf(w, "%s\n", d.Description)
371 }
372 if d.Website != "" {
373 fmt.Fprintf(w, "website: %s\n", d.Website)
374 }
375 if len(d.Topics) > 0 {
376 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
377 }
378 if len(d.ProtectedBranches) > 0 {
379 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
380 }
381 if len(d.Domains) > 0 {
382 fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
383 }
384 if d.ForkOf != "" {
385 fmt.Fprintf(w, "fork of: %s\n", d.ForkOf)
386 }
387 if d.Watch != "" {
388 fmt.Fprintf(w, "watch: %s\n", d.Watch)
389 }
365 bookmarked, archived := "", ""
390366 if d.Bookmarked {
391 fmt.Fprintln(w, "bookmarked")
367 bookmarked = "yes"
392368 }
393 for _, m := range d.Mirrors {
394 status := "ok"
395 if m.Pending {
396 status = "pending"
397 }
398 if m.LastError != "" {
399 status = "error: " + m.LastError
369 if d.Archived {
370 archived = "yes"
371 }
372 v := c.view(w)
373 v.title(d.Path, d.Description, d.Visibility)
374 v.fields(
375 "default branch", d.DefaultBranch,
376 "website", d.Website,
377 "topics", strings.Join(d.Topics, ", "),
378 "protected", strings.Join(d.ProtectedBranches, ", "),
379 "pages domains", strings.Join(d.Domains, ", "),
380 "fork of", d.ForkOf,
381 "watch", d.Watch,
382 "bookmarked", bookmarked,
383 "archived", archived,
384 "url", c.siteURL(d.Path),
385 )
386 if len(d.Mirrors) > 0 {
387 v.section("mirror")
388 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
389 for _, m := range d.Mirrors {
390 status := "ok"
391 if m.Pending {
392 status = "pending"
393 }
394 if m.LastError != "" {
395 status = "error: " + m.LastError
396 }
397 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(m.LastSync)), cState(status))
400398 }
401 fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
399 tb.flush()
402400 }
403401 })
404402}
@@ -630,8 +628,16 @@ func runSettingsShow(c *Ctx, args []string) int {
630628 return code
631629 }
632630 return c.emit(repo.Settings, func(w io.Writer) {
633 fmt.Fprintf(w, "protected_branches: %s\nprotected_tags: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
634 strings.Join(repo.Settings.ProtectedBranches, ", "), strings.Join(repo.Settings.ProtectedTags, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
631 v := c.view(w)
632 v.title(repo.Path(), "settings", "")
633 v.fields(
634 "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
635 "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
636 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
637 "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
638 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
639 "archived", strconv.FormatBool(repo.Settings.Archived),
640 )
635641 })
636642}
637643
internal/control/snippet.go +14 −9
@@ -197,16 +197,21 @@ func runSnippetShow(c *Ctx, args []string) int {
197197 }
198198 sn.Files = files
199199 return c.emit(snippetOut(c, sn), func(w io.Writer) {
200 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
201 if sn.Description != "" {
202 fmt.Fprintf(w, "%s\n", sn.Description)
203 }
204 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
205 tb := c.table(w, "NAME", "SIZE")
206 for _, f := range files {
207 tb.row(cRef(" "+f.Name), cText(fmt.Sprintf("%d bytes", f.Size)))
200 v := c.view(w)
201 v.title(sn.PublicID, sn.Description, sn.Visibility)
202 v.fields(
203 "author", sn.OwnerName,
204 "updated", c.when(sn.UpdatedAt),
205 "url", snippetURL(c, sn),
206 )
207 if len(files) > 0 {
208 io.WriteString(w, "\n")
209 tb := c.table(w, "NAME", "SIZE")
210 for _, f := range files {
211 tb.row(cRef(f.Name), cText(fmt.Sprintf("%d bytes", f.Size)))
212 }
213 tb.flush()
208214 }
209 tb.flush()
210215 })
211216}
212217
internal/control/teams.go +10 −5
@@ -177,12 +177,17 @@ func runTeamShow(c *Ctx, args []string) int {
177177 Grants []store.TeamGrant `json:"grants,omitempty"`
178178 }{team.Name, members, grants}
179179 return c.emit(d, func(w io.Writer) {
180 fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", "))
181 tb := c.table(w, "REPO", "ROLE")
182 for _, g := range grants {
183 tb.row(cRef(g.RepoPath), cState(g.Role))
180 v := c.view(w)
181 v.title(org.Name+"/"+team.Name, "", "")
182 v.fields("members", strings.Join(members, ", "))
183 if len(grants) > 0 {
184 io.WriteString(w, "\n")
185 tb := c.table(w, "REPO", "ROLE")
186 for _, g := range grants {
187 tb.row(cRef(g.RepoPath), cState(g.Role))
188 }
189 tb.flush()
184190 }
185 tb.flush()
186191 })
187192}
188193
internal/control/thread.go +1
@@ -54,6 +54,7 @@ type commentOut struct {
5454 Body string `json:"body"`
5555 BodyFormat string `json:"body_format,omitempty"`
5656 CreatedAt string `json:"created_at"`
57 Kind string `json:"-"` // "comment" or "system"; not part of the wire shape
5758}
5859
5960// thread is what a comment command needs to know about its noun.
internal/control/view.go added +198
@@ -0,0 +1,198 @@
1package control
2
3import (
4 "io"
5 "strings"
6
7 "gitbay.org/gitbay/internal/termtext"
8)
9
10// when is a stored timestamp in a view: the web's format at a
11// terminal, RFC3339 to the second when plain.
12func (c *Ctx) when(s string) string {
13 if c.Term.Cols == 0 {
14 return stamp(s)
15 }
16 t, ok := parseStamp(s)
17 if !ok {
18 return s
19 }
20 return t.Format("2006-01-02 15:04 UTC")
21}
22
23// siteURL is the instance's address with path segments appended.
24func (c *Ctx) siteURL(parts ...string) string {
25 return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/")
26}
27
28// view lays out a show: a title line, aligned fields, a body, events,
29// comments. Plain output is the same lines without colour or wrapping.
30type view struct {
31 c *Ctx
32 w io.Writer
33 wrote bool // has this view written anything yet
34}
35
36func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
37
38func (v *view) opts() termtext.Options {
39 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
40}
41
42// sep writes a blank line before the next block, unless this view has
43// written nothing yet: fields, a body or a section as the first thing a
44// command prints (admin runners, admin stats, notifications settings
45// show, ...) does not open with an empty line.
46func (v *view) sep() {
47 if v.wrote {
48 io.WriteString(v.w, "\n")
49 }
50 v.wrote = true
51}
52
53// section prints a sub-table's label: a blank line, then the label bold
54// at a terminal or "label:" in plain. Callers skip the call entirely
55// when the table it introduces has no rows.
56func (v *view) section(label string) {
57 v.sep()
58 if v.c.Term.Cols == 0 {
59 io.WriteString(v.w, label+":\n")
60 return
61 }
62 io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n")
63}
64
65// title prints "ref title state", wrapping title+state to the
66// terminal width. Continuation lines indent under the title, and each
67// line is painted after wrapping so no SGR sequence crosses a break.
68// title or state may be "": either is skipped rather than leaving a
69// trailing blank field.
70func (v *view) title(ref, title, state string) {
71 v.sep()
72 t := v.c.Term
73 if t.Cols == 0 {
74 switch {
75 case title == "" && state == "":
76 io.WriteString(v.w, ref+"\n")
77 case state == "":
78 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n")
79 case title == "":
80 io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n")
81 default:
82 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
83 }
84 return
85 }
86 prefix := ref + " "
87 indent := strings.Repeat(" ", cells(prefix))
88 rest := title
89 if state != "" {
90 if rest != "" {
91 rest += " "
92 }
93 rest += state
94 }
95 if rest == "" {
96 io.WriteString(v.w, ref+"\n")
97 return
98 }
99 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
100 for i, line := range lines {
101 p := indent
102 if i == 0 {
103 p = prefix
104 }
105 if i < len(lines)-1 || state == "" {
106 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
107 continue
108 }
109 // Last line carries the state word, the last word overall
110 // (states are single words): keep it coloured, not bold.
111 head, last := line, line
112 if idx := strings.LastIndex(line, " "); idx >= 0 {
113 head, last = line[:idx], line[idx+1:]
114 } else {
115 head = ""
116 }
117 io.WriteString(v.w, p)
118 if head != "" {
119 io.WriteString(v.w, t.paint(sgrBold, head)+" ")
120 }
121 io.WriteString(v.w, t.paint(stateColor(state), last)+"\n")
122 }
123}
124
125// fields prints key/value pairs aligned on the widest key, skipping
126// empty values. A value that does not fit wraps, its continuation
127// lines indented to the value column.
128func (v *view) fields(kv ...string) {
129 wide := 0
130 for i := 0; i+1 < len(kv); i += 2 {
131 if kv[i+1] != "" {
132 wide = max(wide, cells(kv[i]))
133 }
134 }
135 v.sep()
136 for i := 0; i+1 < len(kv); i += 2 {
137 key, val := kv[i], kv[i+1]
138 if val == "" {
139 continue
140 }
141 prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " "
142 if v.c.Term.Cols == 0 {
143 io.WriteString(v.w, prefix+val+"\n")
144 continue
145 }
146 indent := strings.Repeat(" ", 2+wide+2)
147 for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) {
148 p := indent
149 if j == 0 {
150 p = prefix
151 }
152 io.WriteString(v.w, p+line+"\n")
153 }
154 }
155}
156
157func (v *view) body(src, format string) {
158 if strings.TrimSpace(src) == "" {
159 return
160 }
161 v.sep()
162 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
163 if line == "" {
164 io.WriteString(v.w, "\n")
165 continue
166 }
167 io.WriteString(v.w, " "+line+"\n")
168 }
169}
170
171// event is one line for a system comment: its text without link
172// targets, the time at the right edge at a terminal.
173func (v *view) event(text, format, ts string) {
174 line := "· " + termtext.Inline(text, format)
175 when := v.c.when(ts)
176 if cols := v.c.Term.Cols; cols > 0 {
177 room := cols - 2 - 2 - cells(when)
178 line = pad(clip(line, room), room)
179 }
180 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
181 v.wrote = true
182}
183
184func (v *view) comment(author, ts, body, format string) {
185 when := v.c.when(ts)
186 cols := v.c.Term.Cols
187 if cols > 0 {
188 suffix := ", " + when + " "
189 author = clip(author, max(0, cols-cells("── "+suffix)-1))
190 }
191 head := "── " + author + ", " + when + " "
192 if cols > 0 {
193 head += strings.Repeat("─", max(1, cols-cells(head)))
194 }
195 v.sep()
196 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
197 v.body(body, format)
198}
internal/control/view_test.go added +128
@@ -0,0 +1,128 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func showIssue(t *testing.T, term Term) string {
13 t.Helper()
14 st, repo, uid := newQueueTestRepo(t)
15 n, err := st.CreateIssue(repo.ID, uid, "A title", "Body with a [link](/x/y).", "md")
16 if err != nil {
17 t.Fatal(err)
18 }
19 issue, err := st.IssueByNumber(repo.ID, n)
20 if err != nil {
21 t.Fatal(err)
22 }
23 if err := st.AddIssueSystemComment(issue.ID, uid, "referenced in commit [abc1234567](/o/r/commit/abc) by [alice](/alice)"); err != nil {
24 t.Fatal(err)
25 }
26 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
27 c.Cfg.Server.SiteURL = "https://forge.test"
28 c.Term = term
29 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
30 t.Fatalf("exit %d: %s", code, errOut)
31 }
32 return c.Stdout.(*bytes.Buffer).String()
33}
34
35func TestIssueShowPlain(t *testing.T) {
36 out := showIssue(t, Term{})
37 for _, want := range []string{
38 "#1 A title open\n",
39 " author alice, ",
40 " url https://forge.test/",
41 "Body with a link (https://forge.test/x/y).",
42 " · referenced in commit abc1234567 by alice ",
43 } {
44 if !strings.Contains(out, want) {
45 t.Errorf("missing %q in:\n%s", want, out)
46 }
47 }
48 if strings.Contains(out, "\x1b") || strings.Contains(out, "](") {
49 t.Errorf("markup or SGR in plain view:\n%s", out)
50 }
51}
52
53func TestIssueShowTerminal(t *testing.T) {
54 out := showIssue(t, Term{Cols: 60, Color: true})
55 if !strings.Contains(out, sgrGreen+"open"+sgrReset) {
56 t.Errorf("state not coloured:\n%s", out)
57 }
58 if !strings.Contains(out, " UTC") {
59 t.Errorf("no web-format timestamp:\n%s", out)
60 }
61 for _, line := range strings.Split(stripSGR(out), "\n") {
62 if cells(line) > 60 {
63 t.Errorf("line over 60 cells: %q", line)
64 }
65 }
66}
67
68// TestIssueShowNarrowWraps: a long title and long labels must not push
69// any line past the terminal width, and no content is lost to it.
70func TestIssueShowNarrowWraps(t *testing.T) {
71 st, repo, uid := newQueueTestRepo(t)
72 title := strings.TrimSpace(strings.Repeat("a very long issue title that keeps going on and on ", 3))[:90]
73 n, err := st.CreateIssue(repo.ID, uid, title, "body", "md")
74 if err != nil {
75 t.Fatal(err)
76 }
77 for _, name := range []string{"needs-review-before-merge", "blocked-on-release", "requires-design-signoff"} {
78 if err := st.SetLabel(repo, name, "888888"); err != nil {
79 t.Fatal(err)
80 }
81 if err := st.SetIssueLabel(repo, issueIDFor(t, st, repo.ID, n), name, true); err != nil {
82 t.Fatal(err)
83 }
84 }
85 // No SiteURL set: the url field stays a short path so it does not
86 // itself force an unbreakable line over width, which is not what
87 // this test is about (title/labels/author wrapping is).
88 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
89 c.Term = Term{Cols: 40, Color: true}
90 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
91 t.Fatalf("exit %d: %s", code, errOut)
92 }
93 out := c.Stdout.(*bytes.Buffer).String()
94
95 var titleLines []string
96 inTitle := false
97 for _, line := range strings.Split(stripSGR(out), "\n") {
98 if cells(line) > 40 {
99 t.Errorf("line over 40 cells: %q", line)
100 }
101 switch {
102 case strings.HasPrefix(line, "#1 "):
103 inTitle = true
104 titleLines = append(titleLines, strings.TrimPrefix(line, "#1 "))
105 case inTitle && strings.HasPrefix(line, " "):
106 titleLines = append(titleLines, strings.TrimSpace(line))
107 default:
108 inTitle = false
109 }
110 }
111 got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ")
112 want := strings.Join(strings.Fields(title+" open"), " ")
113 if got != want {
114 t.Errorf("title text lost across wrap: got %q, want %q", got, want)
115 }
116 if n := strings.Count(stripSGR(out), "open"); n != 1 {
117 t.Errorf("state word appears %d times, want 1:\n%s", n, out)
118 }
119}
120
121func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 {
122 t.Helper()
123 issue, err := st.IssueByNumber(repoID, n)
124 if err != nil {
125 t.Fatal(err)
126 }
127 return issue.ID
128}
internal/control/wiki.go +24 −1
@@ -163,8 +163,31 @@ func runWikiShow(c *Ctx, args []string) int {
163163 } else {
164164 d.Content = string(raw)
165165 }
166 format := "md"
167 if ext == ".org" {
168 format = "org"
169 }
166170 return c.emit(d, func(w io.Writer) {
167 fmt.Fprint(w, d.Content)
171 // Plain: the page source verbatim, same as any other piped
172 // file read. The title/fields/body layout is terminal-only.
173 if c.Term.Cols == 0 {
174 fmt.Fprint(w, d.Content)
175 return
176 }
177 v := c.view(w)
178 v.title(repo.Path(), page, "")
179 binaryNote := ""
180 if binary {
181 binaryNote = fmt.Sprintf("%d bytes, binary", d.Size)
182 }
183 v.fields(
184 "file", d.File,
185 "binary", binaryNote,
186 "url", c.siteURL(repo.Path(), "wiki", page),
187 )
188 if !binary {
189 v.body(d.Content, format)
190 }
168191 })
169192 }
170193 return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path())
internal/termtext/markdown.go added +197
@@ -0,0 +1,197 @@
1package termtext
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/yuin/goldmark"
8 "github.com/yuin/goldmark/ast"
9 "github.com/yuin/goldmark/extension"
10 east "github.com/yuin/goldmark/extension/ast"
11 "github.com/yuin/goldmark/text"
12)
13
14// md parses as the web does (CommonMark plus GFM); raw HTML is dropped
15// there and here.
16var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
17
18func Markdown(src string, o Options) string {
19 return renderMarkdown(src, &out{o: o})
20}
21
22func renderMarkdown(src string, w *out) string {
23 source := []byte(src)
24 doc := md.Parser().Parse(text.NewReader(source))
25 r := mdRenderer{w: w, src: source}
26 r.blocks(doc, "", "")
27 return w.String()
28}
29
30// Inline is src as one line of plain text, links reduced to their
31// text, for event lines.
32func Inline(src, format string) string {
33 w := &out{noURLs: true}
34 var s string
35 if format == "org" {
36 s = renderOrg(src, w)
37 } else {
38 s = renderMarkdown(src, w)
39 }
40 return strings.Join(strings.Fields(s), " ")
41}
42
43type mdRenderer struct {
44 w *out
45 src []byte
46}
47
48// blocks renders n's children with a blank line between them. The
49// first child's first line is prefixed by first, every other line by
50// rest.
51func (r mdRenderer) blocks(n ast.Node, first, rest string) {
52 p := first
53 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
54 if c != n.FirstChild() {
55 r.w.blank()
56 }
57 r.block(c, p, rest)
58 p = rest
59 }
60}
61
62func (r mdRenderer) block(n ast.Node, first, rest string) {
63 switch n := n.(type) {
64 case *ast.Heading:
65 r.w.para(r.w.paint(sgrBold, r.inline(n)), first, rest)
66 case *ast.Paragraph:
67 r.w.para(r.inline(n), first, rest)
68 case *ast.TextBlock:
69 r.w.para(r.inline(n), first, rest)
70 case *ast.List:
71 i := n.Start
72 p := first
73 for item := n.FirstChild(); item != nil; item = item.NextSibling() {
74 marker := "• "
75 if n.IsOrdered() {
76 marker = fmt.Sprintf("%d. ", i)
77 i++
78 }
79 hang := rest + strings.Repeat(" ", cells(marker))
80 for c := item.FirstChild(); c != nil; c = c.NextSibling() {
81 if c == item.FirstChild() {
82 r.block(c, p+marker, hang)
83 } else {
84 if !n.IsTight {
85 r.w.blank()
86 }
87 r.block(c, hang, hang)
88 }
89 }
90 p = rest
91 }
92 case *ast.FencedCodeBlock:
93 r.w.code(r.lines(n), string(n.Language(r.src)), rest)
94 case *ast.CodeBlock:
95 r.w.code(r.lines(n), "", rest)
96 case *ast.Blockquote:
97 bar := r.w.paint(sgrDim, "│ ")
98 r.blocks(n, first+bar, rest+bar)
99 case *ast.ThematicBreak:
100 r.w.rule(first)
101 case *ast.HTMLBlock:
102 // Dropped, as the web drops it.
103 default:
104 // GFM tables and anything else: the source, as a code block.
105 // Tables (and their rows/cells) don't carry Lines() themselves,
106 // so span the raw source under the node instead.
107 r.w.code(r.raw(n), "", rest)
108 }
109}
110
111func (r mdRenderer) lines(n ast.Node) string {
112 var b strings.Builder
113 ls := n.Lines()
114 for i := 0; i < ls.Len(); i++ {
115 seg := ls.At(i)
116 b.Write(seg.Value(r.src))
117 }
118 return b.String()
119}
120
121// raw returns the source text spanned by n and its descendants,
122// extended to whole lines.
123func (r mdRenderer) raw(n ast.Node) string {
124 start, end := -1, -1
125 var walk func(ast.Node)
126 walk = func(x ast.Node) {
127 if x.Type() == ast.TypeBlock {
128 ls := x.Lines()
129 for i := 0; i < ls.Len(); i++ {
130 seg := ls.At(i)
131 if start == -1 || seg.Start < start {
132 start = seg.Start
133 }
134 if seg.Stop > end {
135 end = seg.Stop
136 }
137 }
138 }
139 for c := x.FirstChild(); c != nil; c = c.NextSibling() {
140 walk(c)
141 }
142 }
143 walk(n)
144 if start == -1 {
145 return ""
146 }
147 for start > 0 && r.src[start-1] != '\n' {
148 start--
149 }
150 for end < len(r.src) && r.src[end] != '\n' {
151 end++
152 }
153 return string(r.src[start:end])
154}
155
156func (r mdRenderer) inline(n ast.Node) string {
157 var b strings.Builder
158 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
159 switch c := c.(type) {
160 case *ast.Text:
161 b.Write(c.Segment.Value(r.src))
162 switch {
163 case c.HardLineBreak():
164 b.WriteString("\n")
165 case c.SoftLineBreak():
166 b.WriteString(" ")
167 }
168 case *ast.String:
169 b.Write(c.Value)
170 case *ast.CodeSpan:
171 b.WriteString(r.inline(c))
172 case *ast.Emphasis:
173 sgr := sgrUnderline
174 if c.Level == 2 {
175 sgr = sgrBold
176 }
177 b.WriteString(r.w.paint(sgr, r.inline(c)))
178 case *ast.Link:
179 b.WriteString(r.w.link(r.inline(c), string(c.Destination)))
180 case *ast.AutoLink:
181 u := string(c.URL(r.src))
182 b.WriteString(r.w.link(u, u))
183 case *ast.Image:
184 b.WriteString("[image: " + r.inline(c) + "]")
185 case *ast.RawHTML:
186 case *east.TaskCheckBox:
187 if c.IsChecked {
188 b.WriteString("[x] ")
189 } else {
190 b.WriteString("[ ] ")
191 }
192 default:
193 b.WriteString(r.inline(c))
194 }
195 }
196 return b.String()
197}
internal/termtext/markdown_test.go added +101
@@ -0,0 +1,101 @@
1package termtext
2
3import (
4 "flag"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11var update = flag.Bool("update", false, "rewrite golden files")
12
13func golden(t *testing.T, name, got string) {
14 t.Helper()
15 path := filepath.Join("testdata", name)
16 if *update {
17 os.WriteFile(path, []byte(got), 0o644)
18 }
19 want, err := os.ReadFile(path)
20 if err != nil {
21 t.Fatal(err)
22 }
23 if got != string(want) {
24 t.Errorf("%s differs:\n--- got\n%s\n--- want\n%s", name, got, want)
25 }
26}
27
28func TestMarkdownGolden(t *testing.T) {
29 src, err := os.ReadFile("testdata/doc.md")
30 if err != nil {
31 t.Fatal(err)
32 }
33 for _, o := range []struct {
34 name string
35 opt Options
36 }{
37 {"doc.md.plain.golden", Options{Base: "https://forge.test"}},
38 {"doc.md.60.golden", Options{Width: 60, Base: "https://forge.test"}},
39 {"doc.md.60color.golden", Options{Width: 60, Color: true, Base: "https://forge.test"}},
40 } {
41 golden(t, o.name, Markdown(string(src), o.opt))
42 }
43}
44
45func TestMarkdownWidth(t *testing.T) {
46 src, _ := os.ReadFile("testdata/doc.md")
47 out := Markdown(string(src), Options{Width: 60, Color: true})
48 inCode := false
49 for _, line := range strings.Split(out, "\n") {
50 plain := stripSGR(line)
51 if strings.HasPrefix(plain, " ") {
52 inCode = true
53 } else if plain != "" {
54 inCode = false
55 }
56 if !inCode && cells(plain) > 60 {
57 t.Errorf("line of %d cells: %q", cells(plain), plain)
58 }
59 }
60}
61
62// TestWrappedColorClosesAtLineEnd covers a bold span that wraps mid-run:
63// the style must not bleed across the line break onto the unstyled
64// list-hang prefix, and stripping colour must reproduce the plain
65// rendering exactly.
66func TestWrappedColorClosesAtLineEnd(t *testing.T) {
67 src := "- item with **some very long bold phrase spanning many words here** and more text after it to force a wrap"
68 color := Markdown(src, Options{Width: 30, Color: true})
69 plain := Markdown(src, Options{Width: 30})
70
71 if got := stripSGR(color); got != plain {
72 t.Errorf("stripSGR(color) = %q, want %q", got, plain)
73 }
74
75 // Both continuation lines land wholly inside the wrapped bold span
76 // (the first) or start inside it (the second): the plain prefix
77 // always comes before the reopened style, never styled itself, and
78 // a run left open at wrap time is closed again at line's end.
79 lines := strings.Split(strings.TrimRight(color, "\n"), "\n")
80 for _, i := range []int{1, 2} {
81 if !strings.HasPrefix(lines[i], " \x1b[1m") {
82 t.Errorf("line %d: styled run starts before the plain prefix, or is missing: %q", i, lines[i])
83 }
84 }
85 if !strings.HasSuffix(lines[1], sgrReset) {
86 t.Errorf("line 1: open run never closed: %q", lines[1])
87 }
88 if want := " \x1b[1mbold phrase spanning many\x1b[0m"; lines[1] != want {
89 t.Errorf("line 1 = %q, want %q", lines[1], want)
90 }
91 if want := " \x1b[1mwords here\x1b[0m and more text"; lines[2] != want {
92 t.Errorf("line 2 = %q, want %q", lines[2], want)
93 }
94}
95
96func TestInlineDropsLinkTargets(t *testing.T) {
97 got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md")
98 if got != "referenced in commit 6c4d1e1454 by cmc: landing" {
99 t.Errorf("Inline = %q", got)
100 }
101}
internal/termtext/org.go added +210
@@ -0,0 +1,210 @@
1package termtext
2
3import (
4 "bytes"
5 "errors"
6 "io"
7 "log"
8 "strings"
9
10 "github.com/niklasfasching/go-org/org"
11)
12
13func Org(src string, o Options) string {
14 return renderOrg(src, &out{o: o})
15}
16
17// renderOrg parses with the same restrictions as the web: no file is
18// ever read (#+INCLUDE, #+SETUPFILE), and parse warnings go nowhere.
19func renderOrg(src string, w *out) string {
20 c := org.New()
21 c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("org: includes are disabled") }
22 c.Log = log.New(io.Discard, "", 0)
23 doc := c.Parse(bytes.NewReader([]byte(src)), "")
24 r := orgRenderer{w: w}
25 r.nodes(doc.Nodes, "", "")
26 return w.String()
27}
28
29type orgRenderer struct{ w *out }
30
31func (r orgRenderer) nodes(ns []org.Node, first, rest string) {
32 p := first
33 wrote := false
34 for _, n := range ns {
35 if skipOrg(n) {
36 continue
37 }
38 if wrote {
39 r.w.blank()
40 }
41 r.block(n, p, rest)
42 p, wrote = rest, true
43 }
44}
45
46// skipOrg drops nodes that carry nothing to render: keywords
47// (#+INCLUDE included — ReadFile already refuses the read, but the
48// keyword itself is still a line of source, not content), property
49// drawers, comments, and the empty paragraph go-org emits for a blank
50// line that separated two blocks or ended a list item.
51func skipOrg(n org.Node) bool {
52 switch n := n.(type) {
53 case org.Keyword, org.Include, *org.PropertyDrawer, org.Comment:
54 return true
55 case org.Paragraph:
56 return len(n.Children) == 0
57 }
58 return false
59}
60
61func (r orgRenderer) block(n org.Node, first, rest string) {
62 switch n := n.(type) {
63 case org.Headline:
64 r.w.para(r.w.paint(sgrBold, r.inline(n.Title)), first, rest)
65 if len(n.Children) > 0 {
66 r.w.blank()
67 r.nodes(n.Children, rest, rest)
68 }
69 case org.Paragraph:
70 r.w.para(r.inline(n.Children), first, rest)
71 case org.List:
72 loose := looseOrgList(n)
73 p := first
74 for i, item := range n.Items {
75 if i > 0 && loose {
76 r.w.blank()
77 }
78 switch item := item.(type) {
79 case org.ListItem:
80 marker := "• "
81 if n.Kind == "ordered" {
82 marker = item.Bullet + " "
83 }
84 hang := rest + strings.Repeat(" ", cells(marker))
85 r.itemChildren(item.Children, p+marker, hang, loose)
86 case org.DescriptiveListItem:
87 term := r.w.paint(sgrBold, r.inline(item.Term))
88 r.w.para(term, p, rest)
89 r.itemChildren(item.Details, rest+" ", rest+" ", loose)
90 default:
91 r.w.code(org.String(item), "", rest)
92 }
93 p = rest
94 }
95 case org.Block:
96 switch strings.ToUpper(n.Name) {
97 case "SRC":
98 lang := ""
99 if len(n.Parameters) > 0 {
100 lang = n.Parameters[0]
101 }
102 r.w.code(org.String(n.Children...), lang, rest)
103 case "QUOTE":
104 bar := r.w.paint(sgrDim, "│ ")
105 r.nodes(n.Children, first+bar, rest+bar)
106 default:
107 r.w.code(org.String(n.Children...), "", rest)
108 }
109 case org.Example:
110 r.w.code(org.String(n.Children...), "", rest)
111 case org.HorizontalRule:
112 r.w.rule(first)
113 default:
114 r.w.code(org.String(n), "", rest)
115 }
116}
117
118// looseOrgList reports whether n's source had a blank line between
119// any two of its items. go-org marks the item before such a blank
120// with a trailing empty org.Paragraph — the same artifact skipOrg
121// drops elsewhere, checked here first since skipOrg would erase it.
122// The list's own last item gets that trailing empty paragraph too
123// whenever a blank line follows the whole list (ending it before the
124// next block), which says nothing about spacing inside the list, so
125// only non-last items are checked. A single-item list is always
126// tight by this measure: there is no gap between items to have or
127// lack a blank line.
128func looseOrgList(n org.List) bool {
129 for i := 0; i < len(n.Items)-1; i++ {
130 var children []org.Node
131 switch item := n.Items[i].(type) {
132 case org.ListItem:
133 children = item.Children
134 case org.DescriptiveListItem:
135 children = item.Details
136 default:
137 continue
138 }
139 if len(children) == 0 {
140 continue
141 }
142 if p, ok := children[len(children)-1].(org.Paragraph); ok && len(p.Children) == 0 {
143 return true
144 }
145 }
146 return false
147}
148
149// itemChildren renders a list item's children. A loose list keeps
150// nodes' blank line between them; a tight list runs them straight
151// together, so a nested list sits directly under its parent item's
152// line rather than a line below it.
153func (r orgRenderer) itemChildren(ns []org.Node, first, rest string, loose bool) {
154 if loose {
155 r.nodes(ns, first, rest)
156 return
157 }
158 p := first
159 for _, n := range ns {
160 if skipOrg(n) {
161 continue
162 }
163 r.block(n, p, rest)
164 p = rest
165 }
166}
167
168// trimOrgLineBreaks drops leading and trailing org.LineBreak nodes: a
169// paragraph that follows a keyword across a blank line, or a blank
170// line gap already handled elsewhere, otherwise renders that break as
171// the leading or trailing space inline() gives it.
172func trimOrgLineBreaks(ns []org.Node) []org.Node {
173 isBreak := func(n org.Node) bool { _, ok := n.(org.LineBreak); return ok }
174 for len(ns) > 0 && isBreak(ns[0]) {
175 ns = ns[1:]
176 }
177 for len(ns) > 0 && isBreak(ns[len(ns)-1]) {
178 ns = ns[:len(ns)-1]
179 }
180 return ns
181}
182
183func (r orgRenderer) inline(ns []org.Node) string {
184 ns = trimOrgLineBreaks(ns)
185 var b strings.Builder
186 for _, n := range ns {
187 switch n := n.(type) {
188 case org.Text:
189 b.WriteString(n.Content)
190 case org.LineBreak:
191 b.WriteString(" ")
192 case org.ExplicitLineBreak:
193 b.WriteString("\n")
194 case org.Emphasis:
195 s := r.inline(n.Content)
196 switch n.Kind {
197 case "*":
198 s = r.w.paint(sgrBold, s)
199 case "/", "_":
200 s = r.w.paint(sgrUnderline, s)
201 }
202 b.WriteString(s)
203 case org.RegularLink:
204 b.WriteString(r.w.link(r.inline(n.Description), n.URL))
205 default:
206 b.WriteString(org.String(n))
207 }
208 }
209 return b.String()
210}
internal/termtext/org_test.go added +45
@@ -0,0 +1,45 @@
1package termtext
2
3import (
4 "os"
5 "strings"
6 "testing"
7)
8
9func TestOrgGolden(t *testing.T) {
10 src, err := os.ReadFile("testdata/doc.org")
11 if err != nil {
12 t.Fatal(err)
13 }
14 golden(t, "doc.org.plain.golden", Org(string(src), Options{Base: "https://forge.test"}))
15 golden(t, "doc.org.60.golden", Org(string(src), Options{Width: 60, Base: "https://forge.test"}))
16 golden(t, "doc.org.60color.golden", Org(string(src), Options{Width: 60, Color: true, Base: "https://forge.test"}))
17}
18
19// #+INCLUDE reads nothing from the server's disk, and the keyword
20// itself renders nothing either.
21func TestOrgIncludeIsInert(t *testing.T) {
22 got := Org("#+INCLUDE: \"/etc/passwd\"\n\ntext\n", Options{})
23 if strings.Contains(got, "root:") {
24 t.Fatalf("include read a file: %q", got)
25 }
26 if strings.Contains(got, "#+INCLUDE") {
27 t.Fatalf("include rendered as text: %q", got)
28 }
29}
30
31// A keyword (dropped) followed by a blank line then a paragraph must
32// not leave the paragraph's leading LineBreak as a stray space.
33func TestOrgKeywordGapNoLeadingSpace(t *testing.T) {
34 got := Org("#+SETUPFILE: \"x\"\n\ntext\n", Options{})
35 if got != "text\n" {
36 t.Errorf("Org = %q, want %q", got, "text\n")
37 }
38}
39
40func TestOrgInlineDropsLinkTargets(t *testing.T) {
41 got := Inline("see [[https://x.test/a][the page]] now", "org")
42 if got != "see the page now" {
43 t.Errorf("Inline = %q", got)
44 }
45}
internal/termtext/termtext.go added +210
@@ -0,0 +1,210 @@
1// Package termtext renders markdown and org to text for a terminal:
2// wrapped to a width, links reduced to their text, code highlighted
3// with 16 colours. Width 0 is plain: no wrapping and no SGR, for
4// piped output.
5package termtext
6
7import (
8 "bytes"
9 "strings"
10 "unicode"
11 "unicode/utf8"
12
13 "github.com/alecthomas/chroma/v2/quick"
14 "golang.org/x/text/width"
15)
16
17type Options struct {
18 Width int
19 Color bool
20 Base string
21}
22
23func Render(src, format string, o Options) string {
24 if format == "org" {
25 return Org(src, o)
26 }
27 return Markdown(src, o)
28}
29
30const (
31 sgrReset = "\x1b[0m"
32 sgrBold = "\x1b[1m"
33 sgrDim = "\x1b[2m"
34 sgrUnderline = "\x1b[4m"
35)
36
37// out collects rendered lines. Every block goes through it so the
38// prefixes (indent, list marker, quote bar) and the wrap live in one
39// place.
40type out struct {
41 o Options
42 b strings.Builder
43 noURLs bool // links as their text only (Inline)
44}
45
46func (w *out) paint(sgr, s string) string {
47 if !w.o.Color || s == "" {
48 return s
49 }
50 return sgr + s + sgrReset
51}
52
53// para writes s wrapped to the width, the first line after first and
54// the rest after rest. Hard breaks in s ("\n") start a new line. An
55// SGR run open at a wrapped line's end is closed there and reopened
56// after the next line's prefix, so the prefix itself is never painted
57// and a style never bleeds past a line break.
58func (w *out) para(s, first, rest string) {
59 prefix := first
60 var open []string
61 for _, hard := range strings.Split(s, "\n") {
62 for _, line := range Wrap(hard, w.o.Width-cells(rest)) {
63 w.b.WriteString(prefix)
64 for _, sgr := range open {
65 w.b.WriteString(sgr)
66 }
67 w.b.WriteString(line)
68 open = sgrOpen(line, open)
69 if len(open) > 0 {
70 w.b.WriteString(sgrReset)
71 }
72 w.b.WriteString("\n")
73 prefix = rest
74 }
75 }
76}
77
78// sgrOpen scans s for SGR sequences, starting from the stack of runs
79// already open, and returns the stack still open at s's end. sgrReset
80// clears the whole stack; any other sequence pushes onto it.
81func sgrOpen(s string, open []string) []string {
82 for i := 0; i < len(s); i++ {
83 if s[i] != 0x1b {
84 continue
85 }
86 j := strings.IndexByte(s[i:], 'm')
87 if j < 0 {
88 break
89 }
90 sgr := s[i : i+j+1]
91 if sgr == sgrReset {
92 open = nil
93 } else {
94 open = append(open, sgr)
95 }
96 i += j
97 }
98 return open
99}
100
101// code writes lines verbatim under prefix plus four spaces,
102// highlighted when colour is on.
103func (w *out) code(src, lang, prefix string) {
104 src = strings.TrimRight(src, "\n")
105 if w.o.Color && w.o.Width > 0 {
106 var hb bytes.Buffer
107 if lang == "" {
108 lang = "plaintext"
109 }
110 if quick.Highlight(&hb, src, lang, "terminal16", "monokai") == nil {
111 src = strings.TrimRight(hb.String(), "\n")
112 }
113 }
114 for _, line := range strings.Split(src, "\n") {
115 w.b.WriteString(prefix + " " + line + "\n")
116 }
117}
118
119func (w *out) rule(prefix string) {
120 w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n")
121}
122
123func (w *out) blank() { w.b.WriteString("\n") }
124
125func (w *out) String() string {
126 return strings.TrimRight(w.b.String(), "\n") + "\n"
127}
128
129// link is a link as terminal text: its text, then the target when the
130// target says something the text does not. Relative targets are made
131// absolute against Base.
132func (w *out) link(text, target string) string {
133 if w.noURLs && text != "" {
134 return text
135 }
136 if strings.HasPrefix(target, "/") && w.o.Base != "" {
137 target = strings.TrimRight(w.o.Base, "/") + target
138 }
139 if text == "" {
140 return target
141 }
142 if target == "" || target == text || "mailto:"+text == target ||
143 strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text {
144 return text
145 }
146 return text + " (" + target + ")"
147}
148
149// Wrap breaks s at spaces into lines of at most width cells. A word
150// wider than width is a line of its own. width <= 0 is no wrapping.
151func Wrap(s string, width int) []string {
152 if width <= 0 {
153 return []string{s}
154 }
155 var lines []string
156 var cur string
157 for _, word := range strings.Fields(s) {
158 switch {
159 case cur == "":
160 cur = word
161 case cells(cur)+1+cells(word) <= width:
162 cur += " " + word
163 default:
164 lines = append(lines, cur)
165 cur = word
166 }
167 }
168 if cur != "" || len(lines) == 0 {
169 lines = append(lines, cur)
170 }
171 return lines
172}
173
174func cells(s string) int {
175 n := 0
176 for i := 0; i < len(s); {
177 if s[i] == 0x1b {
178 j := strings.IndexByte(s[i:], 'm')
179 if j < 0 {
180 break
181 }
182 i += j + 1
183 continue
184 }
185 r, size := utf8.DecodeRuneInString(s[i:])
186 i += size
187 switch {
188 case unicode.In(r, unicode.Mn, unicode.Me) || r == '‍':
189 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
190 n += 2
191 default:
192 n++
193 }
194 }
195 return n
196}
197
198func stripSGR(s string) string {
199 var b strings.Builder
200 for i := 0; i < len(s); i++ {
201 if s[i] == 0x1b {
202 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
203 i += j
204 continue
205 }
206 }
207 b.WriteByte(s[i])
208 }
209 return b.String()
210}
internal/termtext/testdata/doc.md added +27
@@ -0,0 +1,27 @@
1# A heading
2
3A paragraph long enough to wrap at sixty columns, with **strong** and *emphasis*, `code`, a [link](https://example.com/page), a [forge link](/krz/gitbay/issues/1), an autolink <https://example.com>, and ~~struck~~ text.
4
5- one
6- two, which is long enough that its continuation line has to hang under the text rather than the bullet
7 - nested
8
91. first
102. second
11
12- [x] done
13- [ ] open
14
15> quoted text
16
17```go
18func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
19```
20
21![alt text](/img.png)
22
23---
24
25| a | b |
26|---|---|
27| 1 | 2 |
internal/termtext/testdata/doc.md.60.golden added +30
@@ -0,0 +1,30 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with
4strong and emphasis, code, a link
5(https://example.com/page), a forge link
6(https://forge.test/krz/gitbay/issues/1), an autolink
7https://example.com, and struck text.
8
9• one
10• two, which is long enough that its continuation line has
11 to hang under the text rather than the bullet
12 • nested
13
141. first
152. second
16
17• [x] done
18• [ ] open
19
20│ quoted text
21
22 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
23
24[image: alt text]
25
26───
27
28 | a | b |
29 |---|---|
30 | 1 | 2 |
internal/termtext/testdata/doc.md.60color.golden added +30
@@ -0,0 +1,30 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with
4strong and emphasis, code, a link
5(https://example.com/page), a forge link
6(https://forge.test/krz/gitbay/issues/1), an autolink
7https://example.com, and struck text.
8
9• one
10• two, which is long enough that its continuation line has
11 to hang under the text rather than the bullet
12 • nested
13
141. first
152. second
16
17• [x] done
18• [ ] open
19
20│ quoted text
21
22 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
23
24[image: alt text]
25
26───
27
28 | a | b |
29 |---|---|
30 | 1 | 2 |
internal/termtext/testdata/doc.md.plain.golden added +25
@@ -0,0 +1,25 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with strong and emphasis, code, a link (https://example.com/page), a forge link (https://forge.test/krz/gitbay/issues/1), an autolink https://example.com, and struck text.
4
5• one
6• two, which is long enough that its continuation line has to hang under the text rather than the bullet
7 • nested
8
91. first
102. second
11
12• [x] done
13• [ ] open
14
15│ quoted text
16
17 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
18
19[image: alt text]
20
21───
22
23 | a | b |
24 |---|---|
25 | 1 | 2 |
internal/termtext/testdata/doc.org added +27
@@ -0,0 +1,27 @@
1#+TITLE: ignored keyword
2
3* A heading
4A paragraph long enough to wrap at sixty columns, with *bold*, /italic/, _underline_, =verbatim=, ~code~, a [[https://example.com/page][link]], and a bare [[https://example.com]].
5
6- one
7- two, which is long enough that its continuation line has to hang under the text rather than the bullet
8 - nested
9
101. first
112. second
12
13- term :: its description
14
15#+BEGIN_SRC go
16func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
17#+END_SRC
18
19#+BEGIN_QUOTE
20quoted text
21#+END_QUOTE
22
23-----
24
25| a | b |
26|---+---|
27| 1 | 2 |
internal/termtext/testdata/doc.org.60.golden added +26
@@ -0,0 +1,26 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold,
4italic, underline, verbatim, code, a link
5(https://example.com/page), and a bare https://example.com.
6
7• one
8• two, which is long enough that its continuation line has
9 to hang under the text rather than the bullet
10 • nested
11
121. first
132. second
14
15term
16 its description
17
18 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
19
20│ quoted text
21
22───
23
24 | a | b |
25 |---+---|
26 | 1 | 2 |
internal/termtext/testdata/doc.org.60color.golden added +26
@@ -0,0 +1,26 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold,
4italic, underline, verbatim, code, a link
5(https://example.com/page), and a bare https://example.com.
6
7• one
8• two, which is long enough that its continuation line has
9 to hang under the text rather than the bullet
10 • nested
11
121. first
132. second
14
15term
16 its description
17
18 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
19
20│ quoted text
21
22───
23
24 | a | b |
25 |---+---|
26 | 1 | 2 |
internal/termtext/testdata/doc.org.plain.golden added +23
@@ -0,0 +1,23 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold, italic, underline, verbatim, code, a link (https://example.com/page), and a bare https://example.com.
4
5• one
6• two, which is long enough that its continuation line has to hang under the text rather than the bullet
7 • nested
8
91. first
102. second
11
12term
13 its description
14
15 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
16
17│ quoted text
18
19───
20
21 | a | b |
22 |---+---|
23 | 1 | 2 |