Commit c19814239d
Verified · cmc
Layout: unified · split
internal/control/issue.go +54 −2
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "slices" | |
| 7 | 8 | "strconv" |
| 8 | 9 | "strings" |
| 9 | 10 | |
| @@ -399,7 +400,13 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 399 | 400 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) |
| 400 | 401 | } |
| 401 | 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 | 410 | v := c.view(w) |
| 404 | 411 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) |
| 405 | 412 | v.fields( |
| @@ -429,7 +436,7 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 429 | 436 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) |
| 430 | 437 | v.reactions(cm.Reactions) |
| 431 | 438 | } |
| 432 | }) | |
| 439 | }, func() screen { return issueShowScreen(c, repo, d, canWrite) }) | |
| 433 | 440 | } |
| 434 | 441 | |
| 435 | 442 | func 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 | 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. | |
| 730 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "slices" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/protocol" | |
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | func 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 | ||
| 28 | func 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 | ||
| 38 | func 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 | ||
| 46 | func 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 | ||
| 59 | func 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 | ||
| 68 | func 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 | 2271 | if len(files) > 0 { |
| 2272 | 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 | 2276 | if open { |
| 2277 | 2277 | if behind { |
internal/control/mrscreen_test.go +2 −2
| @@ -126,7 +126,7 @@ func TestMRShowScreenRenders(t *testing.T) { | ||
| 126 | 126 | t.Errorf("missing %q in:\n%s", want, out) |
| 127 | 127 | } |
| 128 | 128 | } |
| 129 | if strings.Contains(out, "marked ready") { | |
| 130 | t.Errorf("system event in the discussion:\n%s", out) | |
| 129 | if !strings.Contains(out, "Events (1)\n9m ago marked ready\n") { | |
| 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 | 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 | 289 | // empty, as "(0)". |
| 290 | 290 | func discussion(cs []commentOut) section { |
| 291 | 291 | sec := section{title: "Discussion", empty: true} |
| @@ -298,3 +298,17 @@ func discussion(cs []commentOut) section { | ||
| 298 | 298 | } |
| 299 | 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. | |
| 304 | func 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 | 55 | if !strings.Contains(out, sgrGreen+"open"+sgrReset) { |
| 56 | 56 | t.Errorf("state not coloured:\n%s", out) |
| 57 | 57 | } |
| 58 | if !strings.Contains(out, " UTC") { | |
| 59 | t.Errorf("no web-format timestamp:\n%s", out) | |
| 58 | if !strings.Contains(stripSGR(out), "referenced in commit abc1234567 by alice") { | |
| 59 | t.Errorf("event missing:\n%s", out) | |
| 60 | 60 | } |
| 61 | 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 | 66 | if cells(line) > 60 { |
| 63 | 67 | t.Errorf("line over 60 cells: %q", line) |
| 64 | 68 | } |
| @@ -95,21 +99,23 @@ func TestIssueShowNarrowWraps(t *testing.T) { | ||
| 95 | 99 | var titleLines []string |
| 96 | 100 | inTitle := false |
| 97 | 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 | 104 | t.Errorf("line over 40 cells: %q", line) |
| 100 | 105 | } |
| 106 | _, rest, isTitle := strings.Cut(line, "#1 ") | |
| 101 | 107 | switch { |
| 102 | case strings.HasPrefix(line, "#1 "): | |
| 108 | case strings.HasPrefix(line, "Issue:") && isTitle: | |
| 103 | 109 | inTitle = true |
| 104 | titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) | |
| 105 | case inTitle && strings.HasPrefix(line, " "): | |
| 110 | titleLines = append(titleLines, rest) | |
| 111 | case inTitle && strings.HasPrefix(line, " "): | |
| 106 | 112 | titleLines = append(titleLines, strings.TrimSpace(line)) |
| 107 | 113 | default: |
| 108 | 114 | inTitle = false |
| 109 | 115 | } |
| 110 | 116 | } |
| 111 | 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 | 119 | if got != want { |
| 114 | 120 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) |
| 115 | 121 | } |