control: list and dashboard screens (CLI views stage 2b) !554
14 files changed, +479 −193
Layout: unified · split
.gitbay/wiki/Users.org +11 −5
| @@ -1024,11 +1024,17 @@ then prints: | |||
| 1024 | =krz/gitbay=, SHAs) dim. A marker piped output keeps as its own | 1024 | =krz/gitbay=, SHAs) dim. A marker piped output keeps as its own |
| 1025 | trailing cell (=[archived]=, =primary=) joins the state at a terminal: | 1025 | trailing cell (=[archived]=, =primary=) joins the state at a terminal: |
| 1026 | =public, archived=; | 1026 | =public, archived=; |
| 1027 | - lists that say what to do next: =mr list= with checks, review state | 1027 | - =mr list=, =issue list= and =build list= as one-section screens: |
| 1028 | and age, =issue list= with labels, assignee, comments and age, a | 1028 | each row led by =●= when it waits on you (your review requested, an |
| 1029 | =dashboard= that opens with what needs you; a column empty on every | 1029 | issue assigned to you), otherwise by its checks' or state's glyph; |
| 1030 | row is left out. These columns are terminal-only; piped rows keep | 1030 | =mr list= with review state, branch and age, =issue list= with |
| 1031 | their own; | 1031 | labels, assignees, comments and age. A =dashboard= screen opens with |
| 1032 | what waits on you, then open merge requests, failed builds (a job's | ||
| 1033 | latest build only), five lines of activity with a count of builds | ||
| 1034 | that passed, and pinned repositories; for admins a =Problems= line | ||
| 1035 | counts background failures, and =admin stats= has the detail. A | ||
| 1036 | column empty on every row is left out. These columns are | ||
| 1037 | terminal-only; piped rows keep their own; | ||
| 1032 | - diffs coloured (file headers bold, hunks cyan, added green, removed | 1038 | - diffs coloured (file headers bold, hunks cyan, added green, removed |
| 1033 | red), and =build log= with its step lines bold, the failed one red; | 1039 | red), and =build log= with its step lines bold, the failed one red; |
| 1034 | both made safe like any user text, the log's own colour codes dropped; | 1040 | both made safe like any user text, the log's own colour codes dropped; |
CHANGELOG.org +5
| @@ -20,6 +20,11 @@ anything beyond "replace the binary and restart" is needed. | |||
| 20 | the files with their line counts; =repo show= its open merge requests, | 20 | the files with their line counts; =repo show= its open merge requests, |
| 21 | open issues and latest commits; system events sit in their own | 21 | open issues and latest commits; system events sit in their own |
| 22 | =Events= section. Pending checks lead with =◐=. | 22 | =Events= section. Pending checks lead with =◐=. |
| 23 | - =mr list=, =issue list=, =build list= and =dashboard= at a terminal | ||
| 24 | are screens (#318). Rows that wait on you lead with =●=. The terminal | ||
| 25 | dashboard drops the server build and queue tables (=admin stats= has | ||
| 26 | them; =--json= is unchanged) and shows a job's failed build only | ||
| 27 | while it is that job's latest. | ||
| 23 | 28 | ||
| 24 | * v1.41.0 — 2026-10-01 | 29 | * v1.41.0 — 2026-10-01 |
| 25 | 30 | ||
internal/control/build.go +18 −2
| @@ -213,13 +213,29 @@ func runBuildList(c *Ctx, args []string) int { | |||
| 213 | for i := range ds { | 213 | for i := range ds { |
| 214 | ds[i].Subject = subjects[ds[i].SHA] | 214 | ds[i].Subject = subjects[ds[i].SHA] |
| 215 | } | 215 | } |
| 216 | return c.emitPage(p, ds, next, func(w io.Writer) { | 216 | return c.emitPageView(p, ds, next, func(w io.Writer) { |
| 217 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") | 217 | tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE") |
| 218 | for _, d := range ds { | 218 | for _, d := range ds { |
| 219 | tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) | 219 | tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject)) |
| 220 | } | 220 | } |
| 221 | tb.flush() | 221 | tb.flush() |
| 222 | }) | 222 | }, func() screen { return buildListScreen(c, repo, ds) }) |
| 223 | } | ||
| 224 | |||
| 225 | // buildListScreen is build list at a terminal: one section of builds, | ||
| 226 | // each led by its outcome's mark, then job, ref, commit subject and age. | ||
| 227 | func buildListScreen(c *Ctx, repo store.Repo, ds []BuildOut) screen { | ||
| 228 | sec := section{title: "Builds", n: len(ds)} | ||
| 229 | for _, d := range ds { | ||
| 230 | n := strconv.FormatInt(d.Number, 10) | ||
| 231 | sec.rows = append(sec.rows, rowOf(cLink(n, c.siteURL(repo.Path(), "builds", n)), cGlyph(d.Status), | ||
| 232 | cText(d.Job), cText(d.Ref), cFlex(d.Subject), cAge(d.CreatedAt))) | ||
| 233 | } | ||
| 234 | s := screen{sections: []section{sec}} | ||
| 235 | if len(ds) > 0 { | ||
| 236 | s.actions = []action{{"Read", []string{"build", "show", repo.Path(), strconv.FormatInt(ds[0].Number, 10)}}} | ||
| 237 | } | ||
| 238 | return s | ||
| 223 | } | 239 | } |
| 224 | 240 | ||
| 225 | // buildSubjects reads the commit subject of each distinct sha on a page | 241 | // buildSubjects reads the commit subject of each distinct sha on a page |
internal/control/dashboard.go +142 −110
| @@ -95,47 +95,7 @@ type DashboardOut struct { | |||
| 95 | 95 | ||
| 96 | // dashboardActivity is how many feed lines the dashboard shows at a | 96 | // dashboardActivity is how many feed lines the dashboard shows at a |
| 97 | // terminal. | 97 | // terminal. |
| 98 | const dashboardActivity = 8 | 98 | const dashboardActivity = 5 |
| 99 | |||
| 100 | // needsYou is the dashboard's first line at a terminal: what waits on | ||
| 101 | // the viewer, in yellow, or a dim line saying nothing does. | ||
| 102 | func (t Term) needsYou(d DashboardOut) string { | ||
| 103 | var parts []string | ||
| 104 | add := func(n int, one, many string) { | ||
| 105 | switch { | ||
| 106 | case n == 1: | ||
| 107 | parts = append(parts, "1 "+one) | ||
| 108 | case n > 1: | ||
| 109 | parts = append(parts, fmt.Sprintf("%d %s", n, many)) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | add(len(d.Reviews), "review requested", "reviews requested") | ||
| 113 | add(len(d.Assigned), "assigned issue", "assigned issues") | ||
| 114 | add(failingBuilds(d.Builds), "failing build", "failing builds") | ||
| 115 | add(d.Unread, "unread notification", "unread notifications") | ||
| 116 | if len(parts) == 0 { | ||
| 117 | return t.paint(sgrDim, "Nothing waits on you.") | ||
| 118 | } | ||
| 119 | return t.paint(sgrBold+sgrYellow, "Needs you: "+strings.Join(parts, ", ")) | ||
| 120 | } | ||
| 121 | |||
| 122 | // failingBuilds counts the jobs whose latest build failed: a failure a | ||
| 123 | // later build of the same job and ref has already replaced is not one. | ||
| 124 | func failingBuilds(builds []DashboardBuild) int { | ||
| 125 | seen := map[string]bool{} | ||
| 126 | n := 0 | ||
| 127 | for _, b := range builds { | ||
| 128 | key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref | ||
| 129 | if seen[key] { | ||
| 130 | continue | ||
| 131 | } | ||
| 132 | seen[key] = true | ||
| 133 | if b.Status == "failure" { | ||
| 134 | n++ | ||
| 135 | } | ||
| 136 | } | ||
| 137 | return n | ||
| 138 | } | ||
| 139 | 99 | ||
| 140 | func runDashboard(c *Ctx, args []string) int { | 100 | func runDashboard(c *Ctx, args []string) int { |
| 141 | if len(args) != 0 { | 101 | if len(args) != 0 { |
| @@ -220,48 +180,25 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 220 | d.Queues = &q | 180 | d.Queues = &q |
| 221 | } | 181 | } |
| 222 | 182 | ||
| 223 | return c.emit(d, func(w io.Writer) { | 183 | lines := FeedLines(events) |
| 224 | // At a terminal sections are separated by a blank line, and an | 184 | return c.emitView(d, func(w io.Writer) { |
| 225 | // empty one is left out. | 185 | heading := func(title string) { fmt.Fprintln(w, title) } |
| 226 | wrote := false | 186 | section := func(title string, rows [][]cell) { |
| 227 | heading := func(title string) { | ||
| 228 | if c.Term.Cols == 0 { | ||
| 229 | fmt.Fprintln(w, title) | ||
| 230 | return | ||
| 231 | } | ||
| 232 | if wrote { | ||
| 233 | fmt.Fprintln(w) | ||
| 234 | } | ||
| 235 | wrote = true | ||
| 236 | fmt.Fprintln(w, c.Term.heading(title)) | ||
| 237 | } | ||
| 238 | section := func(title string, header []string, rows [][]cell) { | ||
| 239 | if c.Term.Cols > 0 && len(rows) == 0 { | ||
| 240 | return | ||
| 241 | } | ||
| 242 | heading(title) | 187 | heading(title) |
| 243 | if len(rows) == 0 { | 188 | if len(rows) == 0 { |
| 244 | fmt.Fprintln(w, " none") | 189 | fmt.Fprintln(w, " none") |
| 245 | return | 190 | return |
| 246 | } | 191 | } |
| 247 | if c.Term.Cols == 0 { | 192 | for _, r := range rows { |
| 248 | for _, r := range rows { | 193 | parts := make([]string, len(r)) |
| 249 | parts := make([]string, len(r)) | 194 | for i, cl := range r { |
| 250 | for i, cl := range r { | 195 | parts[i] = cl.s |
| 251 | parts[i] = cl.s | 196 | if cl.kind == kindAge { |
| 252 | if cl.kind == kindAge { | 197 | parts[i] = stamp(cl.s) |
| 253 | parts[i] = stamp(cl.s) | ||
| 254 | } | ||
| 255 | } | 198 | } |
| 256 | fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t")) | ||
| 257 | } | 199 | } |
| 258 | return | 200 | fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t")) |
| 259 | } | ||
| 260 | tb := c.table(w, header...) | ||
| 261 | for _, r := range rows { | ||
| 262 | tb.row(r...) | ||
| 263 | } | 201 | } |
| 264 | tb.flush() | ||
| 265 | } | 202 | } |
| 266 | itemRows := func(items []DashboardItem, marker string) [][]cell { | 203 | itemRows := func(items []DashboardItem, marker string) [][]cell { |
| 267 | rows := make([][]cell, len(items)) | 204 | rows := make([][]cell, len(items)) |
| @@ -276,17 +213,13 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 276 | return rows | 213 | return rows |
| 277 | } | 214 | } |
| 278 | 215 | ||
| 279 | if c.Term.Cols > 0 { | 216 | if d.Unread > 0 { |
| 280 | fmt.Fprintln(w, c.Term.needsYou(d)) | ||
| 281 | wrote = true | ||
| 282 | } else if d.Unread > 0 { | ||
| 283 | fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) | 217 | fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) |
| 284 | } | 218 | } |
| 285 | itemHeader := []string{"REF", "TITLE", "AUTHOR"} | 219 | section("waiting on your review:", itemRows(d.Reviews, "!")) |
| 286 | section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!")) | 220 | section("assigned to you:", itemRows(d.Assigned, "#")) |
| 287 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) | 221 | section("open merge requests:", itemRows(d.MRs, "!")) |
| 288 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) | 222 | section("open issues:", itemRows(d.Issues, "#")) |
| 289 | section("open issues:", itemHeader, itemRows(d.Issues, "#")) | ||
| 290 | for _, q := range d.Queries { | 223 | for _, q := range d.Queries { |
| 291 | rows := make([][]cell, 0, len(q.Items)) | 224 | rows := make([][]cell, 0, len(q.Items)) |
| 292 | for _, it := range q.Items { | 225 | for _, it := range q.Items { |
| @@ -296,7 +229,7 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 296 | if q.Error != "" { | 229 | if q.Error != "" { |
| 297 | title = fmt.Sprintf("query %s: %s", q.Name, q.Error) | 230 | title = fmt.Sprintf("query %s: %s", q.Name, q.Error) |
| 298 | } | 231 | } |
| 299 | section(title, itemHeader, rows) | 232 | section(title, rows) |
| 300 | } | 233 | } |
| 301 | 234 | ||
| 302 | pinnedRows := make([][]cell, len(d.Pinned)) | 235 | pinnedRows := make([][]cell, len(d.Pinned)) |
| @@ -307,32 +240,19 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 307 | } | 240 | } |
| 308 | pinnedRows[i] = cells | 241 | pinnedRows[i] = cells |
| 309 | } | 242 | } |
| 310 | section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows) | 243 | section("pinned:", pinnedRows) |
| 311 | 244 | ||
| 312 | lines := FeedLines(events) | 245 | activityRows := make([][]cell, len(lines)) |
| 313 | if c.Term.Cols > 0 { | 246 | for i, l := range lines { |
| 314 | // The feed has its own command; the dashboard shows the start. | 247 | activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())} |
| 315 | rows := make([][]cell, 0, dashboardActivity) | ||
| 316 | for _, l := range lines[:min(len(lines), dashboardActivity)] { | ||
| 317 | rows = append(rows, l.termCells(c)) | ||
| 318 | } | ||
| 319 | section("recent activity:", feedHeader, rows) | ||
| 320 | if len(lines) > dashboardActivity { | ||
| 321 | fmt.Fprintln(w, c.Term.paint(sgrDim, "more: gitbay feed")) | ||
| 322 | } | ||
| 323 | } else { | ||
| 324 | activityRows := make([][]cell, len(lines)) | ||
| 325 | for i, l := range lines { | ||
| 326 | activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())} | ||
| 327 | } | ||
| 328 | section("recent activity:", []string{"WHEN", "EVENT"}, activityRows) | ||
| 329 | } | 248 | } |
| 249 | section("recent activity:", activityRows) | ||
| 330 | 250 | ||
| 331 | buildRows := make([][]cell, len(d.Builds)) | 251 | buildRows := make([][]cell, len(d.Builds)) |
| 332 | for i, b := range d.Builds { | 252 | for i, b := range d.Builds { |
| 333 | buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} | 253 | buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} |
| 334 | } | 254 | } |
| 335 | section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) | 255 | section("builds:", buildRows) |
| 336 | 256 | ||
| 337 | if d.Server != nil { | 257 | if d.Server != nil { |
| 338 | heading("server:") | 258 | heading("server:") |
| @@ -377,11 +297,7 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 377 | if it.Status == "pending" { | 297 | if it.Status == "pending" { |
| 378 | since = it.CreatedAt | 298 | since = it.CreatedAt |
| 379 | } | 299 | } |
| 380 | if c.Term.Cols == 0 { | 300 | since = stamp(since) |
| 381 | since = stamp(since) | ||
| 382 | } else { | ||
| 383 | since = relAge(since, termNow()) | ||
| 384 | } | ||
| 385 | tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since))) | 301 | tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since))) |
| 386 | } | 302 | } |
| 387 | tbq.flush() | 303 | tbq.flush() |
| @@ -393,7 +309,7 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 393 | } | 309 | } |
| 394 | tde.flush() | 310 | tde.flush() |
| 395 | } | 311 | } |
| 396 | }) | 312 | }, func() screen { return dashboardScreen(c, d, lines) }) |
| 397 | } | 313 | } |
| 398 | 314 | ||
| 399 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further | 315 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further |
| @@ -457,3 +373,119 @@ func runFeed(c *Ctx, args []string) int { | |||
| 457 | tb.flush() | 373 | tb.flush() |
| 458 | }) | 374 | }) |
| 459 | } | 375 | } |
| 376 | |||
| 377 | // dashboardScreen is dashboard at a terminal: what waits on the viewer | ||
| 378 | // first, then open merge requests, failed builds, a few lines of | ||
| 379 | // activity and the pinned repositories. The operator's queues are | ||
| 380 | // admin stats'; a background failure shows as one header line. | ||
| 381 | func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen { | ||
| 382 | var s screen | ||
| 383 | s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}}) | ||
| 384 | if host := c.Cfg.SiteHost(); host != "" { | ||
| 385 | s.fields = append(s.fields, field{"Instance", []cell{cText(host)}}) | ||
| 386 | } | ||
| 387 | if q := d.Queues; q != nil { | ||
| 388 | if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 { | ||
| 389 | s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}}) | ||
| 390 | } | ||
| 391 | } | ||
| 392 | if d.Unread > 0 { | ||
| 393 | s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}}) | ||
| 394 | } | ||
| 395 | |||
| 396 | item := func(it DashboardItem, marker, page string, you bool) row { | ||
| 397 | lead := cell{kind: kindGlyph} | ||
| 398 | if you { | ||
| 399 | lead = cYou() | ||
| 400 | } | ||
| 401 | ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10))) | ||
| 402 | return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author)) | ||
| 403 | } | ||
| 404 | items := func(title string, its []DashboardItem, marker, page string, you bool) section { | ||
| 405 | sec := section{title: title, n: len(its)} | ||
| 406 | for _, it := range its { | ||
| 407 | sec.rows = append(sec.rows, item(it, marker, page, you)) | ||
| 408 | } | ||
| 409 | return sec | ||
| 410 | } | ||
| 411 | s.sections = append(s.sections, | ||
| 412 | items("Review requested", d.Reviews, "!", "mrs", true), | ||
| 413 | items("Assigned issues", d.Assigned, "#", "issues", true), | ||
| 414 | items("Open merge requests", d.MRs, "!", "mrs", false), | ||
| 415 | ) | ||
| 416 | for _, q := range d.Queries { | ||
| 417 | sec := section{title: q.Name, n: q.Count, more: []string{"query", "run", q.Name}} | ||
| 418 | if q.Error != "" { | ||
| 419 | sec.note, sec.empty = q.Error, true | ||
| 420 | } | ||
| 421 | for _, it := range q.Items { | ||
| 422 | sec.rows = append(sec.rows, rowOf(cRef(it.Ref()), cFlex(it.Title), cMeta(it.Author))) | ||
| 423 | } | ||
| 424 | s.sections = append(s.sections, sec) | ||
| 425 | } | ||
| 426 | |||
| 427 | // A job's latest build is the one that counts: a failure a later | ||
| 428 | // build of the same job and ref has replaced is not shown. | ||
| 429 | failed := section{title: "Failed builds"} | ||
| 430 | passed := 0 | ||
| 431 | seen := map[string]bool{} | ||
| 432 | var firstFailed *DashboardBuild | ||
| 433 | for i, b := range d.Builds { | ||
| 434 | key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref | ||
| 435 | if seen[key] { | ||
| 436 | continue | ||
| 437 | } | ||
| 438 | seen[key] = true | ||
| 439 | switch b.Status { | ||
| 440 | case "success": | ||
| 441 | passed++ | ||
| 442 | case "failure", "error": | ||
| 443 | n := strconv.FormatInt(b.Number, 10) | ||
| 444 | failed.n++ | ||
| 445 | failed.rows = append(failed.rows, rowOf(cLink(n, c.siteURL(b.Repo, "builds", n)), cGlyph(b.Status), | ||
| 446 | cFlex(b.Job+" "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow())))) | ||
| 447 | if firstFailed == nil { | ||
| 448 | firstFailed = &d.Builds[i] | ||
| 449 | } | ||
| 450 | } | ||
| 451 | } | ||
| 452 | s.sections = append(s.sections, failed) | ||
| 453 | |||
| 454 | activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}} | ||
| 455 | for _, l := range lines[:min(len(lines), dashboardActivity)] { | ||
| 456 | activity.rows = append(activity.rows, rowOf(l.termCells(c)...)) | ||
| 457 | } | ||
| 458 | if passed > 0 { | ||
| 459 | word := "builds" | ||
| 460 | if passed == 1 { | ||
| 461 | word = "build" | ||
| 462 | } | ||
| 463 | activity.note, activity.empty = fmt.Sprintf("%d %s passed", passed, word), true | ||
| 464 | } | ||
| 465 | s.sections = append(s.sections, activity) | ||
| 466 | |||
| 467 | if len(d.Pinned) > 0 { | ||
| 468 | paths := make([]string, len(d.Pinned)) | ||
| 469 | for i, p := range d.Pinned { | ||
| 470 | paths[i] = p.Path | ||
| 471 | } | ||
| 472 | s.sections = append(s.sections, section{title: "Pinned", n: len(d.Pinned), rows: []row{rowOf(cMeta(strings.Join(paths, " ")))}}) | ||
| 473 | } | ||
| 474 | |||
| 475 | if len(d.Reviews) > 0 { | ||
| 476 | it := d.Reviews[0] | ||
| 477 | s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}}) | ||
| 478 | } | ||
| 479 | if len(d.Assigned) > 0 { | ||
| 480 | it := d.Assigned[0] | ||
| 481 | s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}}) | ||
| 482 | } | ||
| 483 | if firstFailed != nil { | ||
| 484 | s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}}) | ||
| 485 | } | ||
| 486 | s.actions = append(s.actions, action{"More", []string{"feed"}}) | ||
| 487 | if c.User.IsAdmin { | ||
| 488 | s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}}) | ||
| 489 | } | ||
| 490 | return s | ||
| 491 | } | ||
internal/control/dashscreen_test.go added +99
| @@ -0,0 +1,99 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/protocol" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestDashboardPlainPinned(t *testing.T) { | ||
| 13 | st, repo, owner := twoMRTestRepo(t) | ||
| 14 | // Both merge requests are created in the same second; the dashboard | ||
| 15 | // orders by updated_at alone, so give them distinct times. | ||
| 16 | if _, err := st.DB.Exec(`UPDATE merge_requests SET updated_at = '2020-01-01T00:00:00Z' WHERE repo_id = ? AND number = 1`, repo.ID); err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | c, out, errOut := mrTestCtx(st, owner) | ||
| 20 | if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK { | ||
| 21 | t.Fatalf("exit %d: %s", code, errOut) | ||
| 22 | } | ||
| 23 | pinPlain(t, "dashboard", out.String()) | ||
| 24 | } | ||
| 25 | |||
| 26 | func dashFixture() DashboardOut { | ||
| 27 | return DashboardOut{ | ||
| 28 | Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}}, | ||
| 29 | MRs: []DashboardItem{{Repo: "krz/gitbay", Number: 554, Title: "list screens", Author: "cmc"}}, | ||
| 30 | Builds: []DashboardBuild{ | ||
| 31 | {Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"}, | ||
| 32 | {Repo: "krz/gitbay", Number: 1784, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"}, | ||
| 33 | {Repo: "krz/gitbay", Number: 1783, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"}, | ||
| 34 | {Repo: "krz/gitbay", Number: 1700, Job: "build", Status: "failure", Ref: "main", CreatedAt: "2026-09-01T10:00:00Z"}, | ||
| 35 | }, | ||
| 36 | Server: &ServerOut{Commit: "135f300"}, | ||
| 37 | Queues: &store.Queues{}, | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestDashboardScreen(t *testing.T) { | ||
| 42 | c := screenCtx(110, false) | ||
| 43 | c.User = store.User{Username: "cmc", IsAdmin: true} | ||
| 44 | s := dashboardScreen(c, dashFixture(), nil) | ||
| 45 | var titles []string | ||
| 46 | for _, sec := range s.sections { | ||
| 47 | if len(sec.rows) > 0 || sec.empty { | ||
| 48 | titles = append(titles, sec.title) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | if want := []string{"Assigned issues", "Open merge requests", "Failed builds", "Recent activity"}; !slices.Equal(titles, want) { | ||
| 52 | t.Errorf("sections = %q, want %q", titles, want) | ||
| 53 | } | ||
| 54 | if n := sectionCounts(s)["Failed builds"]; n != 1 { | ||
| 55 | t.Errorf("failed builds = %d, want 1 (an older failure of a job that has since passed is not one)", n) | ||
| 56 | } | ||
| 57 | out := renderString(c, s) | ||
| 58 | for _, unwanted := range []string{"135f300", "Queues", "webhooks"} { | ||
| 59 | if strings.Contains(out, unwanted) { | ||
| 60 | t.Errorf("admin block %q on the dashboard screen:\n%s", unwanted, out) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | if !strings.Contains(out, "2 builds passed") { | ||
| 64 | t.Errorf("no passing-build summary:\n%s", out) | ||
| 65 | } | ||
| 66 | var cmds []string | ||
| 67 | for _, a := range s.actions { | ||
| 68 | cmds = append(cmds, strings.Join(a.argv, " ")) | ||
| 69 | } | ||
| 70 | for _, want := range []string{"issue show krz/skunky-art 33", "build log krz/omaha-metro-blotter 83", "feed", "admin stats"} { | ||
| 71 | if !slices.Contains(cmds, want) { | ||
| 72 | t.Errorf("actions %q lack %q", cmds, want) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | checkActions(t, s) | ||
| 76 | } | ||
| 77 | |||
| 78 | func TestDashboardScreenProblemsLine(t *testing.T) { | ||
| 79 | c := screenCtx(110, false) | ||
| 80 | c.User = store.User{Username: "cmc", IsAdmin: true} | ||
| 81 | d := dashFixture() | ||
| 82 | d.Queues.Mirrors.Errors = 1 | ||
| 83 | out := renderString(c, dashboardScreen(c, d, nil)) | ||
| 84 | if !strings.Contains(out, "Problems: ✗ 1 failing in the background\n") { | ||
| 85 | t.Errorf("no problems line:\n%s", out) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | func TestDashboardScreenNonAdmin(t *testing.T) { | ||
| 90 | c := screenCtx(110, false) | ||
| 91 | c.User = store.User{Username: "bob"} | ||
| 92 | d := dashFixture() | ||
| 93 | d.Server, d.Queues = nil, nil | ||
| 94 | for _, a := range dashboardScreen(c, d, nil).actions { | ||
| 95 | if a.argv[0] == "admin" { | ||
| 96 | t.Errorf("non-admin offered %q", a.argv) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
internal/control/issue.go +38 −18
| @@ -354,29 +354,13 @@ func runIssueList(c *Ctx, args []string) int { | |||
| 354 | return c.fail(protocol.ExitFailure, "%v", err) | 354 | return c.fail(protocol.ExitFailure, "%v", err) |
| 355 | } | 355 | } |
| 356 | } | 356 | } |
| 357 | return c.emitPage(p, ds, next, func(w io.Writer) { | 357 | return c.emitPageView(p, ds, next, func(w io.Writer) { |
| 358 | if c.Term.Cols > 0 { | ||
| 359 | // At a terminal: who it waits on and how much it has moved, | ||
| 360 | // in place of who opened it. | ||
| 361 | tb := c.table(w, "#", "STATE", "TITLE", "LABELS", "ASSIGNEE", "COMMENTS", "UPDATED") | ||
| 362 | for i, d := range ds { | ||
| 363 | n := "" | ||
| 364 | if k := comments[issues[i].ID]; k > 0 { | ||
| 365 | n = strconv.Itoa(k) | ||
| 366 | } | ||
| 367 | tb.row(cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))), cState(d.State), cFlex(d.Title), | ||
| 368 | cText(labelsMark(labels[issues[i].ID])), assigneesMark(assignees[issues[i].ID], c.User.Username), | ||
| 369 | cText(n), cAge(issues[i].UpdatedAt)) | ||
| 370 | } | ||
| 371 | tb.flush() | ||
| 372 | return | ||
| 373 | } | ||
| 374 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") | 358 | tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR") |
| 375 | for _, d := range ds { | 359 | for _, d := range ds { |
| 376 | tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author)) | 360 | tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author)) |
| 377 | } | 361 | } |
| 378 | tb.flush() | 362 | tb.flush() |
| 379 | }) | 363 | }, func() screen { return issueListScreen(c, repo, f.State, issues, ds, comments, labels, assignees) }) |
| 380 | } | 364 | } |
| 381 | 365 | ||
| 382 | func runIssueShow(c *Ctx, args []string) int { | 366 | func runIssueShow(c *Ctx, args []string) int { |
| @@ -769,3 +753,39 @@ func issueShowScreen(c *Ctx, repo store.Repo, d IssueShow, canWrite bool) screen | |||
| 769 | } | 753 | } |
| 770 | return s | 754 | return s |
| 771 | } | 755 | } |
| 756 | |||
| 757 | // issueListScreen is issue list at a terminal: one section of issues, | ||
| 758 | // each led by ● when it is assigned to the viewer, then title, labels, | ||
| 759 | // assignees, comments and age. | ||
| 760 | func issueListScreen(c *Ctx, repo store.Repo, state string, issues []store.Issue, ds []issueOut, comments map[int64]int, labels, assignees map[int64][]string) screen { | ||
| 761 | title, ok := map[string]string{"open": "Open issues", "closed": "Closed issues"}[state] | ||
| 762 | if !ok { | ||
| 763 | title = "Issues" | ||
| 764 | } | ||
| 765 | sec := section{title: title, n: len(ds)} | ||
| 766 | for i, d := range ds { | ||
| 767 | id := issues[i].ID | ||
| 768 | lead := cGlyph(d.State) | ||
| 769 | if slices.Contains(assignees[id], c.User.Username) { | ||
| 770 | lead = cYou() | ||
| 771 | } | ||
| 772 | n := "" | ||
| 773 | switch k := comments[id]; { | ||
| 774 | case k == 1: | ||
| 775 | n = "1 comment" | ||
| 776 | case k > 1: | ||
| 777 | n = fmt.Sprintf("%d comments", k) | ||
| 778 | } | ||
| 779 | ref := cLink(fmt.Sprintf("#%d", d.Number), c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10))) | ||
| 780 | sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), | ||
| 781 | cMeta(labelsMark(labels[id]), labelsMark(assignees[id]), n, relAge(issues[i].UpdatedAt, termNow())))) | ||
| 782 | } | ||
| 783 | other := "closed" | ||
| 784 | if state != "open" { | ||
| 785 | other = "open" | ||
| 786 | } | ||
| 787 | return screen{sections: []section{sec}, actions: []action{ | ||
| 788 | {"New", []string{"issue", "create", repo.Path()}}, | ||
| 789 | {"Filter", []string{"issue", "list", repo.Path(), "--state", other}}, | ||
| 790 | }} | ||
| 791 | } | ||
internal/control/listscreen_test.go added +111
| @@ -0,0 +1,111 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/protocol" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestListsPlainPinned(t *testing.T) { | ||
| 13 | st, repo, owner := twoMRTestRepo(t) | ||
| 14 | if _, err := st.CreateIssue(repo.ID, owner.ID, "Android app", "", "md"); err != nil { | ||
| 15 | t.Fatal(err) | ||
| 16 | } | ||
| 17 | if _, err := st.CreateBuild(repo.ID, "test", "abc111", "main", `["go test ./..."]`, "", "", true); err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | for name, argv := range map[string][]string{ | ||
| 21 | "mr-list": {"mr", "list", repo.Path()}, | ||
| 22 | "issue-list": {"issue", "list", repo.Path()}, | ||
| 23 | "build-list": {"build", "list", repo.Path()}, | ||
| 24 | } { | ||
| 25 | c, out, errOut := mrTestCtx(st, owner) | ||
| 26 | if code := Dispatch(c, argv); code != protocol.ExitOK { | ||
| 27 | t.Fatalf("%s: exit %d: %s", name, code, errOut) | ||
| 28 | } | ||
| 29 | pinPlain(t, name, out.String()) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | func TestMRListScreen(t *testing.T) { | ||
| 34 | c := screenCtx(100, false) | ||
| 35 | c.User = store.User{Username: "alice"} | ||
| 36 | mrs := []store.MR{{ID: 1, Number: 552, UpdatedAt: "2026-10-01T11:40:00Z"}, {ID: 2, Number: 551, UpdatedAt: "2026-10-01T10:00:00Z"}} | ||
| 37 | ds := []mrOut{ | ||
| 38 | {Number: 552, Title: "wire $PAGER", State: "open", Source: "cli-pager", TargetRef: "main"}, | ||
| 39 | {Number: 551, Title: "changelog", State: "open", Source: "release", TargetRef: "stable"}, | ||
| 40 | } | ||
| 41 | checks := map[int64]cell{1: cMark("✓ 2/2", sgrGreen), 2: cMark("✗ 1 failed", sgrRed)} | ||
| 42 | review := map[int64]cell{1: cMark("review requested", sgrYellow)} | ||
| 43 | s := mrListScreen(c, screenRepo, "open", mrs, ds, checks, review) | ||
| 44 | if len(s.sections) != 1 || s.sections[0].title != "Open merge requests" || s.sections[0].n != 2 { | ||
| 45 | t.Fatalf("sections = %+v", s.sections) | ||
| 46 | } | ||
| 47 | if g := s.sections[0].rows[0].cells[1]; g.s != "●" { | ||
| 48 | t.Errorf("review requested of the viewer should lead with ●, got %q", g.s) | ||
| 49 | } | ||
| 50 | if g := s.sections[0].rows[1].cells[1]; g.s != "✗" || g.sgr != sgrRed { | ||
| 51 | t.Errorf("failed checks should lead with a red ✗, got %+v", g) | ||
| 52 | } | ||
| 53 | out := renderString(c, s) | ||
| 54 | if !strings.Contains(out, "release → stable") { | ||
| 55 | t.Errorf("non-default target not shown:\n%s", out) | ||
| 56 | } | ||
| 57 | var verbs []string | ||
| 58 | for _, a := range s.actions { | ||
| 59 | verbs = append(verbs, strings.Join(a.argv[:2], " ")) | ||
| 60 | } | ||
| 61 | if !slices.Equal(verbs, []string{"mr create", "mr list"}) { | ||
| 62 | t.Errorf("actions = %q", verbs) | ||
| 63 | } | ||
| 64 | checkActions(t, s) | ||
| 65 | } | ||
| 66 | |||
| 67 | func TestIssueListScreen(t *testing.T) { | ||
| 68 | c := screenCtx(100, false) | ||
| 69 | c.User = store.User{Username: "alice"} | ||
| 70 | issues := []store.Issue{{ID: 1, Number: 12, UpdatedAt: "2026-09-01T10:00:00Z"}, {ID: 2, Number: 13, UpdatedAt: "2026-09-02T10:00:00Z"}} | ||
| 71 | ds := []issueOut{{Number: 12, Title: "Android app", State: "open"}, {Number: 13, Title: "Docs", State: "open"}} | ||
| 72 | s := issueListScreen(c, screenRepo, "open", issues, ds, map[int64]int{1: 3}, map[int64][]string{1: {"mobile"}}, map[int64][]string{1: {"alice"}}) | ||
| 73 | sec := s.sections[0] | ||
| 74 | if sec.title != "Open issues" || sec.n != 2 { | ||
| 75 | t.Fatalf("section = %+v", sec) | ||
| 76 | } | ||
| 77 | if g := sec.rows[0].cells[1]; g.s != "●" { | ||
| 78 | t.Errorf("assigned to the viewer should lead with ●, got %q", g.s) | ||
| 79 | } | ||
| 80 | if out := renderString(c, s); !strings.Contains(out, "mobile · alice · 3 comments") { | ||
| 81 | t.Errorf("meta:\n%s", out) | ||
| 82 | } | ||
| 83 | var verbs []string | ||
| 84 | for _, a := range s.actions { | ||
| 85 | verbs = append(verbs, strings.Join(a.argv, " ")) | ||
| 86 | } | ||
| 87 | if !slices.Equal(verbs, []string{"issue create krz/gitbay", "issue list krz/gitbay --state closed"}) { | ||
| 88 | t.Errorf("actions = %q", verbs) | ||
| 89 | } | ||
| 90 | checkActions(t, s) | ||
| 91 | } | ||
| 92 | |||
| 93 | func TestBuildListScreen(t *testing.T) { | ||
| 94 | c := screenCtx(100, false) | ||
| 95 | ds := []BuildOut{ | ||
| 96 | {Number: 1784, Job: "test", Status: "running", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"}, | ||
| 97 | {Number: 1783, Job: "build", Status: "success", Ref: "cli-views-2a", Subject: "docs: screens", CreatedAt: "2026-10-01T12:00:00Z"}, | ||
| 98 | } | ||
| 99 | s := buildListScreen(c, screenRepo, ds) | ||
| 100 | sec := s.sections[0] | ||
| 101 | if sec.title != "Builds" || sec.n != 2 { | ||
| 102 | t.Fatalf("section = %+v", sec) | ||
| 103 | } | ||
| 104 | if g := sec.rows[0].cells[1]; g.s != "◐" { | ||
| 105 | t.Errorf("running build glyph = %q", g.s) | ||
| 106 | } | ||
| 107 | if len(s.actions) != 1 || strings.Join(s.actions[0].argv, " ") != "build show krz/gitbay 1784" { | ||
| 108 | t.Errorf("actions = %+v", s.actions) | ||
| 109 | } | ||
| 110 | checkActions(t, s) | ||
| 111 | } | ||
internal/control/marks.go −9
| @@ -115,12 +115,3 @@ func labelsMark(labels []string) string { | |||
| 115 | } | 115 | } |
| 116 | return fmt.Sprintf("%s, +%d", strings.Join(labels[:2], ", "), len(labels)-2) | 116 | return fmt.Sprintf("%s, +%d", strings.Join(labels[:2], ", "), len(labels)-2) |
| 117 | } | 117 | } |
| 118 | |||
| 119 | // assigneesMark is a row's assignees, yellow when the viewer is one. | ||
| 120 | func assigneesMark(assignees []string, viewer string) cell { | ||
| 121 | s := labelsMark(assignees) | ||
| 122 | if slices.Contains(assignees, viewer) { | ||
| 123 | return cMark(s, sgrYellow) | ||
| 124 | } | ||
| 125 | return cText(s) | ||
| 126 | } | ||
internal/control/marks_test.go +1 −29
| @@ -59,38 +59,10 @@ func TestReviewMark(t *testing.T) { | |||
| 59 | } | 59 | } |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | func TestLabelsAndAssignees(t *testing.T) { | 62 | func TestLabelsMark(t *testing.T) { |
| 63 | if got := labelsMark([]string{"a", "b", "c", "d"}); got != "a, b, +2" { | 63 | if got := labelsMark([]string{"a", "b", "c", "d"}); got != "a, b, +2" { |
| 64 | t.Errorf("labelsMark = %q", got) | 64 | t.Errorf("labelsMark = %q", got) |
| 65 | } | 65 | } |
| 66 | if got := assigneesMark([]string{"me"}, "me"); got.sgr != sgrYellow { | ||
| 67 | t.Errorf("viewer's assignment not yellow: %+v", got) | ||
| 68 | } | ||
| 69 | if got := assigneesMark([]string{"bob"}, "me"); got.sgr != "" { | ||
| 70 | t.Errorf("someone else's assignment coloured: %+v", got) | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | func TestFailingBuilds(t *testing.T) { | ||
| 75 | builds := []DashboardBuild{ | ||
| 76 | {Repo: "a/x", Job: "test", Ref: "main", Status: "success"}, | ||
| 77 | {Repo: "a/x", Job: "test", Ref: "main", Status: "failure"}, // replaced by the newer success | ||
| 78 | {Repo: "a/y", Job: "pull", Ref: "main", Status: "failure"}, | ||
| 79 | } | ||
| 80 | if got := failingBuilds(builds); got != 1 { | ||
| 81 | t.Errorf("failingBuilds = %d, want 1", got) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | func TestNeedsYou(t *testing.T) { | ||
| 86 | term := Term{Cols: 80} | ||
| 87 | if got := term.needsYou(DashboardOut{}); got != "Nothing waits on you." { | ||
| 88 | t.Errorf("empty = %q", got) | ||
| 89 | } | ||
| 90 | d := DashboardOut{Reviews: make([]DashboardItem, 2), Unread: 1} | ||
| 91 | if got := term.needsYou(d); got != "Needs you: 2 reviews requested, 1 unread notification" { | ||
| 92 | t.Errorf("needsYou = %q", got) | ||
| 93 | } | ||
| 94 | } | 66 | } |
| 95 | 67 | ||
| 96 | func TestStepState(t *testing.T) { | 68 | func TestStepState(t *testing.T) { |
internal/control/mr.go +35 −20
| @@ -685,11 +685,7 @@ func runMRList(c *Ctx, args []string) int { | |||
| 685 | return c.fail(protocol.ExitFailure, "%v", err) | 685 | return c.fail(protocol.ExitFailure, "%v", err) |
| 686 | } | 686 | } |
| 687 | } | 687 | } |
| 688 | return c.emitPage(p, ds, next, func(w io.Writer) { | 688 | return c.emitPageView(p, ds, next, func(w io.Writer) { |
| 689 | if c.Term.Cols > 0 { | ||
| 690 | mrListTerm(c, w, repo, mrs, ds, checks, review) | ||
| 691 | return | ||
| 692 | } | ||
| 693 | tb := c.table(w, "!", "STATE", "TITLE", "REF") | 689 | tb := c.table(w, "!", "STATE", "TITLE", "REF") |
| 694 | for _, d := range ds { | 690 | for _, d := range ds { |
| 695 | state := d.State | 691 | state := d.State |
| @@ -703,31 +699,50 @@ func runMRList(c *Ctx, args []string) int { | |||
| 703 | tb.row(cells...) | 699 | tb.row(cells...) |
| 704 | } | 700 | } |
| 705 | tb.flush() | 701 | tb.flush() |
| 706 | }) | 702 | }, func() screen { return mrListScreen(c, repo, f.State, mrs, ds, checks, review) }) |
| 707 | } | 703 | } |
| 708 | 704 | ||
| 709 | // mrListTerm is mr list at a terminal: where checks and review stand | 705 | // mrListScreen is mr list at a terminal: one section of merge requests, |
| 710 | // for each open merge request, and when it last changed. The source | 706 | // each led by ● when its review waits on the viewer, otherwise by its |
| 707 | // checks' mark, then title, review state, branch and age. The source | ||
| 711 | // branch alone names a merge request into the default branch. | 708 | // branch alone names a merge request into the default branch. |
| 712 | func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) { | 709 | func mrListScreen(c *Ctx, repo store.Repo, state string, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) screen { |
| 713 | tb := c.table(w, "!", "STATE", "TITLE", "BRANCH", "CHECKS", "REVIEW", "UPDATED") | 710 | title, ok := map[string]string{"open": "Open merge requests", "closed": "Closed merge requests", "merged": "Merged merge requests"}[state] |
| 711 | if !ok { | ||
| 712 | title = "Merge requests" | ||
| 713 | } | ||
| 714 | sec := section{title: title, n: len(ds)} | ||
| 714 | for i, d := range ds { | 715 | for i, d := range ds { |
| 715 | state := d.State | 716 | m := mrs[i] |
| 716 | if d.Draft { | 717 | lead := cell{kind: kindGlyph} |
| 717 | state = "draft" | 718 | if ch := checks[m.ID]; ch.s != "" { |
| 719 | g, _, _ := strings.Cut(ch.s, " ") | ||
| 720 | lead = cell{kind: kindGlyph, s: g, sgr: ch.sgr} | ||
| 718 | } | 721 | } |
| 719 | ref := d.Source | 722 | if review[m.ID].sgr == sgrYellow { |
| 723 | lead = cYou() | ||
| 724 | } | ||
| 725 | branch := d.Source | ||
| 720 | if d.TargetRef != repo.DefaultBranch { | 726 | if d.TargetRef != repo.DefaultBranch { |
| 721 | ref += " -> " + d.TargetRef | 727 | branch += " → " + d.TargetRef |
| 722 | } | 728 | } |
| 723 | if d.StackedOn != nil { | 729 | if d.StackedOn != nil { |
| 724 | ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) | 730 | branch += fmt.Sprintf(" (on !%d)", d.StackedOn.Number) |
| 725 | } | 731 | } |
| 726 | m := mrs[i] | 732 | if d.Draft { |
| 727 | tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref), | 733 | branch = "draft · " + branch |
| 728 | checks[m.ID], review[m.ID], cAge(m.UpdatedAt)) | 734 | } |
| 735 | ref := cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) | ||
| 736 | sec.rows = append(sec.rows, rowOf(ref, lead, cFlex(d.Title), review[m.ID], cMeta(branch, relAge(m.UpdatedAt, termNow())))) | ||
| 737 | } | ||
| 738 | other := "all" | ||
| 739 | if state == "all" { | ||
| 740 | other = "open" | ||
| 729 | } | 741 | } |
| 730 | tb.flush() | 742 | return screen{sections: []section{sec}, actions: []action{ |
| 743 | {"New", []string{"mr", "create", repo.Path()}}, | ||
| 744 | {"Filter", []string{"mr", "list", repo.Path(), "--state", other}}, | ||
| 745 | }} | ||
| 731 | } | 746 | } |
| 732 | 747 | ||
| 733 | // byWhom renders " by <user>", or nothing when the actor is unknown — an | 748 | // byWhom renders " by <user>", or nothing when the actor is unknown — an |
internal/control/testdata/plain/build-list.txt added +1
| @@ -0,0 +1 @@ | |||
| 1 | 1 test pending abc111 main | ||
internal/control/testdata/plain/dashboard.txt added +15
| @@ -0,0 +1,15 @@ | |||
| 1 | waiting on your review: | ||
| 2 | none | ||
| 3 | assigned to you: | ||
| 4 | none | ||
| 5 | open merge requests: | ||
| 6 | alice/app!2 two alice | ||
| 7 | alice/app!1 one alice | ||
| 8 | open issues: | ||
| 9 | none | ||
| 10 | pinned: | ||
| 11 | none | ||
| 12 | recent activity: | ||
| 13 | none | ||
| 14 | builds: | ||
| 15 | none | ||
internal/control/testdata/plain/issue-list.txt added +1
| @@ -0,0 +1 @@ | |||
| 1 | #1 open Android app alice | ||
internal/control/testdata/plain/mr-list.txt added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | !2 open two feature2 -> main | ||
| 2 | !1 open one feature1 -> main | ||