Commit c19814239d
Verified · cmc
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 | ||
| 435 | func runIssueComment(c *Ctx, args []string) int { | 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 | 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. | ||
| 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 | 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)". |
| 290 | func discussion(cs []commentOut) section { | 290 | func 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. | ||
| 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 | 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 | } |