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