dashboard: build runs in the feed, relative times; the rail strip tidied !409
9 files changed, +323 −18
Layout: unified · split
e2e/dashboard_test.go +53
| @@ -356,3 +356,56 @@ func TestDashboardQueues(t *testing.T) { | |||
| 356 | t.Fatalf("reviewed MR still waiting:\n%s", after) | 356 | t.Fatalf("reviewed MR still waiting:\n%s", after) |
| 357 | } | 357 | } |
| 358 | } | 358 | } |
| 359 | |||
| 360 | // D04/D05: two jobs on one commit fold into a single feed line, marked | ||
| 361 | // with the worse of the two outcomes, and shown with a relative time | ||
| 362 | // carrying the exact UTC time in its title. | ||
| 363 | func TestDashboardFeedFoldsBuildRun(t *testing.T) { | ||
| 364 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 365 | inst.runner = buildRunner(t) | ||
| 366 | aliceKey := inst.newKey(t, "alice") | ||
| 367 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 368 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 369 | runnerKey := inst.newKey(t, "ci") | ||
| 370 | inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin") | ||
| 371 | |||
| 372 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 373 | t.Fatalf("repo create: %s", errOut) | ||
| 374 | } | ||
| 375 | work := t.TempDir() | ||
| 376 | env := inst.gitEnv(aliceKey) | ||
| 377 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 378 | dir := filepath.Join(work, "w") | ||
| 379 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | ||
| 380 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte( | ||
| 381 | "jobs:\n ok:\n steps:\n - echo fine\n broken:\n steps:\n - \"false\"\n"), 0o644) | ||
| 382 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 383 | mustGit(t, dir, env, "add", ".") | ||
| 384 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 385 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 386 | sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | ||
| 387 | |||
| 388 | // The runner processes both jobs ("broken" sorts first). | ||
| 389 | inst.runnerOnce(t, runnerKey) | ||
| 390 | inst.runnerOnce(t, runnerKey) | ||
| 391 | |||
| 392 | _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") | ||
| 393 | if !strings.Contains(body, "ran 2 jobs on") { | ||
| 394 | t.Fatalf("feed did not fold the two jobs into one run:\n%s", body) | ||
| 395 | } | ||
| 396 | if !strings.Contains(body, `class="dot bad"`) { | ||
| 397 | t.Fatalf("feed did not mark the run with the worse (failure) status:\n%s", body) | ||
| 398 | } | ||
| 399 | if !strings.Contains(body, sha[:10]) { | ||
| 400 | t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body) | ||
| 401 | } | ||
| 402 | // A build reported moments ago renders as "just now"; ago() only | ||
| 403 | // switches to "N ago" past a minute, so either form proves the | ||
| 404 | // relative-time rendering rather than the raw timestamp. | ||
| 405 | if !strings.Contains(body, ">just now<") && !strings.Contains(body, " ago<") { | ||
| 406 | t.Fatalf("feed missing a relative time:\n%s", body) | ||
| 407 | } | ||
| 408 | if !strings.Contains(body, "title=\"") || !strings.Contains(body, " UTC\"") { | ||
| 409 | t.Fatalf("feed missing the exact time in a title:\n%s", body) | ||
| 410 | } | ||
| 411 | } | ||
internal/control/build.go +3 −3
| @@ -593,7 +593,7 @@ func runRunnerDone(c *Ctx, args []string) int { | |||
| 593 | return c.fail(protocol.ExitFailure, "%v", err) | 593 | return c.fail(protocol.ExitFailure, "%v", err) |
| 594 | } | 594 | } |
| 595 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], | 595 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], |
| 596 | fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) | 596 | fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) |
| 597 | // A red build mails the repo's notify targets with the log tail — a | 597 | // A red build mails the repo's notify targets with the log tail — a |
| 598 | // failed scheduled job must not wait to be noticed. | 598 | // failed scheduled job must not wait to be noticed. |
| 599 | if args[1] == "failure" { | 599 | if args[1] == "failure" { |
| @@ -829,7 +829,7 @@ func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int { | |||
| 829 | c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf( | 829 | c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf( |
| 830 | "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA))) | 830 | "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA))) |
| 831 | resolveCancelledCommitStatus(c, repo, b) | 831 | resolveCancelledCommitStatus(c, repo, b) |
| 832 | c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) | 832 | c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) |
| 833 | return -1 | 833 | return -1 |
| 834 | } | 834 | } |
| 835 | 835 | ||
| @@ -858,7 +858,7 @@ func runBuildCancel(c *Ctx, args []string) int { | |||
| 858 | } | 858 | } |
| 859 | // The queued status replaced whatever the commit had for this job. | 859 | // The queued status replaced whatever the commit had for this job. |
| 860 | resolveCancelledCommitStatus(c, repo, b) | 860 | resolveCancelledCommitStatus(c, repo, b) |
| 861 | c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) | 861 | c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) |
| 862 | return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) { | 862 | return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) { |
| 863 | if b.Status == "running" { | 863 | if b.Status == "running" { |
| 864 | fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job) | 864 | fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job) |
internal/httpd/builds.go +20 −2
| @@ -99,15 +99,33 @@ var runStatusPriority = []string{"failure", "cancelled", "running", "pending"} | |||
| 99 | // combinedStatus is the run's status: the worst of its builds' statuses, | 99 | // combinedStatus is the run's status: the worst of its builds' statuses, |
| 100 | // success only when every one of them is. | 100 | // success only when every one of them is. |
| 101 | func combinedStatus(builds []control.BuildOut) string { | 101 | func combinedStatus(builds []control.BuildOut) string { |
| 102 | statuses := make([]string, len(builds)) | ||
| 103 | for i, b := range builds { | ||
| 104 | statuses[i] = b.Status | ||
| 105 | } | ||
| 106 | return worstStatus(statuses) | ||
| 107 | } | ||
| 108 | |||
| 109 | // worstStatus is combinedStatus's ordering rule, factored out so the | ||
| 110 | // dashboard feed can apply the same worst-first precedence to a folded | ||
| 111 | // build run (D04). A status outside runStatusPriority (a future state | ||
| 112 | // such as "skipped") is still not "success": it is returned unchanged | ||
| 113 | // rather than falling through and reading as green. | ||
| 114 | func worstStatus(statuses []string) string { | ||
| 102 | has := map[string]bool{} | 115 | has := map[string]bool{} |
| 103 | for _, b := range builds { | 116 | for _, s := range statuses { |
| 104 | has[b.Status] = true | 117 | has[s] = true |
| 105 | } | 118 | } |
| 106 | for _, s := range runStatusPriority { | 119 | for _, s := range runStatusPriority { |
| 107 | if has[s] { | 120 | if has[s] { |
| 108 | return s | 121 | return s |
| 109 | } | 122 | } |
| 110 | } | 123 | } |
| 124 | for _, s := range statuses { | ||
| 125 | if s != "success" { | ||
| 126 | return s | ||
| 127 | } | ||
| 128 | } | ||
| 111 | return "success" | 129 | return "success" |
| 112 | } | 130 | } |
| 113 | 131 | ||
internal/httpd/builds_test.go +4
| @@ -55,6 +55,10 @@ func TestGroupRunsCombinedStatusPriority(t *testing.T) { | |||
| 55 | {[]string{"running", "cancelled"}, "cancelled"}, | 55 | {[]string{"running", "cancelled"}, "cancelled"}, |
| 56 | {[]string{"cancelled", "failure"}, "failure"}, | 56 | {[]string{"cancelled", "failure"}, "failure"}, |
| 57 | {[]string{"success", "success", "failure"}, "failure"}, | 57 | {[]string{"success", "success", "failure"}, "failure"}, |
| 58 | // A status outside runStatusPriority (a future state such as | ||
| 59 | // "skipped") is still not success: it must not fall through to | ||
| 60 | // the "success" default and read as green. | ||
| 61 | {[]string{"success", "skipped"}, "skipped"}, | ||
| 58 | } | 62 | } |
| 59 | for _, tc := range cases { | 63 | for _, tc := range cases { |
| 60 | var builds []control.BuildOut | 64 | var builds []control.BuildOut |
internal/httpd/feed.go +47 −5
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "strings" | 6 | "strings" |
| 7 | "time" | ||
| 7 | 8 | ||
| 8 | "gitbay.org/gitbay/internal/store" | 9 | "gitbay.org/gitbay/internal/store" |
| 9 | ) | 10 | ) |
| @@ -11,30 +12,53 @@ import ( | |||
| 11 | // feedLine is one activity entry, already phrased and linked. | 12 | // feedLine is one activity entry, already phrased and linked. |
| 12 | type feedLine struct { | 13 | type feedLine struct { |
| 13 | Actor string | 14 | Actor string |
| 14 | Verb string // "opened issue", "merged" | 15 | Verb string // "opened issue", "merged", "ran 2 jobs on" |
| 15 | Ref string // "#12", "!35", "v0.4.0" | 16 | Ref string // "#12", "!35", "v0.4.0", a short sha |
| 16 | Repo string | 17 | Repo string |
| 17 | URL string | 18 | URL string |
| 18 | When string | 19 | When string // the stored timestamp, for anything still reading it raw |
| 20 | WhenT time.Time // parsed from When, for ago/whenT rendering | ||
| 21 | State string // a build run's combined status; empty for anything else | ||
| 22 | Jobs []string // job names folded into a build run | ||
| 23 | sha string // the commit a build event fired on, for fold-matching | ||
| 19 | } | 24 | } |
| 20 | 25 | ||
| 21 | // feedLines turns stored events into readable lines. An unknown kind | 26 | // feedLines turns stored events into readable lines. An unknown kind |
| 22 | // still shows: the feed says what happened even for events added later. | 27 | // still shows: the feed says what happened even for events added later. |
| 28 | // Build events on the same commit, adjacent in the input, fold into one | ||
| 29 | // "run" line (D04): its State is the worst of the folded jobs' outcomes, | ||
| 30 | // via worstStatus — the same rule the builds tab uses for a run's status. | ||
| 23 | func feedLines(events []store.FeedEvent) []feedLine { | 31 | func feedLines(events []store.FeedEvent) []feedLine { |
| 24 | out := make([]feedLine, 0, len(events)) | 32 | out := make([]feedLine, 0, len(events)) |
| 33 | statuses := make([][]string, 0, len(events)) | ||
| 25 | for _, e := range events { | 34 | for _, e := range events { |
| 26 | var d struct { | 35 | var d struct { |
| 27 | Number int64 `json:"number"` | 36 | Number int64 `json:"number"` |
| 28 | Job string `json:"job"` | 37 | Job string `json:"job"` |
| 29 | Tag string `json:"tag"` | 38 | Tag string `json:"tag"` |
| 39 | SHA string `json:"sha"` | ||
| 30 | } | 40 | } |
| 31 | json.Unmarshal([]byte(e.Data), &d) | 41 | json.Unmarshal([]byte(e.Data), &d) |
| 42 | kind, rest, _ := strings.Cut(e.Kind, ".") | ||
| 43 | |||
| 44 | if kind == "build" && d.SHA != "" { | ||
| 45 | if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath { | ||
| 46 | i := n - 1 | ||
| 47 | out[i].Jobs = append(out[i].Jobs, d.Job) | ||
| 48 | statuses[i] = append(statuses[i], rest) | ||
| 49 | out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs)) | ||
| 50 | out[i].Ref = fmt.Sprintf("%.10s", d.SHA) | ||
| 51 | out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA) | ||
| 52 | out[i].State = worstStatus(statuses[i]) | ||
| 53 | continue | ||
| 54 | } | ||
| 55 | } | ||
| 32 | 56 | ||
| 33 | l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt} | 57 | l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)} |
| 34 | if l.Actor == "" { | 58 | if l.Actor == "" { |
| 35 | l.Actor = "gitbay" | 59 | l.Actor = "gitbay" |
| 36 | } | 60 | } |
| 37 | kind, rest, _ := strings.Cut(e.Kind, ".") | 61 | var st []string |
| 38 | switch kind { | 62 | switch kind { |
| 39 | case "issue": | 63 | case "issue": |
| 40 | l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) | 64 | l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) |
| @@ -45,6 +69,12 @@ func feedLines(events []store.FeedEvent) []feedLine { | |||
| 45 | case "build": | 69 | case "build": |
| 46 | l.Verb, l.Ref = "build "+rest, d.Job | 70 | l.Verb, l.Ref = "build "+rest, d.Job |
| 47 | l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) | 71 | l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) |
| 72 | if d.SHA != "" { | ||
| 73 | l.sha = d.SHA | ||
| 74 | l.Jobs = []string{d.Job} | ||
| 75 | l.State = rest | ||
| 76 | st = []string{rest} | ||
| 77 | } | ||
| 48 | case "release": | 78 | case "release": |
| 49 | l.Verb, l.Ref = "released", d.Tag | 79 | l.Verb, l.Ref = "released", d.Tag |
| 50 | l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) | 80 | l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) |
| @@ -56,10 +86,22 @@ func feedLines(events []store.FeedEvent) []feedLine { | |||
| 56 | l.URL = "/" + e.RepoPath | 86 | l.URL = "/" + e.RepoPath |
| 57 | } | 87 | } |
| 58 | out = append(out, l) | 88 | out = append(out, l) |
| 89 | statuses = append(statuses, st) | ||
| 59 | } | 90 | } |
| 60 | return out | 91 | return out |
| 61 | } | 92 | } |
| 62 | 93 | ||
| 94 | // parseEventTime parses a stored RFC3339 timestamp for ago/whenT | ||
| 95 | // rendering; an unparseable value (or none) comes back zero rather than | ||
| 96 | // guessing. | ||
| 97 | func parseEventTime(s string) time.Time { | ||
| 98 | t, err := time.Parse(time.RFC3339Nano, s) | ||
| 99 | if err != nil { | ||
| 100 | return time.Time{} | ||
| 101 | } | ||
| 102 | return t | ||
| 103 | } | ||
| 104 | |||
| 63 | func issueVerb(s string) string { | 105 | func issueVerb(s string) string { |
| 64 | switch s { | 106 | switch s { |
| 65 | case "created": | 107 | case "created": |
internal/httpd/feed_test.go added +156
| @@ -0,0 +1,156 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "time" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/store" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // D04: build events on the same commit fold into one feed line, a "run", | ||
| 11 | // whose State is the worst of the folded jobs' outcomes. | ||
| 12 | func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) { | ||
| 13 | events := []store.FeedEvent{ | ||
| 14 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", | ||
| 15 | Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 16 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", | ||
| 17 | Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 18 | } | ||
| 19 | lines := feedLines(events) | ||
| 20 | if len(lines) != 1 { | ||
| 21 | t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines) | ||
| 22 | } | ||
| 23 | l := lines[0] | ||
| 24 | if l.State != "failure" { | ||
| 25 | t.Errorf("State = %q, want failure", l.State) | ||
| 26 | } | ||
| 27 | if l.Verb != "ran 2 jobs on" { | ||
| 28 | t.Errorf("Verb = %q, want %q", l.Verb, "ran 2 jobs on") | ||
| 29 | } | ||
| 30 | if l.Ref != "aaaaaaaaaa" { | ||
| 31 | t.Errorf("Ref = %q, want short sha", l.Ref) | ||
| 32 | } | ||
| 33 | if l.URL != "/alice/app/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { | ||
| 34 | t.Errorf("URL = %q", l.URL) | ||
| 35 | } | ||
| 36 | if len(l.Jobs) != 2 || l.Jobs[0] != "unit" || l.Jobs[1] != "lint" { | ||
| 37 | t.Errorf("Jobs = %+v", l.Jobs) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | // A single build event with a sha still gets a State, and keeps its | ||
| 42 | // ordinary verb/ref/url — a run of one job reads the same as before. | ||
| 43 | func TestFeedLinesSingleBuildGetsState(t *testing.T) { | ||
| 44 | events := []store.FeedEvent{ | ||
| 45 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", | ||
| 46 | Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 47 | } | ||
| 48 | lines := feedLines(events) | ||
| 49 | if len(lines) != 1 { | ||
| 50 | t.Fatalf("feedLines returned %d lines, want 1", len(lines)) | ||
| 51 | } | ||
| 52 | l := lines[0] | ||
| 53 | if l.State != "success" { | ||
| 54 | t.Errorf("State = %q, want success", l.State) | ||
| 55 | } | ||
| 56 | if l.Verb != "build success" || l.Ref != "unit" || l.URL != "/alice/app/builds/1" { | ||
| 57 | t.Errorf("single build line changed shape: %+v", l) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | // Two different commits never fold, even back to back. | ||
| 62 | func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) { | ||
| 63 | events := []store.FeedEvent{ | ||
| 64 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", | ||
| 65 | Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 66 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", | ||
| 67 | Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`}, | ||
| 68 | } | ||
| 69 | lines := feedLines(events) | ||
| 70 | if len(lines) != 2 { | ||
| 71 | t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // An event with no sha (an older event recorded before this field existed) | ||
| 76 | // never folds into anything, even when it shares a repo with an adjacent | ||
| 77 | // build event. | ||
| 78 | func TestFeedLinesNoSHANeverFolds(t *testing.T) { | ||
| 79 | events := []store.FeedEvent{ | ||
| 80 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`}, | ||
| 81 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`}, | ||
| 82 | } | ||
| 83 | lines := feedLines(events) | ||
| 84 | if len(lines) != 2 { | ||
| 85 | t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) | ||
| 86 | } | ||
| 87 | if lines[0].State != "" || lines[1].State != "" { | ||
| 88 | t.Errorf("shaless build lines got a State: %+v", lines) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | // A non-build event between two builds of the same commit breaks the | ||
| 93 | // fold: only adjacent build events on the same commit combine. | ||
| 94 | func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) { | ||
| 95 | events := []store.FeedEvent{ | ||
| 96 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", | ||
| 97 | Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 98 | {RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":1}`}, | ||
| 99 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", | ||
| 100 | Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, | ||
| 101 | } | ||
| 102 | lines := feedLines(events) | ||
| 103 | if len(lines) != 3 { | ||
| 104 | t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // worstStatus governs the run's combined State the same way it governs | ||
| 109 | // combinedStatus for the builds tab. | ||
| 110 | func TestFeedLinesRunStatePrecedence(t *testing.T) { | ||
| 111 | cases := []struct { | ||
| 112 | statuses []string | ||
| 113 | want string | ||
| 114 | }{ | ||
| 115 | {[]string{"success", "success"}, "success"}, | ||
| 116 | {[]string{"success", "pending"}, "pending"}, | ||
| 117 | {[]string{"pending", "running"}, "running"}, | ||
| 118 | {[]string{"running", "cancelled"}, "cancelled"}, | ||
| 119 | {[]string{"cancelled", "failure"}, "failure"}, | ||
| 120 | } | ||
| 121 | for _, tc := range cases { | ||
| 122 | events := make([]store.FeedEvent, len(tc.statuses)) | ||
| 123 | for i, s := range tc.statuses { | ||
| 124 | events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s, | ||
| 125 | Data: `{"number":1,"job":"j","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`} | ||
| 126 | } | ||
| 127 | lines := feedLines(events) | ||
| 128 | if len(lines) != 1 || lines[0].State != tc.want { | ||
| 129 | t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | // D05: feedLines parses the stored RFC3339 timestamp into WhenT for the | ||
| 135 | // template's relative-time rendering; an unparseable value leaves it zero | ||
| 136 | // rather than panicking or guessing. | ||
| 137 | func TestFeedLinesParsesWhenT(t *testing.T) { | ||
| 138 | events := []store.FeedEvent{ | ||
| 139 | {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created", | ||
| 140 | Data: `{"number":1}`, CreatedAt: "2026-09-10T12:00:00Z"}, | ||
| 141 | {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created", | ||
| 142 | Data: `{"number":2}`, CreatedAt: "not-a-time"}, | ||
| 143 | } | ||
| 144 | lines := feedLines(events) | ||
| 145 | want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z") | ||
| 146 | if !lines[0].WhenT.Equal(want) { | ||
| 147 | t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want) | ||
| 148 | } | ||
| 149 | if !lines[1].WhenT.IsZero() { | ||
| 150 | t.Errorf("WhenT for bad timestamp = %v, want zero", lines[1].WhenT) | ||
| 151 | } | ||
| 152 | // When is preserved for anything that still reads the raw string. | ||
| 153 | if lines[0].When != "2026-09-10T12:00:00Z" { | ||
| 154 | t.Errorf("When = %q", lines[0].When) | ||
| 155 | } | ||
| 156 | } | ||
internal/web/static/style.css +38 −6
| @@ -306,6 +306,18 @@ ul.raillist a[aria-current] { | |||
| 306 | font-weight: 600; | 306 | font-weight: 600; |
| 307 | } | 307 | } |
| 308 | ul.raillist .owner { color: var(--shell-muted); } | 308 | ul.raillist .owner { color: var(--shell-muted); } |
| 309 | ul.raillist a.newrepo { | ||
| 310 | display: inline-flex; | ||
| 311 | width: 28px; | ||
| 312 | height: 28px; | ||
| 313 | align-items: center; | ||
| 314 | justify-content: center; | ||
| 315 | padding: 0; | ||
| 316 | border: 1px solid var(--shell-line); | ||
| 317 | border-radius: var(--r-ctl); | ||
| 318 | font-size: var(--fs-3); | ||
| 319 | font-weight: 600; | ||
| 320 | } | ||
| 309 | ul.raillist.wide a { white-space: normal; display: block; } | 321 | ul.raillist.wide a { white-space: normal; display: block; } |
| 310 | ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } | 322 | ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } |
| 311 | ul.raillist.wide .owner { display: block; font-size: var(--fs-0); } | 323 | ul.raillist.wide .owner { display: block; font-size: var(--fs-0); } |
| @@ -1461,28 +1473,48 @@ svg.icon { vertical-align: -0.125em; } | |||
| 1461 | height: auto; | 1473 | height: auto; |
| 1462 | position: static; | 1474 | position: static; |
| 1463 | flex-direction: row; | 1475 | flex-direction: row; |
| 1464 | align-items: center; | 1476 | align-items: flex-start; |
| 1465 | gap: var(--sp-2); | 1477 | gap: var(--sp-2); |
| 1466 | border-right: 0; | 1478 | border-right: 0; |
| 1467 | border-bottom: 1px solid var(--shell-line); | 1479 | border-bottom: 1px solid var(--shell-line); |
| 1468 | overflow-x: auto; | 1480 | overflow-x: auto; |
| 1469 | } | 1481 | } |
| 1470 | a.brand { padding: var(--sp-2) var(--sp-3); } | 1482 | a.brand { padding: var(--sp-2) var(--sp-3); min-height: 39px; display: flex; align-items: center; } |
| 1471 | .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; } | 1483 | .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; } |
| 1472 | .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; } | 1484 | .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; } |
| 1473 | .railtop > * { flex-shrink: 0; } | 1485 | .railtop > * { flex-shrink: 0; } |
| 1474 | form.railsearch { margin: 0 var(--sp-2); } | 1486 | form.railsearch { margin: 0 var(--sp-2); } |
| 1475 | form.railsearch input[type="search"] { min-width: 8rem; } | 1487 | form.railsearch input[type="search"] { min-width: 8rem; } |
| 1476 | /* the strip hides the review queue, reachable from the dashboard, and | 1488 | /* the strip hides the review queue, reachable from the dashboard, and |
| 1477 | shows pinned repositories as a second row of the strip */ | 1489 | shows pinned repositories as a second, quieter row of the strip */ |
| 1478 | .railgroup { display: none; } | 1490 | .railgroup { display: none; } |
| 1479 | .railgroup.pinned { | 1491 | .railgroup.pinned { |
| 1480 | display: block; | 1492 | display: flex; |
| 1493 | align-items: center; | ||
| 1494 | gap: var(--sp-2); | ||
| 1495 | width: 100%; | ||
| 1481 | border-top: 1px solid var(--shell-line); | 1496 | border-top: 1px solid var(--shell-line); |
| 1482 | padding: 0 var(--sp-2); | 1497 | padding: 0 var(--sp-2); |
| 1498 | min-height: 32px; | ||
| 1499 | } | ||
| 1500 | .railgroup.pinned .raillabel { | ||
| 1501 | display: block; | ||
| 1502 | margin: 0; | ||
| 1503 | font-size: var(--fs-0); | ||
| 1504 | color: var(--shell-muted); | ||
| 1505 | text-transform: uppercase; | ||
| 1506 | letter-spacing: .08em; | ||
| 1507 | flex: none; | ||
| 1508 | } | ||
| 1509 | .railgroup.pinned ul.raillist { | ||
| 1510 | display: flex; | ||
| 1511 | overflow-x: auto; | ||
| 1512 | margin: 0; | ||
| 1513 | flex: 1; | ||
| 1514 | min-width: 0; | ||
| 1483 | } | 1515 | } |
| 1484 | .railgroup.pinned .raillabel { display: none; } | 1516 | .railgroup.pinned ul.raillist a { font-size: var(--fs-1); padding: var(--sp-1) var(--sp-2); } |
| 1485 | .railgroup.pinned ul.raillist { overflow-x: auto; } | 1517 | .railgroup.pinned ul.raillist a .owner { display: none; } |
| 1486 | ul.raillist { display: flex; margin: 0; } | 1518 | ul.raillist { display: flex; margin: 0; } |
| 1487 | ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); } | 1519 | ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); } |
| 1488 | ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); } | 1520 | ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); } |
internal/web/templates/dashboard.html +1 −1
| @@ -36,7 +36,7 @@ | |||
| 36 | <aside class="aside"> | 36 | <aside class="aside"> |
| 37 | <div class="grp"> | 37 | <div class="grp"> |
| 38 | <h2>Recent activity</h2> | 38 | <h2>Recent activity</h2> |
| 39 | {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> | 39 | {{range .Feed}}<p class="row feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> |
| 40 | {{else}}<p class="none">No activity yet</p>{{end}} | 40 | {{else}}<p class="none">No activity yet</p>{{end}} |
| 41 | </div> | 41 | </div> |
| 42 | </aside> | 42 | </aside> |
internal/web/templates/layout.html +1 −1
| @@ -25,7 +25,7 @@ | |||
| 25 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> | 25 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> |
| 26 | {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> | 26 | {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> |
| 27 | <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li> | 27 | <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li> |
| 28 | <li><a href="/new">New repository</a></li>{{end}} | 28 | <li><a class="newrepo" href="/new" aria-label="New repository" title="New repository">+</a></li>{{end}} |
| 29 | </ul> | 29 | </ul> |
| 30 | </div> | 30 | </div> |
| 31 | {{with .Rail.Pinned}} | 31 | {{with .Rail.Pinned}} |