cli: dashboard activity as sentences, no duplicate issues !481

merged merged by cmc on 2026-09-28 22:03 UTC · krz/gitbay:cli-ux-activity into main

17 files changed, +326 −72

Layout: unified · split

CHANGELOG.org +7
@@ -69,6 +69,13 @@ must add =--scope full=. Existing tokens keep their scope.
6969 separately (#275).
7070- Audit retention deletes by id, up to the newest row older than the
7171 retention, so a clock step back cannot leave a gap in the chain (#275).
72- =dashboard= and =feed= print activity as sentences
73 (=cmc opened issue krz/gitbay#12=) instead of raw event payloads,
74 and a labelled event names its labels there and on the web feed. An
75 issue assigned to you appears only under "assigned to you": the
76 =open_issues= field of =dashboard --json= no longer includes issues
77 assigned to the caller. =notifications list= names =--all= when only
78 read items remain (#265).
7279
7380* v1.36.0 — 2026-09-23
7481
e2e/dashboard_test.go +3 −2
@@ -247,8 +247,9 @@ func TestDashboardCommand(t *testing.T) {
247247 d.Assigned[0].Title != "todo one" {
248248 t.Fatalf("assigned_issues = %+v", d.Assigned)
249249 }
250 if len(d.Issues) != 1 || d.Issues[0].Repo != "alice/app" || d.Issues[0].Number != 1 ||
251 d.Issues[0].Title != "todo one" {
250 // The only open issue is assigned to alice, so it is listed under
251 // assigned_issues and not again under open_issues.
252 if len(d.Issues) != 0 {
252253 t.Fatalf("open_issues = %+v", d.Issues)
253254 }
254255 if len(d.Activity) == 0 || d.Activity[0].Repo != "alice/app" {
internal/control/dashboard.go +9 −7
@@ -226,11 +226,12 @@ func runDashboard(c *Ctx, args []string) int {
226226 }
227227 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
228228
229 activityRows := make([][]cell, len(d.Activity))
230 for i, e := range d.Activity {
231 activityRows[i] = []cell{cAge(e.CreatedAt), cText(e.Actor), cText(e.Kind), cRef(e.Repo), cFlex(string(e.Data))}
229 lines := FeedLines(events)
230 activityRows := make([][]cell, len(lines))
231 for i, l := range lines {
232 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
232233 }
233 section("recent activity:", []string{"WHEN", "ACTOR", "KIND", "REPO", "DATA"}, activityRows)
234 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
234235
235236 buildRows := make([][]cell, len(d.Builds))
236237 for i, b := range d.Builds {
@@ -343,10 +344,11 @@ func runFeed(c *Ctx, args []string) int {
343344 return strconv.FormatInt(e.ID, 10)
344345 })
345346 ds := feedOutputs(events)
347 lines := FeedLines(events)
346348 return c.emitPage(p, ds, next, func(w io.Writer) {
347 tb := c.table(w, "WHEN", "ACTOR", "KIND", "REPO", "DATA")
348 for _, d := range ds {
349 tb.row(cAge(d.CreatedAt), cText(d.Actor), cText(d.Kind), cRef(d.Repo), cFlex(string(d.Data)))
349 tb := c.table(w, "WHEN", "EVENT")
350 for _, l := range lines {
351 tb.row(cAge(l.When), cFlex(l.Sentence()))
350352 }
351353 tb.flush()
352354 })
internal/control/dashboard_test.go +47
@@ -23,6 +23,53 @@ func TestDashboardEmptySectionsSayNone(t *testing.T) {
2323 }
2424}
2525
26func TestDashboardActivityIsASentence(t *testing.T) {
27 c := notifTestCtx(t, "cmc")
28 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
29 if err != nil {
30 t.Fatal(err)
31 }
32 repo, err := c.Store.RepoByID(repoID)
33 if err != nil {
34 t.Fatal(err)
35 }
36 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", `{"number":262,"labels":["ops","security"]}`)
37
38 var out bytes.Buffer
39 c.Stdout, c.Stderr = &out, &out
40 if code := runDashboard(c, nil); code != 0 {
41 t.Fatalf("exit %d: %s", code, out.String())
42 }
43 if strings.Contains(out.String(), `{"number"`) {
44 t.Errorf("raw payload leaked into plain output:\n%s", out.String())
45 }
46 if !strings.Contains(out.String(), "cmc labelled cmc/gitbay#262 ops, security") {
47 t.Errorf("no sentence in output:\n%s", out.String())
48 }
49}
50
51func TestFeedIsASentence(t *testing.T) {
52 c := notifTestCtx(t, "cmc")
53 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
54 if err != nil {
55 t.Fatal(err)
56 }
57 repo, err := c.Store.RepoByID(repoID)
58 if err != nil {
59 t.Fatal(err)
60 }
61 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", `{"number":1}`)
62
63 var out bytes.Buffer
64 c.Stdout, c.Stderr = &out, &out
65 if code := runFeed(c, nil); code != 0 {
66 t.Fatalf("exit %d: %s", code, out.String())
67 }
68 if !strings.Contains(out.String(), "cmc opened issue cmc/gitbay#1") {
69 t.Errorf("no sentence in output:\n%s", out.String())
70 }
71}
72
2673// The push queue is the one worker queue whose worst failure — a key_id
2774// or team_id Apple did not issue, which config validation cannot check —
2875// dead-letters every row on its first attempt with nothing but a log
internal/httpd/feed.go → internal/control/feedline.go renamed +70 −14
@@ -1,4 +1,4 @@
1package httpd
1package control
22
33import (
44 "encoding/json"
@@ -10,8 +10,8 @@ import (
1010 "gitbay.org/gitbay/internal/store"
1111)
1212
13// feedLine is one activity entry, already phrased and linked.
14type feedLine struct {
13// FeedLine is one activity entry, already phrased and linked.
14type FeedLine struct {
1515 Actor string
1616 Verb string // "opened issue", "merged", "ran 2 jobs on"
1717 Ref string // "#12", "!35", "v0.4.0", a short sha
@@ -21,14 +21,32 @@ type feedLine struct {
2121 WhenT time.Time // parsed from When, for ago/whenT rendering
2222 State string // a build run's combined status; empty for anything else
2323 Jobs []string // job names folded into a build run
24 sha string // the commit a build event fired on, for fold-matching
24 // Extra is trailing detail shown after the ref: the label list on a
25 // labelled event, empty for everything else.
26 Extra string
27 sha string // the commit a build event fired on, for fold-matching
2528}
2629
27// feedLines turns stored events into readable lines. An unknown kind
30// Sentence renders the line the way the CLI shows it: "<actor> <verb>
31// <repo><ref>" for an issue or MR ("krz/gitbay#1"), "<repo> <ref>" for a
32// tag, job or sha, with Extra (a label list) appended when set.
33func (l FeedLine) Sentence() string {
34 ref := l.Ref
35 if ref != "" && ref[0] != '#' && ref[0] != '!' {
36 ref = " " + ref
37 }
38 s := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, ref)
39 if l.Extra != "" {
40 s += " " + l.Extra
41 }
42 return s
43}
44
45// FeedLines turns stored events into readable lines. An unknown kind
2846// still shows: the feed says what happened even for events added later.
2947// Build events on the same commit, adjacent in the input, fold into one
3048// "run" line (D04): its State is the worst of the folded jobs' outcomes,
31// via worstStatus — the same rule the builds tab uses for a run's status.
49// via WorstStatus — the same rule the builds tab uses for a run's status.
3250// A repeated job name ends the line and starts the next, so a scheduled
3351// job firing daily on an unchanged tip reads as one line a day rather than
3452// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
@@ -36,15 +54,16 @@ type feedLine struct {
3654// per job, so there is no queue moment here to key on. The cost is that
3755// the oldest scheduled line on a commit folds in the push's jobs, which
3856// have not been seen yet on that line.
39func feedLines(events []store.FeedEvent) []feedLine {
40 out := make([]feedLine, 0, len(events))
57func FeedLines(events []store.FeedEvent) []FeedLine {
58 out := make([]FeedLine, 0, len(events))
4159 statuses := make([][]string, 0, len(events))
4260 for _, e := range events {
4361 var d struct {
44 Number int64 `json:"number"`
45 Job string `json:"job"`
46 Tag string `json:"tag"`
47 SHA string `json:"sha"`
62 Number int64 `json:"number"`
63 Job string `json:"job"`
64 Tag string `json:"tag"`
65 SHA string `json:"sha"`
66 Labels []string `json:"labels"`
4867 }
4968 json.Unmarshal([]byte(e.Data), &d)
5069 kind, rest, _ := strings.Cut(e.Kind, ".")
@@ -58,12 +77,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
5877 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
5978 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
6079 out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
61 out[i].State = worstStatus(statuses[i])
80 out[i].State = WorstStatus(statuses[i])
6281 continue
6382 }
6483 }
6584
66 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
85 l := FeedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)}
6786 if l.Actor == "" {
6887 l.Actor = "gitbay"
6988 }
@@ -72,9 +91,15 @@ func feedLines(events []store.FeedEvent) []feedLine {
7291 case "issue":
7392 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
7493 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
94 if rest == "labeled" {
95 l.Extra = strings.Join(d.Labels, ", ")
96 }
7597 case "mr":
7698 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
7799 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
100 if rest == "labeled" {
101 l.Extra = strings.Join(d.Labels, ", ")
102 }
78103 case "build":
79104 l.Verb, l.Ref = "build "+rest, d.Job
80105 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
@@ -100,6 +125,33 @@ func feedLines(events []store.FeedEvent) []feedLine {
100125 return out
101126}
102127
128// runStatusPriority orders WorstStatus's worst-first check: a run reads
129// as its least finished or least successful build.
130var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
131
132// WorstStatus is the worst of a set of build statuses, success only when
133// every one of them is. It backs both the builds tab's combinedStatus and
134// FeedLines' folded build run (D04). A status outside runStatusPriority (a
135// future state such as "skipped") is still not "success": it is returned
136// unchanged rather than falling through and reading as green.
137func WorstStatus(statuses []string) string {
138 has := map[string]bool{}
139 for _, s := range statuses {
140 has[s] = true
141 }
142 for _, s := range runStatusPriority {
143 if has[s] {
144 return s
145 }
146 }
147 for _, s := range statuses {
148 if s != "success" {
149 return s
150 }
151 }
152 return "success"
153}
154
103155// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
104156// rendering; an unparseable value (or none) comes back zero rather than
105157// guessing.
@@ -121,6 +173,8 @@ func issueVerb(s string) string {
121173 return "reopened issue"
122174 case "commented":
123175 return "commented on"
176 case "labeled":
177 return "labelled"
124178 }
125179 return "issue " + s
126180}
@@ -135,6 +189,8 @@ func mrVerb(s string) string {
135189 return "commented on"
136190 case "closed":
137191 return "closed merge request"
192 case "labeled":
193 return "labelled"
138194 }
139195 return "merge request " + s
140196}
internal/httpd/feed_test.go → internal/control/feedline_test.go renamed +66 −10
@@ -1,4 +1,4 @@
1package httpd
1package control
22
33import (
44 "fmt"
@@ -18,7 +18,7 @@ func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
1818 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
1919 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
2020 }
21 lines := feedLines(events)
21 lines := FeedLines(events)
2222 if len(lines) != 1 {
2323 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
2424 }
@@ -47,7 +47,7 @@ func TestFeedLinesSingleBuildGetsState(t *testing.T) {
4747 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
4848 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
4949 }
50 lines := feedLines(events)
50 lines := FeedLines(events)
5151 if len(lines) != 1 {
5252 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
5353 }
@@ -68,7 +68,7 @@ func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
6868 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
6969 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
7070 }
71 lines := feedLines(events)
71 lines := FeedLines(events)
7272 if len(lines) != 2 {
7373 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
7474 }
@@ -82,7 +82,7 @@ func TestFeedLinesNoSHANeverFolds(t *testing.T) {
8282 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
8383 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
8484 }
85 lines := feedLines(events)
85 lines := FeedLines(events)
8686 if len(lines) != 2 {
8787 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
8888 }
@@ -101,7 +101,7 @@ func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
101101 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
102102 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
103103 }
104 lines := feedLines(events)
104 lines := FeedLines(events)
105105 if len(lines) != 3 {
106106 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
107107 }
@@ -127,7 +127,7 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
127127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
128128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)}
129129 }
130 lines := feedLines(events)
130 lines := FeedLines(events)
131131 if len(lines) != 1 || lines[0].State != tc.want {
132132 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
133133 }
@@ -144,7 +144,7 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
144144 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
145145 Data: `{"number":2}`, CreatedAt: "not-a-time"},
146146 }
147 lines := feedLines(events)
147 lines := FeedLines(events)
148148 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
149149 if !lines[0].WhenT.Equal(want) {
150150 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
@@ -158,6 +158,35 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
158158 }
159159}
160160
161// A labelled issue names which labels were added, not just "issue
162// labeled": the sentence is otherwise silent about what changed.
163func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) {
164 events := []store.FeedEvent{
165 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled",
166 Data: `{"number":262,"labels":["ops","security"]}`},
167 }
168 lines := FeedLines(events)
169 if len(lines) != 1 {
170 t.Fatalf("FeedLines returned %d lines, want 1", len(lines))
171 }
172 l := lines[0]
173 if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" {
174 t.Errorf("got %+v", l)
175 }
176}
177
178// Same for a labelled merge request.
179func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) {
180 events := []store.FeedEvent{
181 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled",
182 Data: `{"number":471,"labels":["review"]}`},
183 }
184 lines := FeedLines(events)
185 if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" {
186 t.Errorf("got %+v", lines)
187 }
188}
189
161190// A scheduled job firing daily on an unchanged tip is a separate event
162191// each tick, not another job of one run (#240): a repeated job name starts
163192// a new line, so three days read as three lines rather than "ran 3 jobs on"
@@ -169,7 +198,7 @@ func TestFeedLinesSplitsRepeatedJob(t *testing.T) {
169198 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
170199 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
171200 }
172 lines := feedLines(events)
201 lines := FeedLines(events)
173202 if len(lines) != 3 {
174203 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
175204 }
@@ -194,7 +223,7 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
194223 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
195224 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
196225 }
197 lines := feedLines(events)
226 lines := FeedLines(events)
198227 if len(lines) != 2 {
199228 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
200229 }
@@ -202,3 +231,30 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
202231 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs)
203232 }
204233}
234
235func TestFeedLineSentence(t *testing.T) {
236 l := FeedLine{Actor: "cmc", Verb: "opened issue", Repo: "krz/gitbay", Ref: "#1"}
237 if got, want := l.Sentence(), "cmc opened issue krz/gitbay#1"; got != want {
238 t.Errorf("Sentence() = %q, want %q", got, want)
239 }
240
241 l.Extra = "ops, security"
242 if got, want := l.Sentence(), "cmc opened issue krz/gitbay#1 ops, security"; got != want {
243 t.Errorf("Sentence() with Extra = %q, want %q", got, want)
244 }
245
246 for _, c := range []struct {
247 l FeedLine
248 want string
249 }{
250 {FeedLine{Actor: "cmc", Verb: "released", Repo: "krz/gitbay", Ref: "v1.30.0"}, "cmc released krz/gitbay v1.30.0"},
251 {FeedLine{Actor: "cmc", Verb: "build success", Repo: "krz/gitbay", Ref: "test"}, "cmc build success krz/gitbay test"},
252 {FeedLine{Actor: "cmc", Verb: "ran 2 jobs on", Repo: "krz/gitbay", Ref: "0123456789"}, "cmc ran 2 jobs on krz/gitbay 0123456789"},
253 {FeedLine{Actor: "cmc", Verb: "merged", Repo: "krz/gitbay", Ref: "!3"}, "cmc merged krz/gitbay!3"},
254 {FeedLine{Actor: "cmc", Verb: "repository created", Repo: "krz/gitbay"}, "cmc repository created krz/gitbay"},
255 } {
256 if got := c.l.Sentence(); got != c.want {
257 t.Errorf("Sentence() = %q, want %q", got, c.want)
258 }
259 }
260}
internal/control/notifications.go +10
@@ -418,6 +418,16 @@ func runNotificationsList(c *Ctx, args []string) int {
418418 for _, n := range notices {
419419 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
420420 }
421 if !c.JSON && !p.active && len(ds) == 0 {
422 msg := "nothing to list"
423 if !all {
424 if read, err := c.Store.Inbox(c.User.ID, false, 1, 0); err == nil && len(read) > 0 {
425 msg = "no unread notifications (--all for read ones)"
426 }
427 }
428 fmt.Fprintln(c.Stderr, msg)
429 return protocol.ExitOK
430 }
421431 return c.emitPage(p, ds, next, func(w io.Writer) {
422432 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
423433 for _, d := range ds {
internal/control/notifications_test.go +43
@@ -267,3 +267,46 @@ func TestNotificationsSettingsShowsPush(t *testing.T) {
267267 t.Fatalf("no push key: %s", out.String())
268268 }
269269}
270
271func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
272 c, repo, bob := testRepoWithWatcher(t)
273 // An inbox with nothing in it at all keeps the plain message.
274 var empty bytes.Buffer
275 alice := c.User
276 c.User = store.User{ID: bob, Username: "bob"}
277 c.Stdout, c.Stderr = &bytes.Buffer{}, &empty
278 if code := runNotificationsList(c, nil); code != protocol.ExitOK {
279 t.Fatalf("exit %d: %s", code, empty.String())
280 }
281 if got := empty.String(); got != "nothing to list\n" {
282 t.Errorf("empty inbox stderr = %q", got)
283 }
284 c.User = alice
285 // Give bob one notice (acting as alice, so bob isn't filtered out as
286 // the actor), then mark it read as bob, so his inbox has rows but no
287 // unread ones.
288 notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"})
289 c.User = store.User{ID: bob, Username: "bob"}
290 if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK {
291 t.Fatalf("mark read: exit %d", code)
292 }
293 var out, errOut bytes.Buffer
294 c.Stdout, c.Stderr = &out, &errOut
295 if code := runNotificationsList(c, nil); code != protocol.ExitOK {
296 t.Fatalf("exit %d: %s", code, errOut.String())
297 }
298 if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" {
299 t.Errorf("stderr = %q", got)
300 }
301 // --all sees it and stays the generic message when that too is empty.
302 out.Reset()
303 errOut.Reset()
304 if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK {
305 t.Fatalf("exit %d: %s", code, errOut.String())
306 }
307 // The inbox row's summary is the notice's action ("a"), not its mail
308 // subject ("s"); check the path instead, which is unique to this row.
309 if !strings.Contains(out.String(), "x") {
310 t.Errorf("--all did not show the read notice: %q", out.String())
311 }
312}
internal/httpd/anchors_test.go +2 −1
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/control"
1011 "gitbay.org/gitbay/internal/store"
1112 "gitbay.org/gitbay/internal/web"
1213)
@@ -58,7 +59,7 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
5859 Assigned []store.DashboardItem
5960 MRs []store.DashboardItem
6061 Issues []store.DashboardItem
61 Feed []feedLine
62 Feed []control.FeedLine
6263 }{base, "dashboard", nil, nil, nil, nil, nil, nil})
6364 if err != nil {
6465 t.Fatal(err)
internal/httpd/builds.go +1 −28
@@ -147,10 +147,6 @@ type buildRun struct {
147147 Builds []control.BuildOut
148148}
149149
150// runStatusPriority orders combinedStatus's worst-first check: a run reads
151// as its least finished or least successful build.
152var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
153
154150// combinedStatus is the run's status: the worst of its builds' statuses,
155151// success only when every one of them is.
156152func combinedStatus(builds []control.BuildOut) string {
@@ -158,30 +154,7 @@ func combinedStatus(builds []control.BuildOut) string {
158154 for i, b := range builds {
159155 statuses[i] = b.Status
160156 }
161 return worstStatus(statuses)
162}
163
164// worstStatus is combinedStatus's ordering rule, factored out so the
165// dashboard feed can apply the same worst-first precedence to a folded
166// build run (D04). A status outside runStatusPriority (a future state
167// such as "skipped") is still not "success": it is returned unchanged
168// rather than falling through and reading as green.
169func worstStatus(statuses []string) string {
170 has := map[string]bool{}
171 for _, s := range statuses {
172 has[s] = true
173 }
174 for _, s := range runStatusPriority {
175 if has[s] {
176 return s
177 }
178 }
179 for _, s := range statuses {
180 if s != "success" {
181 return s
182 }
183 }
184 return "success"
157 return control.WorstStatus(statuses)
185158}
186159
187160// groupRuns folds consecutive builds of the same commit and the same
internal/httpd/dashpins_test.go +2 −1
@@ -4,6 +4,7 @@ import (
44 "strings"
55 "testing"
66
7 "gitbay.org/gitbay/internal/control"
78 "gitbay.org/gitbay/internal/store"
89 "gitbay.org/gitbay/internal/web"
910)
@@ -23,7 +24,7 @@ func TestDashboardTilesAndPins(t *testing.T) {
2324 Assigned []store.DashboardItem
2425 MRs []store.DashboardItem
2526 Issues []store.DashboardItem
26 Feed []feedLine
27 Feed []control.FeedLine
2728 }{base, "dashboard", []pinnedRow{
2829 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
2930 {Owner: "krz", Name: "old", Build: "cancelled"},
internal/httpd/ownerpage_test.go +2 −2
@@ -55,7 +55,7 @@ func TestOwnerPageTabOrder(t *testing.T) {
5555// it — not the whole history, which is what the atom feed is for.
5656func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) {
5757 d := ownerFixture()
58 d.Log = []feedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
58 d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
5959 out := renderOwner(t, "about", d)
6060 for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} {
6161 if !strings.Contains(out, want) {
@@ -149,7 +149,7 @@ type ownerFixtureData struct {
149149 Self bool
150150 Snippets int
151151 Members []control.ProfileMember
152 Log []feedLine
152 Log []control.FeedLine
153153 Bookmarks []control.BookmarkOut
154154 SnippetRows []snippetRow
155155}
internal/httpd/web.go +5 −5
@@ -216,8 +216,8 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
216216 Assigned []store.DashboardItem
217217 MRs []store.DashboardItem
218218 Issues []store.DashboardItem
219 Feed []feedLine
220 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
219 Feed []control.FeedLine
220 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, control.FeedLines(events)})
221221}
222222
223223func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -467,7 +467,7 @@ const profileEvents = 30
467467// ActivityByDay and OrgActivityByDay respectively — a log that counted
468468// something else would contradict the total printed over it. Only the
469469// About tab renders it, so no other tab pays for the query.
470func (s *Server) ownerFeed(tab, kind, name string) []feedLine {
470func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine {
471471 if tab != "about" {
472472 return nil
473473 }
@@ -490,7 +490,7 @@ func (s *Server) ownerFeed(tab, kind, name string) []feedLine {
490490 if err != nil {
491491 return nil
492492 }
493 return feedLines(events)
493 return control.FeedLines(events)
494494}
495495
496496// ownerPage is what owner.html renders against. It is a named type
@@ -508,7 +508,7 @@ type ownerPage struct {
508508 Orgs []control.ProfileMember
509509 Activity []activityWeek
510510 ActivityTotal int
511 Log []feedLine
511 Log []control.FeedLine
512512 Bookmarks []control.BookmarkOut
513513 SnippetRows []snippetRow
514514 SnippetsAll bool
internal/store/dashboard.go +2
@@ -76,6 +76,8 @@ const dashboardIssuesQuery = `
7676 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
7777 JOIN users au ON au.id = x.author_id
7878 WHERE x.state = 'open' AND ` + involvedCond + `
79 AND NOT EXISTS (SELECT 1 FROM issue_assignees ia
80 WHERE ia.issue_id = x.id AND ia.user_id = ?1)
7981 ORDER BY x.updated_at DESC LIMIT 50`
8082
8183func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) {
internal/store/dashboard_test.go added +55
@@ -0,0 +1,55 @@
1package store
2
3import "testing"
4
5// An issue assigned to the user is not repeated under DashboardIssues:
6// AssignedIssues already covers it, and a repository the user can
7// otherwise reach (here, one they own) is the common case where the two
8// queries used to overlap (#265).
9func TestDashboardIssuesExcludesAssignedIssues(t *testing.T) {
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("cmc", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "gitbay", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 repo, err := s.RepoByID(repoID)
23 if err != nil {
24 t.Fatal(err)
25 }
26 assignedNum, err := s.CreateIssue(repo.ID, uid, "assigned to me", "", "markdown")
27 if err != nil {
28 t.Fatal(err)
29 }
30 if _, err := s.CreateIssue(repo.ID, uid, "not assigned", "", "markdown"); err != nil {
31 t.Fatal(err)
32 }
33 assigned, err := s.IssueByNumber(repo.ID, assignedNum)
34 if err != nil {
35 t.Fatal(err)
36 }
37 if err := s.SetIssueAssignee(assigned.ID, uid, true); err != nil {
38 t.Fatal(err)
39 }
40
41 issues, err := s.DashboardIssues(uid)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if len(issues) != 1 || issues[0].Title != "not assigned" {
46 t.Fatalf("DashboardIssues = %+v, want only the unassigned issue", issues)
47 }
48 assignedList, err := s.AssignedIssues(uid)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if len(assignedList) != 1 || assignedList[0].Title != "assigned to me" {
53 t.Fatalf("AssignedIssues = %+v, want the assigned issue", assignedList)
54 }
55}
internal/web/templates/dashboard.html +1 −1
@@ -46,7 +46,7 @@
4646
4747<aside class="feedcol" aria-label="Recent activity">
4848 <h2 class="colhead">Recent activity</h2>
49 {{range .Feed}}<p class="feedline"><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>
49 {{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
5050 {{else}}<p class="none">No activity yet</p>{{end}}
5151</aside>
5252
internal/web/templates/owner.html +1 −1
@@ -36,7 +36,7 @@
3636 that goes back. */}}
3737{{if .Log}}<h3 class="logsub">recent</h3>
3838<div class="ownerlog">
39{{range .Log}}<p class="feedline"><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>
39{{range .Log}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
4040{{end}}
4141</div>
4242<p class="meta"><a href="{{.Feed}}">activity feed</a></p>