Commit 1706e2f65b
Verified · cmc
internal/control/issue.go +25 −12
| @@ -226,23 +226,36 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 226 | 226 | } |
| 227 | 227 | var cs []commentOut |
| 228 | 228 | 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}) | |
| 230 | 230 | } |
| 231 | 231 | d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs} |
| 232 | _ = repo | |
| 233 | 232 | 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) | |
| 243 | 253 | } |
| 244 | 254 | 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) | |
| 246 | 259 | } |
| 247 | 260 | }) |
| 248 | 261 | } |
internal/control/mr.go +1 −1
| @@ -558,7 +558,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 558 | 558 | } |
| 559 | 559 | var cs []commentOut |
| 560 | 560 | 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}) | |
| 562 | 562 | } |
| 563 | 563 | var rs []ReviewOut |
| 564 | 564 | counts := ReviewersWhoCount(c.Store, repo, reviews) |
internal/control/thread.go +1
| @@ -54,6 +54,7 @@ type commentOut struct { | ||
| 54 | 54 | Body string `json:"body"` |
| 55 | 55 | BodyFormat string `json:"body_format,omitempty"` |
| 56 | 56 | CreatedAt string `json:"created_at"` |
| 57 | Kind string `json:"-"` // "comment" or "system"; not part of the wire shape | |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | // thread is what a comment command needs to know about its noun. |
internal/control/view.go added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func (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. | |
| 24 | func (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. | |
| 30 | type view struct { | |
| 31 | c *Ctx | |
| 32 | w io.Writer | |
| 33 | } | |
| 34 | ||
| 35 | func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} } | |
| 36 | ||
| 37 | func (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 | ||
| 41 | func (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. | |
| 48 | func (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 | ||
| 64 | func (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. | |
| 80 | func (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 | ||
| 90 | func (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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func 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 | ||
| 35 | func 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 | ||
| 53 | func 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 | } | |