Commit 0b989acdeb

0b989acdeb0f5efbbdcb94c384698f72ec8a31b3

parent: 982171c76d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-18 19:35 UTC

dashboard: activity feed shows relative times with the exact time in a title

feedLine gains WhenT, parsed from the stored RFC3339 timestamp; the
dashboard template renders it with ago/whenT instead of the raw
absolute stamp, matching how the tree view already shows commit
times. An unparseable or missing timestamp comes back zero rather
than guessing (D05).

e2e: TestDashboardFeedFoldsBuildRun reports two jobs on one commit
through a real runner and checks the dashboard renders them as one
folded line, marked with the worse status, with the short sha and a
relative time.

Closes #222
e2e/dashboard_test.go +53
@@ -356,3 +356,56 @@ func TestDashboardQueues(t *testing.T) {
356356 t.Fatalf("reviewed MR still waiting:\n%s", after)
357357 }
358358}
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.
363func 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/httpd/feed.go +18 −5
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "fmt"
66 "strings"
7 "time"
78
89 "gitbay.org/gitbay/internal/store"
910)
@@ -15,10 +16,11 @@ type feedLine struct {
1516 Ref string // "#12", "!35", "v0.4.0", a short sha
1617 Repo string
1718 URL string
18 When string // the stored timestamp, for anything still reading it raw
19 State string // a build run's combined status; empty for anything else
20 Jobs []string // job names folded into a build run
21 sha string // the commit a build event fired on, for fold-matching
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
2224}
2325
2426// feedLines turns stored events into readable lines. An unknown kind
@@ -52,7 +54,7 @@ func feedLines(events []store.FeedEvent) []feedLine {
5254 }
5355 }
5456
55 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)}
5658 if l.Actor == "" {
5759 l.Actor = "gitbay"
5860 }
@@ -89,6 +91,17 @@ func feedLines(events []store.FeedEvent) []feedLine {
8991 return out
9092}
9193
94// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
95// rendering; an unparseable value (or none) comes back zero rather than
96// guessing.
97func 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
92105func issueVerb(s string) string {
93106 switch s {
94107 case "created":
internal/httpd/feed_test.go +25
@@ -2,6 +2,7 @@ package httpd
22
33import (
44 "testing"
5 "time"
56
67 "gitbay.org/gitbay/internal/store"
78)
@@ -129,3 +130,27 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
129130 }
130131 }
131132}
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.
137func 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/templates/dashboard.html +1 −1
@@ -36,7 +36,7 @@
3636<aside class="aside">
3737 <div class="grp">
3838 <h2>Recent activity</h2>
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}} · {{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>
4040 {{else}}<p class="none">No activity yet</p>{{end}}
4141 </div>
4242</aside>