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 | 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 | ||
| 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 | // 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 @@ | |||
| 1 | package httpd | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. |
| 14 | type feedLine struct { | 14 | type 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. | ||
| 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 | // 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. |
| 39 | func feedLines(events []store.FeedEvent) []feedLine { | 57 | func 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. | ||
| 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 | // 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 @@ | |||
| 1 | package httpd | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 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 | // 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 | |||
| 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 | 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 | |||
| 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 | "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. | ||
| 152 | var 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. |
| 156 | func combinedStatus(builds []control.BuildOut) string { | 152 | func 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. | ||
| 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" | ||
| 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. |
| 56 | func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) { | 56 | func 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 | ||
| 223 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 223 | func (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. |
| 470 | func (s *Server) ownerFeed(tab, kind, name string) []feedLine { | 470 | func (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 | ||
| 81 | func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) { | 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 | <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> |