Commit 1706e2f65b

1706e2f65b3fc165e79891d8b41502748148b6bd

parent: 211e3d4c4f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 02:42 UTC

control: view layout; issue show on it

Ref #254
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 +1 −1
@@ -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)
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 +97
@@ -0,0 +1,97 @@
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}
34
35func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
36
37func (v *view) opts() termtext.Options {
38 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
39}
40
41func (v *view) title(ref, title, state string) {
42 t := v.c.Term
43 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
44}
45
46// fields prints key/value pairs aligned on the widest key, skipping
47// empty values.
48func (v *view) fields(kv ...string) {
49 wide := 0
50 for i := 0; i+1 < len(kv); i += 2 {
51 if kv[i+1] != "" {
52 wide = max(wide, cells(kv[i]))
53 }
54 }
55 io.WriteString(v.w, "\n")
56 for i := 0; i+1 < len(kv); i += 2 {
57 if kv[i+1] == "" {
58 continue
59 }
60 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, pad(kv[i], wide))+" "+kv[i+1]+"\n")
61 }
62}
63
64func (v *view) body(src, format string) {
65 if strings.TrimSpace(src) == "" {
66 return
67 }
68 io.WriteString(v.w, "\n")
69 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
70 if line == "" {
71 io.WriteString(v.w, "\n")
72 continue
73 }
74 io.WriteString(v.w, " "+line+"\n")
75 }
76}
77
78// event is one line for a system comment: its text without link
79// targets, the time at the right edge at a terminal.
80func (v *view) event(text, format, ts string) {
81 line := "· " + termtext.Inline(text, format)
82 when := v.c.when(ts)
83 if cols := v.c.Term.Cols; cols > 0 {
84 room := cols - 2 - 2 - cells(when)
85 line = pad(clip(line, room), room)
86 }
87 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
88}
89
90func (v *view) comment(author, ts, body, format string) {
91 head := "── " + author + ", " + v.c.when(ts) + " "
92 if cols := v.c.Term.Cols; cols > 0 {
93 head += strings.Repeat("─", max(0, cols-cells(head)))
94 }
95 io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n")
96 v.body(body, format)
97}
internal/control/view_test.go added +66
@@ -0,0 +1,66 @@
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}