Commit c19814239d

c19814239d4868a0f894a1286752d98986525880

parent: dcbfc5a2c8

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:25 UTC

control: issue show as a screen; system events in their own section

Ref #318

Layout: unified · split

internal/control/issue.go +54 −2
@@ -4,6 +4,7 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "slices"
7 "strconv" 8 "strconv"
8 "strings" 9 "strings"
9 10
@@ -399,7 +400,13 @@ func runIssueShow(c *Ctx, args []string) int {
399 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) 400 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
400 } 401 }
401 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs} 402 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
402 return c.emit(d, func(w io.Writer) { 403 canWrite := false
404 if c.Term.Cols > 0 && !c.JSON {
405 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil {
406 canWrite = policy.CanWrite(c.User, repo, grant)
407 }
408 }
409 return c.emitView(d, func(w io.Writer) {
403 v := c.view(w) 410 v := c.view(w)
404 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) 411 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
405 v.fields( 412 v.fields(
@@ -429,7 +436,7 @@ func runIssueShow(c *Ctx, args []string) int {
429 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) 436 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
430 v.reactions(cm.Reactions) 437 v.reactions(cm.Reactions)
431 } 438 }
432 }) 439 }, func() screen { return issueShowScreen(c, repo, d, canWrite) })
433} 440}
434 441
435func runIssueComment(c *Ctx, args []string) int { 442func runIssueComment(c *Ctx, args []string) int {
@@ -717,3 +724,48 @@ func assignIssue(c *Ctx, repo store.Repo, issue store.Issue, adds, removes []sto
717 } 724 }
718 return updated.Assignees, -1 725 return updated.Assignees, -1
719} 726}
727
728// issueShowScreen is issue show at a terminal. canWrite is write access,
729// which assigning and labelling need; the author may also close.
730func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen {
731 n := strconv.FormatInt(d.Number, 10)
732 path := repo.Path()
733 s := screen{body: d.Body, format: d.BodyFormat}
734 s.fields = append(s.fields,
735 field{"Issue", []cell{cLink("#"+n, c.siteURL(path, "issues", n)), cText(d.Title)}},
736 field{"State", []cell{cState(d.State), cMeta(d.Author, relAge(d.CreatedAt, termNow()))}},
737 )
738 if len(d.Labels) > 0 {
739 s.fields = append(s.fields, field{"Labels", []cell{cText(strings.Join(d.Labels, ", "))}})
740 }
741 if len(d.Assignees) > 0 {
742 v := cText(strings.Join(d.Assignees, ", "))
743 if slices.Contains(d.Assignees, c.User.Username) {
744 v.sgr = sgrYellow
745 }
746 s.fields = append(s.fields, field{"Assignee", []cell{v}})
747 }
748 if d.Milestone != "" {
749 s.fields = append(s.fields, field{"Milestone", []cell{cText(d.Milestone)}})
750 }
751 if !c.Term.Links {
752 s.fields = append(s.fields, field{"URL", []cell{cText(c.siteURL(path, "issues", n))}})
753 }
754 s.sections = []section{discussion(d.Comments), events(d.Comments)}
755
756 s.actions = append(s.actions, action{"Discuss", []string{"issue", "comment", path, n}})
757 if canWrite {
758 s.actions = append(s.actions,
759 action{"Triage", []string{"issue", "assign", path, n, "--add", c.User.Username}},
760 action{"Triage", []string{"issue", "label", path, n, "--add", "<label>"}},
761 )
762 }
763 if canWrite || d.Author == c.User.Username {
764 verb := "close"
765 if d.State == "closed" {
766 verb = "reopen"
767 }
768 s.actions = append(s.actions, action{"State", []string{"issue", verb, path, n}})
769 }
770 return s
771}
internal/control/issuescreen_test.go added +77
@@ -0,0 +1,77 @@
1package control
2
3import (
4 "slices"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestIssueShowPlainPinned(t *testing.T) {
12 st, repo, uid := newQueueTestRepo(t)
13 owner := store.User{ID: uid, Username: "alice"}
14 id, err := st.CreateIssue(repo.ID, uid, "Android app", "An app.", "md")
15 if err != nil {
16 t.Fatal(err)
17 }
18 if err := st.AddIssueComment(id, uid, "Started.", "md"); err != nil {
19 t.Fatal(err)
20 }
21 c, out, errOut := mrTestCtx(st, owner)
22 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
23 t.Fatalf("exit %d: %s", code, errOut)
24 }
25 pinPlain(t, "issue-show", out.String())
26}
27
28func issueFixture() IssueShow {
29 return IssueShow{issueOut: issueOut{Number: 12, Title: "Android app", State: "open", Author: "cmc",
30 Labels: []string{"mobile"}, Assignees: []string{"alice"}, Body: "An app.", BodyFormat: "md",
31 CreatedAt: "2026-08-21T10:00:00Z"},
32 Comments: []commentOut{
33 {ID: 1, Author: "cmc", Body: "Started.", BodyFormat: "md", CreatedAt: "2026-09-01T10:00:00Z", Kind: "comment"},
34 {ID: 2, Author: "cmc", Body: "labelled mobile", CreatedAt: "2026-09-01T10:00:00Z", Kind: "system"},
35 }}
36}
37
38func actionVerbs(s screen) []string {
39 var v []string
40 for _, a := range s.actions {
41 v = append(v, a.argv[1])
42 }
43 return v
44}
45
46func TestIssueShowScreenWriter(t *testing.T) {
47 c := screenCtx(100, false)
48 c.User = store.User{Username: "alice"}
49 s := issueShowScreen(c, screenRepo, issueFixture(), true)
50 if n := sectionCounts(s); n["Discussion"] != 1 || n["Events"] != 1 {
51 t.Errorf("sections = %v", n)
52 }
53 if got, want := actionVerbs(s), []string{"comment", "assign", "label", "close"}; !slices.Equal(got, want) {
54 t.Errorf("actions = %q, want %q", got, want)
55 }
56 checkActions(t, s)
57}
58
59func TestIssueShowScreenAuthorCanClose(t *testing.T) {
60 c := screenCtx(100, false)
61 c.User = store.User{Username: "cmc"}
62 s := issueShowScreen(c, screenRepo, issueFixture(), false)
63 if got, want := actionVerbs(s), []string{"comment", "close"}; !slices.Equal(got, want) {
64 t.Errorf("actions = %q, want %q", got, want)
65 }
66}
67
68func TestIssueShowScreenReader(t *testing.T) {
69 c := screenCtx(100, false)
70 c.User = store.User{Username: "bob"}
71 d := issueFixture()
72 d.State = "closed"
73 s := issueShowScreen(c, screenRepo, d, false)
74 if got, want := actionVerbs(s), []string{"comment"}; !slices.Equal(got, want) {
75 t.Errorf("actions = %q, want %q", got, want)
76 }
77}
internal/control/mr.go +1 −1
@@ -2271,7 +2271,7 @@ func mrShowScreen(c *Ctx, repo store.Repo, d MRShow, files []gitutil.NumStat) sc
2271 if len(files) > 0 { 2271 if len(files) > 0 {
2272 fs.note = fmt.Sprintf("+%d −%d", add, del) 2272 fs.note = fmt.Sprintf("+%d −%d", add, del)
2273 } 2273 }
2274 s.sections = []section{commits, fs, discussion(d.Comments)} 2274 s.sections = []section{commits, fs, discussion(d.Comments), events(d.Comments)}
2275 2275
2276 if open { 2276 if open {
2277 if behind { 2277 if behind {
internal/control/mrscreen_test.go +2 −2
@@ -126,7 +126,7 @@ func TestMRShowScreenRenders(t *testing.T) {
126 t.Errorf("missing %q in:\n%s", want, out) 126 t.Errorf("missing %q in:\n%s", want, out)
127 } 127 }
128 } 128 }
129 if strings.Contains(out, "marked ready") { 129 if !strings.Contains(out, "Events (1)\n9m ago marked ready\n") {
130 t.Errorf("system event in the discussion:\n%s", out) 130 t.Errorf("system event not under Events:\n%s", out)
131 } 131 }
132} 132}
internal/control/screen.go +15 −1
@@ -285,7 +285,7 @@ func (c *Ctx) renderLegend(as []action) string {
285} 285}
286 286
287// discussion is a thread's comments as a section, each author and age 287// discussion is a thread's comments as a section, each author and age
288// with its body beneath. System events are left out. It is drawn when 288// with its body beneath. System events are events'. It is drawn when
289// empty, as "(0)". 289// empty, as "(0)".
290func discussion(cs []commentOut) section { 290func discussion(cs []commentOut) section {
291 sec := section{title: "Discussion", empty: true} 291 sec := section{title: "Discussion", empty: true}
@@ -298,3 +298,17 @@ func discussion(cs []commentOut) section {
298 } 298 }
299 return sec 299 return sec
300} 300}
301
302// events is a thread's system comments ("referenced in commit ...") as
303// a section, each its age and its text without link targets.
304func events(cs []commentOut) section {
305 sec := section{title: "Events"}
306 for _, cm := range cs {
307 if cm.Kind != "system" {
308 continue
309 }
310 sec.n++
311 sec.rows = append(sec.rows, rowOf(cAge(cm.CreatedAt), cFlex(termtext.Inline(cm.Body, cm.BodyFormat))))
312 }
313 return sec
314}
internal/control/testdata/plain/issue-show.txt added +10
@@ -0,0 +1,10 @@
1#1 Android app open
2
3 author alice, <time>
4 url /alice/app/issues/1
5
6 An app.
7
8── alice, <time> (comment 1)
9
10 Started.
internal/control/view_test.go +13 −7
@@ -55,10 +55,14 @@ func TestIssueShowTerminal(t *testing.T) {
55 if !strings.Contains(out, sgrGreen+"open"+sgrReset) { 55 if !strings.Contains(out, sgrGreen+"open"+sgrReset) {
56 t.Errorf("state not coloured:\n%s", out) 56 t.Errorf("state not coloured:\n%s", out)
57 } 57 }
58 if !strings.Contains(out, " UTC") { 58 if !strings.Contains(stripSGR(out), "referenced in commit abc1234567 by alice") {
59 t.Errorf("no web-format timestamp:\n%s", out) 59 t.Errorf("event missing:\n%s", out)
60 } 60 }
61 for _, line := range strings.Split(stripSGR(out), "\n") { 61 for _, line := range strings.Split(stripSGR(out), "\n") {
62 // A suggested command is never cut: it has to paste whole.
63 if strings.HasPrefix(line, "gitbay ") {
64 continue
65 }
62 if cells(line) > 60 { 66 if cells(line) > 60 {
63 t.Errorf("line over 60 cells: %q", line) 67 t.Errorf("line over 60 cells: %q", line)
64 } 68 }
@@ -95,21 +99,23 @@ func TestIssueShowNarrowWraps(t *testing.T) {
95 var titleLines []string 99 var titleLines []string
96 inTitle := false 100 inTitle := false
97 for _, line := range strings.Split(stripSGR(out), "\n") { 101 for _, line := range strings.Split(stripSGR(out), "\n") {
98 if cells(line) > 40 { 102 // A suggested command is never cut: it has to paste whole.
103 if cells(line) > 40 && !strings.HasPrefix(line, "gitbay ") {
99 t.Errorf("line over 40 cells: %q", line) 104 t.Errorf("line over 40 cells: %q", line)
100 } 105 }
106 _, rest, isTitle := strings.Cut(line, "#1 ")
101 switch { 107 switch {
102 case strings.HasPrefix(line, "#1 "): 108 case strings.HasPrefix(line, "Issue:") && isTitle:
103 inTitle = true 109 inTitle = true
104 titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) 110 titleLines = append(titleLines, rest)
105 case inTitle && strings.HasPrefix(line, " "): 111 case inTitle && strings.HasPrefix(line, " "):
106 titleLines = append(titleLines, strings.TrimSpace(line)) 112 titleLines = append(titleLines, strings.TrimSpace(line))
107 default: 113 default:
108 inTitle = false 114 inTitle = false
109 } 115 }
110 } 116 }
111 got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") 117 got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ")
112 want := strings.Join(strings.Fields(title+" open"), " ") 118 want := strings.Join(strings.Fields(title), " ")
113 if got != want { 119 if got != want {
114 t.Errorf("title text lost across wrap: got %q, want %q", got, want) 120 t.Errorf("title text lost across wrap: got %q, want %q", got, want)
115 } 121 }