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.
69 separately (#275). 69 separately (#275).
70- Audit retention deletes by id, up to the newest row older than the 70- Audit retention deletes by id, up to the newest row older than the
71 retention, so a clock step back cannot leave a gap in the chain (#275). 71 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).
72 79
73* v1.36.0 — 2026-09-23 80* v1.36.0 — 2026-09-23
74 81
e2e/dashboard_test.go +3 −2
@@ -247,8 +247,9 @@ func TestDashboardCommand(t *testing.T) {
247 d.Assigned[0].Title != "todo one" { 247 d.Assigned[0].Title != "todo one" {
248 t.Fatalf("assigned_issues = %+v", d.Assigned) 248 t.Fatalf("assigned_issues = %+v", d.Assigned)
249 } 249 }
250 if len(d.Issues) != 1 || d.Issues[0].Repo != "alice/app" || d.Issues[0].Number != 1 || 250 // The only open issue is assigned to alice, so it is listed under
251 d.Issues[0].Title != "todo one" { 251 // assigned_issues and not again under open_issues.
252 if len(d.Issues) != 0 {
252 t.Fatalf("open_issues = %+v", d.Issues) 253 t.Fatalf("open_issues = %+v", d.Issues)
253 } 254 }
254 if len(d.Activity) == 0 || d.Activity[0].Repo != "alice/app" { 255 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 {
226 } 226 }
227 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows) 227 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
228 228
229 activityRows := make([][]cell, len(d.Activity)) 229 lines := FeedLines(events)
230 for i, e := range d.Activity { 230 activityRows := make([][]cell, len(lines))
231 activityRows[i] = []cell{cAge(e.CreatedAt), cText(e.Actor), cText(e.Kind), cRef(e.Repo), cFlex(string(e.Data))} 231 for i, l := range lines {
232 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
232 } 233 }
233 section("recent activity:", []string{"WHEN", "ACTOR", "KIND", "REPO", "DATA"}, activityRows) 234 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
234 235
235 buildRows := make([][]cell, len(d.Builds)) 236 buildRows := make([][]cell, len(d.Builds))
236 for i, b := range d.Builds { 237 for i, b := range d.Builds {
@@ -343,10 +344,11 @@ func runFeed(c *Ctx, args []string) int {
343 return strconv.FormatInt(e.ID, 10) 344 return strconv.FormatInt(e.ID, 10)
344 }) 345 })
345 ds := feedOutputs(events) 346 ds := feedOutputs(events)
347 lines := FeedLines(events)
346 return c.emitPage(p, ds, next, func(w io.Writer) { 348 return c.emitPage(p, ds, next, func(w io.Writer) {
347 tb := c.table(w, "WHEN", "ACTOR", "KIND", "REPO", "DATA") 349 tb := c.table(w, "WHEN", "EVENT")
348 for _, d := range ds { 350 for _, l := range lines {
349 tb.row(cAge(d.CreatedAt), cText(d.Actor), cText(d.Kind), cRef(d.Repo), cFlex(string(d.Data))) 351 tb.row(cAge(l.When), cFlex(l.Sentence()))
350 } 352 }
351 tb.flush() 353 tb.flush()
352 }) 354 })
internal/control/dashboard_test.go +47
@@ -23,6 +23,53 @@ func TestDashboardEmptySectionsSayNone(t *testing.T) {
23 } 23 }
24} 24}
25 25
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
26// The push queue is the one worker queue whose worst failure — a key_id 73// The push queue is the one worker queue whose worst failure — a key_id
27// or team_id Apple did not issue, which config validation cannot check — 74// or team_id Apple did not issue, which config validation cannot check —
28// dead-letters every row on its first attempt with nothing but a log 75// 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
2 2
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
@@ -10,8 +10,8 @@ import (
10 "gitbay.org/gitbay/internal/store" 10 "gitbay.org/gitbay/internal/store"
11) 11)
12 12
13// feedLine is one activity entry, already phrased and linked. 13// FeedLine is one activity entry, already phrased and linked.
14type feedLine struct { 14type FeedLine struct {
15 Actor string 15 Actor string
16 Verb string // "opened issue", "merged", "ran 2 jobs on" 16 Verb string // "opened issue", "merged", "ran 2 jobs on"
17 Ref string // "#12", "!35", "v0.4.0", a short sha 17 Ref string // "#12", "!35", "v0.4.0", a short sha
@@ -21,14 +21,32 @@ type feedLine struct {
21 WhenT time.Time // parsed from When, for ago/whenT rendering 21 WhenT time.Time // parsed from When, for ago/whenT rendering
22 State string // a build run's combined status; empty for anything else 22 State string // a build run's combined status; empty for anything else
23 Jobs []string // job names folded into a build run 23 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
25} 28}
26 29
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
28// still shows: the feed says what happened even for events added later. 46// still shows: the feed says what happened even for events added later.
29// Build events on the same commit, adjacent in the input, fold into one 47// Build events on the same commit, adjacent in the input, fold into one
30// "run" line (D04): its State is the worst of the folded jobs' outcomes, 48// "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.
32// A repeated job name ends the line and starts the next, so a scheduled 50// A repeated job name ends the line and starts the next, so a scheduled
33// job firing daily on an unchanged tip reads as one line a day rather than 51// job firing daily on an unchanged tip reads as one line a day rather than
34// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by 52// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
@@ -36,15 +54,16 @@ type feedLine struct {
36// per job, so there is no queue moment here to key on. The cost is that 54// per job, so there is no queue moment here to key on. The cost is that
37// the oldest scheduled line on a commit folds in the push's jobs, which 55// the oldest scheduled line on a commit folds in the push's jobs, which
38// have not been seen yet on that line. 56// have not been seen yet on that line.
39func feedLines(events []store.FeedEvent) []feedLine { 57func FeedLines(events []store.FeedEvent) []FeedLine {
40 out := make([]feedLine, 0, len(events)) 58 out := make([]FeedLine, 0, len(events))
41 statuses := make([][]string, 0, len(events)) 59 statuses := make([][]string, 0, len(events))
42 for _, e := range events { 60 for _, e := range events {
43 var d struct { 61 var d struct {
44 Number int64 `json:"number"` 62 Number int64 `json:"number"`
45 Job string `json:"job"` 63 Job string `json:"job"`
46 Tag string `json:"tag"` 64 Tag string `json:"tag"`
47 SHA string `json:"sha"` 65 SHA string `json:"sha"`
66 Labels []string `json:"labels"`
48 } 67 }
49 json.Unmarshal([]byte(e.Data), &d) 68 json.Unmarshal([]byte(e.Data), &d)
50 kind, rest, _ := strings.Cut(e.Kind, ".") 69 kind, rest, _ := strings.Cut(e.Kind, ".")
@@ -58,12 +77,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
58 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs)) 77 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
59 out[i].Ref = fmt.Sprintf("%.10s", d.SHA) 78 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
60 out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA) 79 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])
62 continue 81 continue
63 } 82 }
64 } 83 }
65 84
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)}
67 if l.Actor == "" { 86 if l.Actor == "" {
68 l.Actor = "gitbay" 87 l.Actor = "gitbay"
69 } 88 }
@@ -72,9 +91,15 @@ func feedLines(events []store.FeedEvent) []feedLine {
72 case "issue": 91 case "issue":
73 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) 92 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
74 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number) 93 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
94 if rest == "labeled" {
95 l.Extra = strings.Join(d.Labels, ", ")
96 }
75 case "mr": 97 case "mr":
76 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number) 98 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
77 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number) 99 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
100 if rest == "labeled" {
101 l.Extra = strings.Join(d.Labels, ", ")
102 }
78 case "build": 103 case "build":
79 l.Verb, l.Ref = "build "+rest, d.Job 104 l.Verb, l.Ref = "build "+rest, d.Job
80 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) 105 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
@@ -100,6 +125,33 @@ func feedLines(events []store.FeedEvent) []feedLine {
100 return out 125 return out
101} 126}
102 127
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
103// parseEventTime parses a stored RFC3339 timestamp for ago/whenT 155// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
104// rendering; an unparseable value (or none) comes back zero rather than 156// rendering; an unparseable value (or none) comes back zero rather than
105// guessing. 157// guessing.
@@ -121,6 +173,8 @@ func issueVerb(s string) string {
121 return "reopened issue" 173 return "reopened issue"
122 case "commented": 174 case "commented":
123 return "commented on" 175 return "commented on"
176 case "labeled":
177 return "labelled"
124 } 178 }
125 return "issue " + s 179 return "issue " + s
126} 180}
@@ -135,6 +189,8 @@ func mrVerb(s string) string {
135 return "commented on" 189 return "commented on"
136 case "closed": 190 case "closed":
137 return "closed merge request" 191 return "closed merge request"
192 case "labeled":
193 return "labelled"
138 } 194 }
139 return "merge request " + s 195 return "merge request " + s
140} 196}
internal/httpd/feed_test.go → internal/control/feedline_test.go renamed +66 −10
@@ -1,4 +1,4 @@
1package httpd 1package control
2 2
3import ( 3import (
4 "fmt" 4 "fmt"
@@ -18,7 +18,7 @@ func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
18 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", 18 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
19 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, 19 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
20 } 20 }
21 lines := feedLines(events) 21 lines := FeedLines(events)
22 if len(lines) != 1 { 22 if len(lines) != 1 {
23 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines) 23 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
24 } 24 }
@@ -47,7 +47,7 @@ func TestFeedLinesSingleBuildGetsState(t *testing.T) {
47 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", 47 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
48 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, 48 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
49 } 49 }
50 lines := feedLines(events) 50 lines := FeedLines(events)
51 if len(lines) != 1 { 51 if len(lines) != 1 {
52 t.Fatalf("feedLines returned %d lines, want 1", len(lines)) 52 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
53 } 53 }
@@ -68,7 +68,7 @@ func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
68 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", 68 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
69 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`}, 69 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
70 } 70 }
71 lines := feedLines(events) 71 lines := FeedLines(events)
72 if len(lines) != 2 { 72 if len(lines) != 2 {
73 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) 73 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
74 } 74 }
@@ -82,7 +82,7 @@ func TestFeedLinesNoSHANeverFolds(t *testing.T) {
82 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`}, 82 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
83 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`}, 83 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
84 } 84 }
85 lines := feedLines(events) 85 lines := FeedLines(events)
86 if len(lines) != 2 { 86 if len(lines) != 2 {
87 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) 87 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
88 } 88 }
@@ -101,7 +101,7 @@ func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
101 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", 101 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
102 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, 102 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
103 } 103 }
104 lines := feedLines(events) 104 lines := FeedLines(events)
105 if len(lines) != 3 { 105 if len(lines) != 3 {
106 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines) 106 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
107 } 107 }
@@ -127,7 +127,7 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s, 127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)} 128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)}
129 } 129 }
130 lines := feedLines(events) 130 lines := FeedLines(events)
131 if len(lines) != 1 || lines[0].State != tc.want { 131 if len(lines) != 1 || lines[0].State != tc.want {
132 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want) 132 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
133 } 133 }
@@ -144,7 +144,7 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
144 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created", 144 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
145 Data: `{"number":2}`, CreatedAt: "not-a-time"}, 145 Data: `{"number":2}`, CreatedAt: "not-a-time"},
146 } 146 }
147 lines := feedLines(events) 147 lines := FeedLines(events)
148 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z") 148 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
149 if !lines[0].WhenT.Equal(want) { 149 if !lines[0].WhenT.Equal(want) {
150 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want) 150 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
@@ -158,6 +158,35 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
158 } 158 }
159} 159}
160 160
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
161// A scheduled job firing daily on an unchanged tip is a separate event 190// A scheduled job firing daily on an unchanged tip is a separate event
162// each tick, not another job of one run (#240): a repeated job name starts 191// each tick, not another job of one run (#240): a repeated job name starts
163// a new line, so three days read as three lines rather than "ran 3 jobs on" 192// 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) {
169 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice", 198 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
170 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`}) 199 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
171 } 200 }
172 lines := feedLines(events) 201 lines := FeedLines(events)
173 if len(lines) != 3 { 202 if len(lines) != 3 {
174 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines) 203 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
175 } 204 }
@@ -194,7 +223,7 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
194 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", 223 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
195 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`}, 224 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
196 } 225 }
197 lines := feedLines(events) 226 lines := FeedLines(events)
198 if len(lines) != 2 { 227 if len(lines) != 2 {
199 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) 228 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
200 } 229 }
@@ -202,3 +231,30 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
202 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs) 231 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs)
203 } 232 }
204} 233}
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 {
418 for _, n := range notices { 418 for _, n := range notices {
419 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt}) 419 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
420 } 420 }
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 }
421 return c.emitPage(p, ds, next, func(w io.Writer) { 431 return c.emitPage(p, ds, next, func(w io.Writer) {
422 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") 432 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
423 for _, d := range ds { 433 for _, d := range ds {
internal/control/notifications_test.go +43
@@ -267,3 +267,46 @@ func TestNotificationsSettingsShowsPush(t *testing.T) {
267 t.Fatalf("no push key: %s", out.String()) 267 t.Fatalf("no push key: %s", out.String())
268 } 268 }
269} 269}
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 (
7 "testing" 7 "testing"
8 8
9 "gitbay.org/gitbay/internal/config" 9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/store" 11 "gitbay.org/gitbay/internal/store"
11 "gitbay.org/gitbay/internal/web" 12 "gitbay.org/gitbay/internal/web"
12) 13)
@@ -58,7 +59,7 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
58 Assigned []store.DashboardItem 59 Assigned []store.DashboardItem
59 MRs []store.DashboardItem 60 MRs []store.DashboardItem
60 Issues []store.DashboardItem 61 Issues []store.DashboardItem
61 Feed []feedLine 62 Feed []control.FeedLine
62 }{base, "dashboard", nil, nil, nil, nil, nil, nil}) 63 }{base, "dashboard", nil, nil, nil, nil, nil, nil})
63 if err != nil { 64 if err != nil {
64 t.Fatal(err) 65 t.Fatal(err)
internal/httpd/builds.go +1 −28
@@ -147,10 +147,6 @@ type buildRun struct {
147 Builds []control.BuildOut 147 Builds []control.BuildOut
148} 148}
149 149
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
154// combinedStatus is the run's status: the worst of its builds' statuses, 150// combinedStatus is the run's status: the worst of its builds' statuses,
155// success only when every one of them is. 151// success only when every one of them is.
156func combinedStatus(builds []control.BuildOut) string { 152func combinedStatus(builds []control.BuildOut) string {
@@ -158,30 +154,7 @@ func combinedStatus(builds []control.BuildOut) string {
158 for i, b := range builds { 154 for i, b := range builds {
159 statuses[i] = b.Status 155 statuses[i] = b.Status
160 } 156 }
161 return worstStatus(statuses) 157 return control.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"
185} 158}
186 159
187// groupRuns folds consecutive builds of the same commit and the same 160// groupRuns folds consecutive builds of the same commit and the same
internal/httpd/dashpins_test.go +2 −1
@@ -4,6 +4,7 @@ import (
4 "strings" 4 "strings"
5 "testing" 5 "testing"
6 6
7 "gitbay.org/gitbay/internal/control"
7 "gitbay.org/gitbay/internal/store" 8 "gitbay.org/gitbay/internal/store"
8 "gitbay.org/gitbay/internal/web" 9 "gitbay.org/gitbay/internal/web"
9) 10)
@@ -23,7 +24,7 @@ func TestDashboardTilesAndPins(t *testing.T) {
23 Assigned []store.DashboardItem 24 Assigned []store.DashboardItem
24 MRs []store.DashboardItem 25 MRs []store.DashboardItem
25 Issues []store.DashboardItem 26 Issues []store.DashboardItem
26 Feed []feedLine 27 Feed []control.FeedLine
27 }{base, "dashboard", []pinnedRow{ 28 }{base, "dashboard", []pinnedRow{
28 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, 29 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
29 {Owner: "krz", Name: "old", Build: "cancelled"}, 30 {Owner: "krz", Name: "old", Build: "cancelled"},
internal/httpd/ownerpage_test.go +2 −2
@@ -55,7 +55,7 @@ func TestOwnerPageTabOrder(t *testing.T) {
55// it — not the whole history, which is what the atom feed is for. 55// it — not the whole history, which is what the atom feed is for.
56func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) { 56func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) {
57 d := ownerFixture() 57 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"}}
59 out := renderOwner(t, "about", d) 59 out := renderOwner(t, "about", d)
60 for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} { 60 for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} {
61 if !strings.Contains(out, want) { 61 if !strings.Contains(out, want) {
@@ -149,7 +149,7 @@ type ownerFixtureData struct {
149 Self bool 149 Self bool
150 Snippets int 150 Snippets int
151 Members []control.ProfileMember 151 Members []control.ProfileMember
152 Log []feedLine 152 Log []control.FeedLine
153 Bookmarks []control.BookmarkOut 153 Bookmarks []control.BookmarkOut
154 SnippetRows []snippetRow 154 SnippetRows []snippetRow
155} 155}
internal/httpd/web.go +5 −5
@@ -216,8 +216,8 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
216 Assigned []store.DashboardItem 216 Assigned []store.DashboardItem
217 MRs []store.DashboardItem 217 MRs []store.DashboardItem
218 Issues []store.DashboardItem 218 Issues []store.DashboardItem
219 Feed []feedLine 219 Feed []control.FeedLine
220 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)}) 220 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, control.FeedLines(events)})
221} 221}
222 222
223func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 223func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -467,7 +467,7 @@ const profileEvents = 30
467// ActivityByDay and OrgActivityByDay respectively — a log that counted 467// ActivityByDay and OrgActivityByDay respectively — a log that counted
468// something else would contradict the total printed over it. Only the 468// something else would contradict the total printed over it. Only the
469// About tab renders it, so no other tab pays for the query. 469// 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 {
471 if tab != "about" { 471 if tab != "about" {
472 return nil 472 return nil
473 } 473 }
@@ -490,7 +490,7 @@ func (s *Server) ownerFeed(tab, kind, name string) []feedLine {
490 if err != nil { 490 if err != nil {
491 return nil 491 return nil
492 } 492 }
493 return feedLines(events) 493 return control.FeedLines(events)
494} 494}
495 495
496// ownerPage is what owner.html renders against. It is a named type 496// ownerPage is what owner.html renders against. It is a named type
@@ -508,7 +508,7 @@ type ownerPage struct {
508 Orgs []control.ProfileMember 508 Orgs []control.ProfileMember
509 Activity []activityWeek 509 Activity []activityWeek
510 ActivityTotal int 510 ActivityTotal int
511 Log []feedLine 511 Log []control.FeedLine
512 Bookmarks []control.BookmarkOut 512 Bookmarks []control.BookmarkOut
513 SnippetRows []snippetRow 513 SnippetRows []snippetRow
514 SnippetsAll bool 514 SnippetsAll bool
internal/store/dashboard.go +2
@@ -76,6 +76,8 @@ const dashboardIssuesQuery = `
76 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id 76 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
77 JOIN users au ON au.id = x.author_id 77 JOIN users au ON au.id = x.author_id
78 WHERE x.state = 'open' AND ` + involvedCond + ` 78 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)
79 ORDER BY x.updated_at DESC LIMIT 50` 81 ORDER BY x.updated_at DESC LIMIT 50`
80 82
81func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) { 83func (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 @@
46 46
47<aside class="feedcol" aria-label="Recent activity"> 47<aside class="feedcol" aria-label="Recent activity">
48 <h2 class="colhead">Recent activity</h2> 48 <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>
50 {{else}}<p class="none">No activity yet</p>{{end}} 50 {{else}}<p class="none">No activity yet</p>{{end}}
51</aside> 51</aside>
52 52
internal/web/templates/owner.html +1 −1
@@ -36,7 +36,7 @@
36 that goes back. */}} 36 that goes back. */}}
37{{if .Log}}<h3 class="logsub">recent</h3> 37{{if .Log}}<h3 class="logsub">recent</h3>
38<div class="ownerlog"> 38<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>
40{{end}} 40{{end}}
41</div> 41</div>
42<p class="meta"><a href="{{.Feed}}">activity feed</a></p> 42<p class="meta"><a href="{{.Feed}}">activity feed</a></p>