Commit 02a6794816
Verified · cmc
e2e/adminusers_test.go +1 −1
| @@ -165,7 +165,7 @@ func TestAdminUserListAndShow(t *testing.T) { | ||
| 165 | 165 | t.Fatalf("unknown user: exit %d", code) |
| 166 | 166 | } |
| 167 | 167 | // Plain output carries the same facts. |
| 168 | if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.Contains(out, "alice") || !strings.Contains(out, "active") || | |
| 168 | if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.HasPrefix(out, "alice active\n") || | |
| 169 | 169 | !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") { |
| 170 | 170 | t.Fatalf("plain show:\n%s", out) |
| 171 | 171 | } |
e2e/gates_test.go +2 −1
| @@ -3,6 +3,7 @@ package e2e | ||
| 3 | 3 | import ( |
| 4 | 4 | "os" |
| 5 | 5 | "path/filepath" |
| 6 | "regexp" | |
| 6 | 7 | "strings" |
| 7 | 8 | "testing" |
| 8 | 9 | ) |
| @@ -88,7 +89,7 @@ func TestMergeGatesVisible(t *testing.T) { | ||
| 88 | 89 | if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) { |
| 89 | 90 | t.Fatalf("gates after approval:\n%s", out) |
| 90 | 91 | } |
| 91 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !strings.Contains(out, "met; fast-forward possible") { | |
| 92 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !regexp.MustCompile(`gates\s+met; fast-forward possible`).MatchString(out) { | |
| 92 | 93 | t.Fatalf("text gates line: %s", out) |
| 93 | 94 | } |
| 94 | 95 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { |
e2e/reposhowstate_test.go +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "regexp" | |
| 4 | 5 | "strings" |
| 5 | 6 | "testing" |
| 6 | 7 | ) |
| @@ -54,7 +55,7 @@ func TestRepoShowCarriesViewerState(t *testing.T) { | ||
| 54 | 55 | } |
| 55 | 56 | |
| 56 | 57 | // Plain output carries the same. |
| 57 | if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !strings.Contains(out, "watching") || | |
| 58 | if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !regexp.MustCompile(`watch\s+watching`).MatchString(out) || | |
| 58 | 59 | !strings.Contains(out, "bookmarked") { |
| 59 | 60 | t.Errorf("plain output lacks the state:\n%s", out) |
| 60 | 61 | } |
internal/control/admin.go +8 −5
| @@ -263,7 +263,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 263 | 263 | "web sessions", fmt.Sprintf("%d", d.WebSessions), |
| 264 | 264 | ) |
| 265 | 265 | if len(d.Keys) > 0 { |
| 266 | io.WriteString(w, "\n") | |
| 266 | v.section("keys") | |
| 267 | 267 | tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") |
| 268 | 268 | for _, k := range d.Keys { |
| 269 | 269 | tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt)) |
| @@ -271,7 +271,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 271 | 271 | tk.flush() |
| 272 | 272 | } |
| 273 | 273 | if len(d.Emails) > 0 { |
| 274 | io.WriteString(w, "\n") | |
| 274 | v.section("emails") | |
| 275 | 275 | te := c.table(w, "ADDRESS", "STATE") |
| 276 | 276 | for _, e := range d.Emails { |
| 277 | 277 | state := "unverified" |
| @@ -287,7 +287,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 287 | 287 | te.flush() |
| 288 | 288 | } |
| 289 | 289 | if len(d.PGPKeys) > 0 { |
| 290 | io.WriteString(w, "\n") | |
| 290 | v.section("pgp keys") | |
| 291 | 291 | tp := c.table(w, "FINGERPRINT") |
| 292 | 292 | for _, k := range d.PGPKeys { |
| 293 | 293 | tp.row(cFlex(k.Fingerprint)) |
| @@ -295,7 +295,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 295 | 295 | tp.flush() |
| 296 | 296 | } |
| 297 | 297 | if len(d.Orgs) > 0 { |
| 298 | io.WriteString(w, "\n") | |
| 298 | v.section("orgs") | |
| 299 | 299 | to := c.table(w, "ORG", "ROLE") |
| 300 | 300 | for _, o := range d.Orgs { |
| 301 | 301 | to.row(cRef(o.Org), cState(o.Role)) |
| @@ -303,7 +303,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 303 | 303 | to.flush() |
| 304 | 304 | } |
| 305 | 305 | if len(d.APITokens) > 0 { |
| 306 | io.WriteString(w, "\n") | |
| 306 | v.section("api tokens") | |
| 307 | 307 | tt := c.table(w, "NAME", "SCOPE", "LAST USED") |
| 308 | 308 | for _, t := range d.APITokens { |
| 309 | 309 | used := "" |
| @@ -551,6 +551,9 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 551 | 551 | "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS), |
| 552 | 552 | "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), |
| 553 | 553 | ) |
| 554 | if len(runners) > 0 { | |
| 555 | v.section("runners") | |
| 556 | } | |
| 554 | 557 | tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") |
| 555 | 558 | for _, r := range runners { |
| 556 | 559 | scope := r.Scope |
internal/control/mr.go +32 −25
| @@ -641,8 +641,12 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 641 | 641 | if d.UnresolvedThreads > 0 { |
| 642 | 642 | unresolved = fmt.Sprintf("%d", d.UnresolvedThreads) |
| 643 | 643 | } |
| 644 | v.fields( | |
| 645 | "author", d.Author+", "+c.when(d.CreatedAt), | |
| 644 | // One fields call for every one-row fact, including the unmet | |
| 645 | // gates and the commit/check/review that only has a single row | |
| 646 | // to show: separate calls each compute their own key width, so | |
| 647 | // keeping them in one call keeps every key aligned. | |
| 648 | kv := []string{ | |
| 649 | "author", d.Author + ", " + c.when(d.CreatedAt), | |
| 646 | 650 | "source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef), |
| 647 | 651 | "head", fmt.Sprintf("%.10s", d.HeadSHA), |
| 648 | 652 | "milestone", d.Milestone, |
| @@ -655,20 +659,32 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 655 | 659 | "superseded by", superseded, |
| 656 | 660 | "unresolved threads", unresolved, |
| 657 | 661 | "gates", gates, |
| 658 | "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)), | |
| 659 | ) | |
| 660 | if g := d.Gates; g != nil && len(g.Unmet) > 0 { | |
| 661 | var kv []string | |
| 662 | } | |
| 663 | if g := d.Gates; g != nil { | |
| 662 | 664 | for _, u := range g.Unmet { |
| 663 | 665 | kv = append(kv, "unmet", u) |
| 664 | 666 | } |
| 665 | v.fields(kv...) | |
| 666 | 667 | } |
| 668 | if len(commits) == 1 { | |
| 669 | kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject)) | |
| 670 | } | |
| 671 | if len(checks) == 1 { | |
| 672 | x := checks[0] | |
| 673 | dur := "" | |
| 674 | if x.Duration != "" { | |
| 675 | dur = " in " + x.Duration | |
| 676 | } | |
| 677 | kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur)) | |
| 678 | } | |
| 679 | if len(rs) == 1 { | |
| 680 | kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt)) | |
| 681 | } | |
| 682 | kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) | |
| 683 | v.fields(kv...) | |
| 684 | ||
| 667 | 685 | v.body(d.Body, d.BodyFormat) |
| 668 | 686 | |
| 669 | if len(commits) == 1 { | |
| 670 | v.fields("commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject)) | |
| 671 | } else if len(commits) > 1 { | |
| 687 | if len(commits) > 1 { | |
| 672 | 688 | io.WriteString(w, "\n") |
| 673 | 689 | tb := c.table(w, "SHA", "SUBJECT") |
| 674 | 690 | for _, cm := range commits { |
| @@ -677,26 +693,17 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 677 | 693 | tb.flush() |
| 678 | 694 | } |
| 679 | 695 | |
| 680 | if len(checks) == 1 { | |
| 681 | x := checks[0] | |
| 682 | dur := "" | |
| 683 | if x.Duration != "" { | |
| 684 | dur = " in " + x.Duration | |
| 685 | } | |
| 686 | v.fields("check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur)) | |
| 687 | } else if len(checks) > 1 { | |
| 688 | io.WriteString(w, "\n") | |
| 689 | tb := c.table(w, "CHECK", "STATE", "UPDATED") | |
| 696 | if len(checks) > 1 { | |
| 697 | v.section("check") | |
| 698 | tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED") | |
| 690 | 699 | for _, x := range checks { |
| 691 | tb.row(cText(x.Context), cState(x.State), cText(c.when(x.UpdatedAt))) | |
| 700 | tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt))) | |
| 692 | 701 | } |
| 693 | 702 | tb.flush() |
| 694 | 703 | } |
| 695 | 704 | |
| 696 | if len(rs) == 1 { | |
| 697 | v.fields("review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt)) | |
| 698 | } else if len(rs) > 1 { | |
| 699 | io.WriteString(w, "\n") | |
| 705 | if len(rs) > 1 { | |
| 706 | v.section("review") | |
| 700 | 707 | tb := c.table(w, "REVIEWER", "VERDICT", "WHEN") |
| 701 | 708 | for _, r := range rs { |
| 702 | 709 | verdict := r.Verdict |
internal/control/profile.go +4 −4
| @@ -252,7 +252,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 252 | 252 | "activity", activity, |
| 253 | 253 | ) |
| 254 | 254 | if len(d.Links) > 0 { |
| 255 | io.WriteString(w, "\n") | |
| 255 | v.section("link") | |
| 256 | 256 | tb := c.table(w, "LINK", "URL") |
| 257 | 257 | for _, l := range d.Links { |
| 258 | 258 | tb.row(cText(l.Label), cFlex(l.URL)) |
| @@ -260,7 +260,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 260 | 260 | tb.flush() |
| 261 | 261 | } |
| 262 | 262 | if len(d.Orgs) > 0 { |
| 263 | io.WriteString(w, "\n") | |
| 263 | v.section("org") | |
| 264 | 264 | tb := c.table(w, "ORG", "ROLE") |
| 265 | 265 | for _, m := range d.Orgs { |
| 266 | 266 | tb.row(cRef(m.Name), cState(m.Role)) |
| @@ -268,7 +268,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 268 | 268 | tb.flush() |
| 269 | 269 | } |
| 270 | 270 | if len(d.Members) > 0 { |
| 271 | io.WriteString(w, "\n") | |
| 271 | v.section("member") | |
| 272 | 272 | tb := c.table(w, "MEMBER", "ROLE") |
| 273 | 273 | for _, m := range d.Members { |
| 274 | 274 | tb.row(cRef(m.Name), cState(m.Role)) |
| @@ -276,7 +276,7 @@ func emitProfile(c *Ctx, d ProfileOut) int { | ||
| 276 | 276 | tb.flush() |
| 277 | 277 | } |
| 278 | 278 | if len(d.Repos) > 0 { |
| 279 | io.WriteString(w, "\n") | |
| 279 | v.section("repo") | |
| 280 | 280 | tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION") |
| 281 | 281 | for _, r := range d.Repos { |
| 282 | 282 | tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description)) |
internal/control/release.go +1 −1
| @@ -279,7 +279,7 @@ func runReleaseShow(c *Ctx, args []string) int { | ||
| 279 | 279 | io.WriteString(w, "\n") |
| 280 | 280 | tb := c.table(w, "NAME", "SIZE", "SHA256") |
| 281 | 281 | for _, a := range d.Assets { |
| 282 | tb.row(cRef(a.Name), cNum(a.Size), cText(a.SHA256[:min(10, len(a.SHA256))])) | |
| 282 | tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256)) | |
| 283 | 283 | } |
| 284 | 284 | tb.flush() |
| 285 | 285 | } |
internal/control/repo.go +1 −1
| @@ -384,7 +384,7 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 384 | 384 | "url", c.siteURL(d.Path), |
| 385 | 385 | ) |
| 386 | 386 | if len(d.Mirrors) > 0 { |
| 387 | io.WriteString(w, "\n") | |
| 387 | v.section("mirror") | |
| 388 | 388 | tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") |
| 389 | 389 | for _, m := range d.Mirrors { |
| 390 | 390 | status := "ok" |
internal/control/view.go +32 −5
| @@ -28,8 +28,9 @@ func (c *Ctx) siteURL(parts ...string) string { | ||
| 28 | 28 | // view lays out a show: a title line, aligned fields, a body, events, |
| 29 | 29 | // comments. Plain output is the same lines without colour or wrapping. |
| 30 | 30 | type view struct { |
| 31 | c *Ctx | |
| 32 | w io.Writer | |
| 31 | c *Ctx | |
| 32 | w io.Writer | |
| 33 | wrote bool // has this view written anything yet | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} } |
| @@ -38,12 +39,36 @@ func (v *view) opts() termtext.Options { | ||
| 38 | 39 | return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL} |
| 39 | 40 | } |
| 40 | 41 | |
| 42 | // sep writes a blank line before the next block, unless this view has | |
| 43 | // written nothing yet: fields, a body or a section as the first thing a | |
| 44 | // command prints (admin runners, admin stats, notifications settings | |
| 45 | // show, ...) does not open with an empty line. | |
| 46 | func (v *view) sep() { | |
| 47 | if v.wrote { | |
| 48 | io.WriteString(v.w, "\n") | |
| 49 | } | |
| 50 | v.wrote = true | |
| 51 | } | |
| 52 | ||
| 53 | // section prints a sub-table's label: a blank line, then the label bold | |
| 54 | // at a terminal or "label:" in plain. Callers skip the call entirely | |
| 55 | // when the table it introduces has no rows. | |
| 56 | func (v *view) section(label string) { | |
| 57 | v.sep() | |
| 58 | if v.c.Term.Cols == 0 { | |
| 59 | io.WriteString(v.w, label+":\n") | |
| 60 | return | |
| 61 | } | |
| 62 | io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n") | |
| 63 | } | |
| 64 | ||
| 41 | 65 | // title prints "ref title state", wrapping title+state to the |
| 42 | 66 | // terminal width. Continuation lines indent under the title, and each |
| 43 | 67 | // line is painted after wrapping so no SGR sequence crosses a break. |
| 44 | 68 | // title or state may be "": either is skipped rather than leaving a |
| 45 | 69 | // trailing blank field. |
| 46 | 70 | func (v *view) title(ref, title, state string) { |
| 71 | v.sep() | |
| 47 | 72 | t := v.c.Term |
| 48 | 73 | if t.Cols == 0 { |
| 49 | 74 | switch { |
| @@ -107,7 +132,7 @@ func (v *view) fields(kv ...string) { | ||
| 107 | 132 | wide = max(wide, cells(kv[i])) |
| 108 | 133 | } |
| 109 | 134 | } |
| 110 | io.WriteString(v.w, "\n") | |
| 135 | v.sep() | |
| 111 | 136 | for i := 0; i+1 < len(kv); i += 2 { |
| 112 | 137 | key, val := kv[i], kv[i+1] |
| 113 | 138 | if val == "" { |
| @@ -133,7 +158,7 @@ func (v *view) body(src, format string) { | ||
| 133 | 158 | if strings.TrimSpace(src) == "" { |
| 134 | 159 | return |
| 135 | 160 | } |
| 136 | io.WriteString(v.w, "\n") | |
| 161 | v.sep() | |
| 137 | 162 | for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") { |
| 138 | 163 | if line == "" { |
| 139 | 164 | io.WriteString(v.w, "\n") |
| @@ -153,6 +178,7 @@ func (v *view) event(text, format, ts string) { | ||
| 153 | 178 | line = pad(clip(line, room), room) |
| 154 | 179 | } |
| 155 | 180 | io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n") |
| 181 | v.wrote = true | |
| 156 | 182 | } |
| 157 | 183 | |
| 158 | 184 | func (v *view) comment(author, ts, body, format string) { |
| @@ -166,6 +192,7 @@ func (v *view) comment(author, ts, body, format string) { | ||
| 166 | 192 | if cols > 0 { |
| 167 | 193 | head += strings.Repeat("─", max(1, cols-cells(head))) |
| 168 | 194 | } |
| 169 | io.WriteString(v.w, "\n"+v.c.Term.paint(sgrDim, head)+"\n") | |
| 195 | v.sep() | |
| 196 | io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n") | |
| 170 | 197 | v.body(body, format) |
| 171 | 198 | } |
internal/control/wiki.go +6
| @@ -168,6 +168,12 @@ func runWikiShow(c *Ctx, args []string) int { | ||
| 168 | 168 | format = "org" |
| 169 | 169 | } |
| 170 | 170 | return c.emit(d, func(w io.Writer) { |
| 171 | // Plain: the page source verbatim, same as any other piped | |
| 172 | // file read. The title/fields/body layout is terminal-only. | |
| 173 | if c.Term.Cols == 0 { | |
| 174 | fmt.Fprint(w, d.Content) | |
| 175 | return | |
| 176 | } | |
| 171 | 177 | v := c.view(w) |
| 172 | 178 | v.title(repo.Path(), page, "") |
| 173 | 179 | binaryNote := "" |