Commit 160fc0ec2b
Verified · cmc ci/build: success
cmd/gitbay/main.go +5 −3
| @@ -38,6 +38,8 @@ func main() { | ||
| 38 | 38 | ), |
| 39 | 39 | pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", |
| 40 | 40 | passOpts{server: []string{"dashboard"}}), |
| 41 | pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]", | |
| 42 | passOpts{server: []string{"feed"}}), | |
| 41 | 43 | repoCmd(), |
| 42 | 44 | issueCmd(), |
| 43 | 45 | milestoneCmd(), |
| @@ -233,7 +235,7 @@ func repoCmd() *cobra.Command { | ||
| 233 | 235 | return group("repo", "create and manage repositories", |
| 234 | 236 | pass("create", "create a repository: gitbay repo create <owner/name> [--private]", |
| 235 | 237 | passOpts{server: []string{"repo", "create"}}), |
| 236 | pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}), | |
| 238 | pass("list", "list repositories you own or can access [--limit n] [--cursor c]", passOpts{server: []string{"repo", "list"}}), | |
| 237 | 239 | pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), |
| 238 | 240 | pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), |
| 239 | 241 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), |
| @@ -303,7 +305,7 @@ func issueCmd() *cobra.Command { | ||
| 303 | 305 | return group("issue", "issues", |
| 304 | 306 | pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", |
| 305 | 307 | passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), |
| 306 | pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), | |
| 308 | pass("list", "list issues [--state open|closed|all] [--limit n] [--cursor c]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), | |
| 307 | 309 | pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), |
| 308 | 310 | pass("comment", "comment on an issue [--message|--file -|$EDITOR]", |
| 309 | 311 | passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| @@ -349,7 +351,7 @@ func mrCmd() *cobra.Command { | ||
| 349 | 351 | return group("mr", "merge requests", |
| 350 | 352 | pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", |
| 351 | 353 | passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), |
| 352 | pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), | |
| 354 | pass("list", "list merge requests [--state ...] [--limit n] [--cursor c]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), | |
| 353 | 355 | pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 354 | 356 | pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 355 | 357 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), |
e2e/pagination_test.go added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "fmt" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | // pageOf unmarshals a paged --json response. | |
| 12 | type pageOf[T any] struct { | |
| 13 | Data struct { | |
| 14 | Items []T `json:"items"` | |
| 15 | Next string `json:"next"` | |
| 16 | } `json:"data"` | |
| 17 | } | |
| 18 | ||
| 19 | func decodePage[T any](t *testing.T, out string) ([]T, string) { | |
| 20 | t.Helper() | |
| 21 | var p pageOf[T] | |
| 22 | if err := json.Unmarshal([]byte(out), &p); err != nil { | |
| 23 | t.Fatalf("bad json: %v\n%s", err, out) | |
| 24 | } | |
| 25 | return p.Data.Items, p.Data.Next | |
| 26 | } | |
| 27 | ||
| 28 | // Cursor pagination on the list commands: opaque cursors, stable pages, | |
| 29 | // and the bare-array shape untouched when the flags are absent. | |
| 30 | func TestCursorPagination(t *testing.T) { | |
| 31 | inst := startInstance(t) | |
| 32 | aliceKey := inst.newKey(t, "alice") | |
| 33 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 34 | ||
| 35 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 36 | t.Fatalf("repo create: %s", errOut) | |
| 37 | } | |
| 38 | for _, name := range []string{"'one'", "'two'", "'three'"} { | |
| 39 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", name); code != 0 { | |
| 40 | t.Fatal("issue create failed") | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | // Bare list: still a plain array. | |
| 45 | out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--json") | |
| 46 | if code != 0 { | |
| 47 | t.Fatal("issue list failed") | |
| 48 | } | |
| 49 | var bare struct { | |
| 50 | Data []struct { | |
| 51 | Number int64 `json:"number"` | |
| 52 | } `json:"data"` | |
| 53 | } | |
| 54 | if err := json.Unmarshal([]byte(out), &bare); err != nil || len(bare.Data) != 3 { | |
| 55 | t.Fatalf("bare issue list: %v\n%s", err, out) | |
| 56 | } | |
| 57 | ||
| 58 | type numbered struct { | |
| 59 | Number int64 `json:"number"` | |
| 60 | } | |
| 61 | // Page 1: newest two, with a cursor onward. | |
| 62 | out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--json") | |
| 63 | if code != 0 { | |
| 64 | t.Fatal("paged issue list failed") | |
| 65 | } | |
| 66 | items, next := decodePage[numbered](t, out) | |
| 67 | if len(items) != 2 || items[0].Number != 3 || items[1].Number != 2 || next == "" { | |
| 68 | t.Fatalf("page 1: %+v next=%q", items, next) | |
| 69 | } | |
| 70 | // Page 2: the remainder, no cursor. | |
| 71 | out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--cursor", next, "--json") | |
| 72 | if code != 0 { | |
| 73 | t.Fatal("cursor follow failed") | |
| 74 | } | |
| 75 | items, next = decodePage[numbered](t, out) | |
| 76 | if len(items) != 1 || items[0].Number != 1 || next != "" { | |
| 77 | t.Fatalf("page 2: %+v next=%q", items, next) | |
| 78 | } | |
| 79 | ||
| 80 | // A cursor from one command is refused by another. | |
| 81 | out, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "2", "--json") | |
| 82 | items, next = decodePage[numbered](t, out) | |
| 83 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--cursor", next); code != 2 { | |
| 84 | t.Fatal("foreign cursor accepted") | |
| 85 | } | |
| 86 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--cursor", "garbage!"); code != 2 { | |
| 87 | t.Fatal("garbage cursor accepted") | |
| 88 | } | |
| 89 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--limit", "0"); code != 2 { | |
| 90 | t.Fatal("limit 0 accepted") | |
| 91 | } | |
| 92 | ||
| 93 | // MR pagination pages by number the same way. | |
| 94 | work := t.TempDir() | |
| 95 | env := inst.gitEnv(aliceKey) | |
| 96 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 97 | dir := filepath.Join(work, "w") | |
| 98 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 99 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 100 | mustGit(t, dir, env, "add", ".") | |
| 101 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 102 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 103 | for i := 1; i <= 2; i++ { | |
| 104 | branch := fmt.Sprintf("feat%d", i) | |
| 105 | mustGit(t, dir, env, "checkout", "-q", "-b", branch, "main") | |
| 106 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte(fmt.Sprintf("a\n%d\n", i)), 0o644) | |
| 107 | mustGit(t, dir, env, "add", ".") | |
| 108 | mustGit(t, dir, env, "commit", "-q", "-m", branch) | |
| 109 | mustGit(t, dir, env, "push", "-q", "origin", branch) | |
| 110 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | |
| 111 | "--source", branch, "--target", "main", "--title", branch); code != 0 { | |
| 112 | t.Fatal("mr create failed") | |
| 113 | } | |
| 114 | } | |
| 115 | out, _, code = inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--limit", "1", "--json") | |
| 116 | if code != 0 { | |
| 117 | t.Fatal("paged mr list failed") | |
| 118 | } | |
| 119 | items, next = decodePage[numbered](t, out) | |
| 120 | if len(items) != 1 || items[0].Number != 2 || next == "" { | |
| 121 | t.Fatalf("mr page 1: %+v next=%q", items, next) | |
| 122 | } | |
| 123 | out, _, code = inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--limit", "1", "--cursor", next, "--json") | |
| 124 | items, next = decodePage[numbered](t, out) | |
| 125 | if len(items) != 1 || items[0].Number != 1 || next != "" { | |
| 126 | t.Fatalf("mr page 2: %+v next=%q", items, next) | |
| 127 | } | |
| 128 | ||
| 129 | // Repo pagination pages by path. | |
| 130 | for _, name := range []string{"alice/butter", "alice/cheese"} { | |
| 131 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", name); code != 0 { | |
| 132 | t.Fatal("repo create failed") | |
| 133 | } | |
| 134 | } | |
| 135 | type pathed struct { | |
| 136 | Path string `json:"path"` | |
| 137 | } | |
| 138 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "list", "--limit", "2", "--json") | |
| 139 | if code != 0 { | |
| 140 | t.Fatal("paged repo list failed") | |
| 141 | } | |
| 142 | rItems, next := decodePage[pathed](t, out) | |
| 143 | if len(rItems) != 2 || rItems[0].Path != "alice/app" || rItems[1].Path != "alice/butter" || next == "" { | |
| 144 | t.Fatalf("repo page 1: %+v next=%q", rItems, next) | |
| 145 | } | |
| 146 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "list", "--limit", "2", "--cursor", next, "--json") | |
| 147 | rItems, next = decodePage[pathed](t, out) | |
| 148 | if len(rItems) != 1 || rItems[0].Path != "alice/cheese" || next != "" { | |
| 149 | t.Fatalf("repo page 2: %+v next=%q", rItems, next) | |
| 150 | } | |
| 151 | ||
| 152 | // The feed pages by event id, newest first. | |
| 153 | type feedRow struct { | |
| 154 | Kind string `json:"kind"` | |
| 155 | Repo string `json:"repo"` | |
| 156 | } | |
| 157 | out, _, code = inst.ssh(t, aliceKey, "", "feed", "--limit", "2", "--json") | |
| 158 | if code != 0 { | |
| 159 | t.Fatal("paged feed failed") | |
| 160 | } | |
| 161 | fItems, next := decodePage[feedRow](t, out) | |
| 162 | if len(fItems) != 2 || next == "" { | |
| 163 | t.Fatalf("feed page 1: %+v next=%q", fItems, next) | |
| 164 | } | |
| 165 | if fItems[0].Kind != "mr.created" || fItems[0].Repo != "alice/app" { | |
| 166 | t.Fatalf("feed head: %+v", fItems[0]) | |
| 167 | } | |
| 168 | seen := len(fItems) | |
| 169 | for next != "" { | |
| 170 | out, _, code = inst.ssh(t, aliceKey, "", "feed", "--limit", "2", "--cursor", next, "--json") | |
| 171 | if code != 0 { | |
| 172 | t.Fatal("feed follow failed") | |
| 173 | } | |
| 174 | fItems, next = decodePage[feedRow](t, out) | |
| 175 | seen += len(fItems) | |
| 176 | if seen > 20 { | |
| 177 | t.Fatal("feed cursor loop") | |
| 178 | } | |
| 179 | } | |
| 180 | // 3 issues + 2 MRs created above. | |
| 181 | if seen != 5 { | |
| 182 | t.Fatalf("feed walked %d events", seen) | |
| 183 | } | |
| 184 | ||
| 185 | // Bare feed: plain array, most recent first. | |
| 186 | out, _, code = inst.ssh(t, aliceKey, "", "feed", "--json") | |
| 187 | if code != 0 { | |
| 188 | t.Fatal("feed failed") | |
| 189 | } | |
| 190 | var bareFeed struct { | |
| 191 | Data []feedRow `json:"data"` | |
| 192 | } | |
| 193 | if err := json.Unmarshal([]byte(out), &bareFeed); err != nil || len(bareFeed.Data) != 5 { | |
| 194 | t.Fatalf("bare feed: %v\n%s", err, out) | |
| 195 | } | |
| 196 | } | |
internal/control/cursor.go added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/base64" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "io" | |
| 8 | "reflect" | |
| 9 | "strconv" | |
| 10 | "strings" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 13 | ) | |
| 14 | ||
| 15 | // Cursor pagination. A cursor is opaque to clients: base64url of | |
| 16 | // "<kind>:<key>", where key is the sort key of the last row of the | |
| 17 | // previous page. The kind keeps a cursor minted by one command from | |
| 18 | // being fed to another. | |
| 19 | ||
| 20 | const maxPageLimit = 200 | |
| 21 | ||
| 22 | func encodeCursor(kind, key string) string { | |
| 23 | return base64.RawURLEncoding.EncodeToString([]byte(kind + ":" + key)) | |
| 24 | } | |
| 25 | ||
| 26 | func decodeCursor(kind, cursor string) (string, error) { | |
| 27 | raw, err := base64.RawURLEncoding.DecodeString(cursor) | |
| 28 | if err != nil { | |
| 29 | return "", errors.New("bad cursor") | |
| 30 | } | |
| 31 | k, key, ok := strings.Cut(string(raw), ":") | |
| 32 | if !ok || k != kind || key == "" { | |
| 33 | return "", errors.New("bad cursor") | |
| 34 | } | |
| 35 | return key, nil | |
| 36 | } | |
| 37 | ||
| 38 | // page carries parsed --limit/--cursor flags. active marks that either | |
| 39 | // flag was given: only then does the output switch to the paged shape. | |
| 40 | type page struct { | |
| 41 | limit int | |
| 42 | key string // decoded cursor key, "" means from the start | |
| 43 | active bool | |
| 44 | } | |
| 45 | ||
| 46 | // queryLimit is what the store is asked for: one row beyond the page, so | |
| 47 | // the presence of a following page is known without a second query. | |
| 48 | func (p page) queryLimit() int { | |
| 49 | if p.limit == 0 { | |
| 50 | return 0 | |
| 51 | } | |
| 52 | return p.limit + 1 | |
| 53 | } | |
| 54 | ||
| 55 | // keyInt returns the cursor key as a number; parsePageFlags has already | |
| 56 | // validated it for numeric kinds. | |
| 57 | func (p page) keyInt() int64 { | |
| 58 | n, _ := strconv.ParseInt(p.key, 10, 64) | |
| 59 | return n | |
| 60 | } | |
| 61 | ||
| 62 | // parsePageFlags strips --limit and --cursor from args. kind names the | |
| 63 | // cursor namespace; numeric declares the sort key an integer. | |
| 64 | func parsePageFlags(c *Ctx, args []string, kind string, numeric bool) (rest []string, p page, code int) { | |
| 65 | for i := 0; i < len(args); i++ { | |
| 66 | switch args[i] { | |
| 67 | case "--limit": | |
| 68 | if i+1 >= len(args) { | |
| 69 | return nil, p, c.fail(protocol.ExitUsage, "--limit requires a value") | |
| 70 | } | |
| 71 | n, err := strconv.Atoi(args[i+1]) | |
| 72 | if err != nil || n < 1 || n > maxPageLimit { | |
| 73 | return nil, p, c.fail(protocol.ExitUsage, "--limit must be 1 to %d", maxPageLimit) | |
| 74 | } | |
| 75 | p.limit, p.active = n, true | |
| 76 | i++ | |
| 77 | case "--cursor": | |
| 78 | if i+1 >= len(args) { | |
| 79 | return nil, p, c.fail(protocol.ExitUsage, "--cursor requires a value") | |
| 80 | } | |
| 81 | key, err := decodeCursor(kind, args[i+1]) | |
| 82 | if err == nil && numeric { | |
| 83 | _, err = strconv.ParseInt(key, 10, 64) | |
| 84 | } | |
| 85 | if err != nil { | |
| 86 | return nil, p, c.fail(protocol.ExitUsage, "bad cursor") | |
| 87 | } | |
| 88 | p.key, p.active = key, true | |
| 89 | i++ | |
| 90 | default: | |
| 91 | rest = append(rest, args[i]) | |
| 92 | } | |
| 93 | } | |
| 94 | return rest, p, -1 | |
| 95 | } | |
| 96 | ||
| 97 | // trimPage cuts the probe row and derives the next cursor from the last | |
| 98 | // row kept. | |
| 99 | func trimPage[T any](p page, items []T, kind string, key func(T) string) ([]T, string) { | |
| 100 | if p.limit == 0 || len(items) <= p.limit { | |
| 101 | return items, "" | |
| 102 | } | |
| 103 | items = items[:p.limit] | |
| 104 | return items, encodeCursor(kind, key(items[len(items)-1])) | |
| 105 | } | |
| 106 | ||
| 107 | // emitPage renders a list result. Without pagination flags the shape is | |
| 108 | // the bare array it has always been; with them the array moves under | |
| 109 | // "items" with the next cursor alongside. | |
| 110 | func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer)) int { | |
| 111 | if !p.active { | |
| 112 | return c.emit(items, plain) | |
| 113 | } | |
| 114 | if v := reflect.ValueOf(items); v.Kind() == reflect.Slice && v.IsNil() { | |
| 115 | items = reflect.MakeSlice(v.Type(), 0, 0).Interface() | |
| 116 | } | |
| 117 | type out struct { | |
| 118 | Items any `json:"items"` | |
| 119 | Next string `json:"next,omitempty"` | |
| 120 | } | |
| 121 | return c.emit(out{items, next}, func(w io.Writer) { | |
| 122 | plain(w) | |
| 123 | if next != "" { | |
| 124 | fmt.Fprintf(w, "next\t%s\n", next) | |
| 125 | } | |
| 126 | }) | |
| 127 | } | |
internal/control/cursor_test.go added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func TestCursorRoundTrip(t *testing.T) { | |
| 6 | cur := encodeCursor("issue", "42") | |
| 7 | key, err := decodeCursor("issue", cur) | |
| 8 | if err != nil || key != "42" { | |
| 9 | t.Fatalf("decode = %q, %v", key, err) | |
| 10 | } | |
| 11 | if _, err := decodeCursor("mr", cur); err == nil { | |
| 12 | t.Fatal("cursor accepted under the wrong kind") | |
| 13 | } | |
| 14 | if _, err := decodeCursor("issue", "not base64!"); err == nil { | |
| 15 | t.Fatal("garbage cursor accepted") | |
| 16 | } | |
| 17 | if _, err := decodeCursor("issue", encodeCursor("issue", "")); err == nil { | |
| 18 | t.Fatal("empty key accepted") | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | func TestTrimPage(t *testing.T) { | |
| 23 | key := func(n int) string { return "k" } | |
| 24 | // No probe row: page as-is, no next. | |
| 25 | items, next := trimPage(page{limit: 3}, []int{1, 2, 3}, "issue", key) | |
| 26 | if len(items) != 3 || next != "" { | |
| 27 | t.Fatalf("full page: %v next=%q", items, next) | |
| 28 | } | |
| 29 | // Probe row present: trimmed, next minted. | |
| 30 | items, next = trimPage(page{limit: 2}, []int{1, 2, 3}, "issue", key) | |
| 31 | if len(items) != 2 || next == "" { | |
| 32 | t.Fatalf("trimmed page: %v next=%q", items, next) | |
| 33 | } | |
| 34 | // Unpaged: untouched. | |
| 35 | items, next = trimPage(page{}, []int{1, 2, 3}, "issue", key) | |
| 36 | if len(items) != 3 || next != "" { | |
| 37 | t.Fatalf("unpaged: %v next=%q", items, next) | |
| 38 | } | |
| 39 | } | |
internal/control/dashboard.go +51
| @@ -1,18 +1,24 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 7 | "strconv" | |
| 6 | 8 | |
| 7 | 9 | "gitbay.org/gitbay/internal/gitutil" |
| 8 | 10 | "gitbay.org/gitbay/internal/policy" |
| 9 | 11 | "gitbay.org/gitbay/internal/protocol" |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 10 | 13 | ) |
| 11 | 14 | |
| 12 | 15 | func init() { |
| 13 | 16 | register(Command{Path: []string{"dashboard"}, |
| 14 | 17 | Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", |
| 15 | 18 | ReadOnly: true, Run: runDashboard}) |
| 19 | register(Command{Path: []string{"feed"}, | |
| 20 | Summary: "activity on repositories you can reach: feed [--limit <n>] [--cursor <c>]", | |
| 21 | ReadOnly: true, Run: runFeed}) | |
| 16 | 22 | } |
| 17 | 23 | |
| 18 | 24 | // dashboardItem is one open issue or MR row, with its repo resolved so a |
| @@ -117,3 +123,48 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 117 | 123 | } |
| 118 | 124 | }) |
| 119 | 125 | } |
| 126 | ||
| 127 | // feedDefaultLimit caps a bare `feed` call; pagination reaches further | |
| 128 | // back. | |
| 129 | const feedDefaultLimit = 50 | |
| 130 | ||
| 131 | func runFeed(c *Ctx, args []string) int { | |
| 132 | rest, p, code := parsePageFlags(c, args, "feed", true) | |
| 133 | if code >= 0 { | |
| 134 | return code | |
| 135 | } | |
| 136 | if len(rest) != 0 { | |
| 137 | return c.fail(protocol.ExitUsage, "usage: feed [--limit <n>] [--cursor <c>]") | |
| 138 | } | |
| 139 | if p.limit == 0 { | |
| 140 | p.limit = feedDefaultLimit | |
| 141 | } | |
| 142 | events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt()) | |
| 143 | if err != nil { | |
| 144 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 145 | } | |
| 146 | events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string { | |
| 147 | return strconv.FormatInt(e.ID, 10) | |
| 148 | }) | |
| 149 | type feedOut struct { | |
| 150 | ID int64 `json:"id"` | |
| 151 | Repo string `json:"repo"` | |
| 152 | Actor string `json:"actor,omitempty"` | |
| 153 | Kind string `json:"kind"` | |
| 154 | Data json.RawMessage `json:"data,omitempty"` | |
| 155 | CreatedAt string `json:"created_at"` | |
| 156 | } | |
| 157 | var ds []feedOut | |
| 158 | for _, e := range events { | |
| 159 | d := feedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt} | |
| 160 | if json.Valid([]byte(e.Data)) { | |
| 161 | d.Data = json.RawMessage(e.Data) | |
| 162 | } | |
| 163 | ds = append(ds, d) | |
| 164 | } | |
| 165 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 166 | for _, d := range ds { | |
| 167 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data)) | |
| 168 | } | |
| 169 | }) | |
| 170 | } | |
internal/control/issue.go +11 −4
| @@ -19,7 +19,7 @@ func init() { | ||
| 19 | 19 | Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", |
| 20 | 20 | ReadsStdin: true, Run: runIssueCreate}) |
| 21 | 21 | register(Command{Path: []string{"issue", "list"}, |
| 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList}) | |
| 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList}) | |
| 23 | 23 | register(Command{Path: []string{"issue", "show"}, |
| 24 | 24 | Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) |
| 25 | 25 | register(Command{Path: []string{"issue", "edit"}, |
| @@ -156,6 +156,10 @@ func runIssueCreate(c *Ctx, args []string) int { | ||
| 156 | 156 | } |
| 157 | 157 | |
| 158 | 158 | func runIssueList(c *Ctx, args []string) int { |
| 159 | args, p, code := parsePageFlags(c, args, "issue", true) | |
| 160 | if code >= 0 { | |
| 161 | return code | |
| 162 | } | |
| 159 | 163 | state := "open" |
| 160 | 164 | var path string |
| 161 | 165 | for i := 0; i < len(args); i++ { |
| @@ -174,21 +178,24 @@ func runIssueList(c *Ctx, args []string) int { | ||
| 174 | 178 | } |
| 175 | 179 | } |
| 176 | 180 | if path == "" || (state != "open" && state != "closed" && state != "all") { |
| 177 | return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all]") | |
| 181 | return c.fail(protocol.ExitUsage, "usage: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]") | |
| 178 | 182 | } |
| 179 | 183 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 180 | 184 | if code >= 0 { |
| 181 | 185 | return code |
| 182 | 186 | } |
| 183 | issues, err := c.Store.ListIssues(repo.ID, state) | |
| 187 | issues, err := c.Store.ListIssues(repo.ID, state, p.queryLimit(), p.keyInt()) | |
| 184 | 188 | if err != nil { |
| 185 | 189 | return c.fail(protocol.ExitFailure, "%v", err) |
| 186 | 190 | } |
| 191 | issues, next := trimPage(p, issues, "issue", func(i store.Issue) string { | |
| 192 | return strconv.FormatInt(i.Number, 10) | |
| 193 | }) | |
| 187 | 194 | var ds []issueOut |
| 188 | 195 | for _, i := range issues { |
| 189 | 196 | ds = append(ds, issueToOut(i, false)) |
| 190 | 197 | } |
| 191 | return c.emit(ds, func(w io.Writer) { | |
| 198 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 192 | 199 | for _, d := range ds { |
| 193 | 200 | fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author) |
| 194 | 201 | } |
internal/control/migrate.go +2 −2
| @@ -88,7 +88,7 @@ func runAccountExport(c *Ctx, args []string) int { | ||
| 88 | 88 | Settings: r.Settings, |
| 89 | 89 | } |
| 90 | 90 | br.Topics, _ = c.Store.ListTopics(r.ID) |
| 91 | issues, _ := c.Store.ListIssues(r.ID, "all") | |
| 91 | issues, _ := c.Store.ListIssues(r.ID, "all", 0, 0) | |
| 92 | 92 | for i := len(issues) - 1; i >= 0; i-- { // ascending numbers |
| 93 | 93 | iss := issues[i] |
| 94 | 94 | full, err := c.Store.IssueByNumber(r.ID, iss.Number) |
| @@ -104,7 +104,7 @@ func runAccountExport(c *Ctx, args []string) int { | ||
| 104 | 104 | } |
| 105 | 105 | br.Issues = append(br.Issues, bi) |
| 106 | 106 | } |
| 107 | mrs, _ := c.Store.ListMRs(r.ID, "all") | |
| 107 | mrs, _ := c.Store.ListMRs(r.ID, "all", 0, 0) | |
| 108 | 108 | for i := len(mrs) - 1; i >= 0; i-- { |
| 109 | 109 | m := mrs[i] |
| 110 | 110 | bm := bundleMR{Number: m.Number, Title: m.Title, Body: m.Body, State: m.State, |
internal/control/mr.go +11 −4
| @@ -30,7 +30,7 @@ func init() { | ||
| 30 | 30 | Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", |
| 31 | 31 | ReadsStdin: true, Run: runMRCreate}) |
| 32 | 32 | register(Command{Path: []string{"mr", "list"}, |
| 33 | Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList}) | |
| 33 | Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList}) | |
| 34 | 34 | register(Command{Path: []string{"mr", "show"}, |
| 35 | 35 | Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| 36 | 36 | register(Command{Path: []string{"mr", "diff"}, |
| @@ -324,6 +324,10 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | ||
| 324 | 324 | } |
| 325 | 325 | |
| 326 | 326 | func runMRList(c *Ctx, args []string) int { |
| 327 | args, p, code := parsePageFlags(c, args, "mr", true) | |
| 328 | if code >= 0 { | |
| 329 | return code | |
| 330 | } | |
| 327 | 331 | state := "open" |
| 328 | 332 | var path string |
| 329 | 333 | for i := 0; i < len(args); i++ { |
| @@ -343,21 +347,24 @@ func runMRList(c *Ctx, args []string) int { | ||
| 343 | 347 | } |
| 344 | 348 | valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} |
| 345 | 349 | if path == "" || !valid[state] { |
| 346 | return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]") | |
| 350 | return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]") | |
| 347 | 351 | } |
| 348 | 352 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 349 | 353 | if code >= 0 { |
| 350 | 354 | return code |
| 351 | 355 | } |
| 352 | mrs, err := c.Store.ListMRs(repo.ID, state) | |
| 356 | mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt()) | |
| 353 | 357 | if err != nil { |
| 354 | 358 | return c.fail(protocol.ExitFailure, "%v", err) |
| 355 | 359 | } |
| 360 | mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string { | |
| 361 | return strconv.FormatInt(m.Number, 10) | |
| 362 | }) | |
| 356 | 363 | var ds []mrOut |
| 357 | 364 | for _, m := range mrs { |
| 358 | 365 | ds = append(ds, mrToOut(repo, m, false)) |
| 359 | 366 | } |
| 360 | return c.emit(ds, func(w io.Writer) { | |
| 367 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 361 | 368 | for _, d := range ds { |
| 362 | 369 | fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef) |
| 363 | 370 | } |
internal/control/repo.go +12 −4
| @@ -27,7 +27,7 @@ func init() { | ||
| 27 | 27 | register(Command{Path: []string{"repo", "create"}, |
| 28 | 28 | Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) |
| 29 | 29 | register(Command{Path: []string{"repo", "list"}, |
| 30 | Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList}) | |
| 30 | Summary: "list repositories you own or can access: repo list [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runRepoList}) | |
| 31 | 31 | register(Command{Path: []string{"repo", "show"}, |
| 32 | 32 | Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) |
| 33 | 33 | register(Command{Path: []string{"repo", "transfer"}, |
| @@ -196,10 +196,18 @@ func hostOf(siteURL string) string { | ||
| 196 | 196 | } |
| 197 | 197 | |
| 198 | 198 | func runRepoList(c *Ctx, args []string) int { |
| 199 | repos, err := c.Store.ListReposForUser(c.User.ID) | |
| 199 | args, p, code := parsePageFlags(c, args, "repo", false) | |
| 200 | if code >= 0 { | |
| 201 | return code | |
| 202 | } | |
| 203 | if len(args) != 0 { | |
| 204 | return c.fail(protocol.ExitUsage, "usage: repo list [--limit <n>] [--cursor <c>]") | |
| 205 | } | |
| 206 | repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key) | |
| 200 | 207 | if err != nil { |
| 201 | 208 | return c.fail(protocol.ExitFailure, "%v", err) |
| 202 | 209 | } |
| 210 | repos, next := trimPage(p, repos, "repo", store.Repo.Path) | |
| 203 | 211 | type out struct { |
| 204 | 212 | Path string `json:"path"` |
| 205 | 213 | Visibility string `json:"visibility"` |
| @@ -211,7 +219,7 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 211 | 219 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) |
| 212 | 220 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) |
| 213 | 221 | } |
| 214 | return c.emit(ds, func(w io.Writer) { | |
| 222 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 215 | 223 | for _, d := range ds { |
| 216 | 224 | mark := "" |
| 217 | 225 | if d.Archived { |
| @@ -706,7 +714,7 @@ func runRepoSearch(c *Ctx, args []string) int { | ||
| 706 | 714 | if err != nil { |
| 707 | 715 | return c.fail(protocol.ExitFailure, "%v", err) |
| 708 | 716 | } |
| 709 | own, err := c.Store.ListReposForUser(c.User.ID) | |
| 717 | own, err := c.Store.ListReposForUser(c.User.ID, 0, "") | |
| 710 | 718 | if err != nil { |
| 711 | 719 | return c.fail(protocol.ExitFailure, "%v", err) |
| 712 | 720 | } |
internal/httpd/web.go +3 −3
| @@ -153,7 +153,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 153 | 153 | issues, _ := s.st.DashboardIssues(viewer.ID) |
| 154 | 154 | reviews, _ := s.st.ReviewQueue(viewer.ID) |
| 155 | 155 | assigned, _ := s.st.AssignedIssues(viewer.ID) |
| 156 | events, _ := s.st.RecentEvents(viewer.ID, 20) | |
| 156 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) | |
| 157 | 157 | s.render(w, "dashboard.html", struct { |
| 158 | 158 | basePage |
| 159 | 159 | Pinned []store.Repo |
| @@ -1308,7 +1308,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | ||
| 1308 | 1308 | if state != "closed" && state != "all" { |
| 1309 | 1309 | state = "open" |
| 1310 | 1310 | } |
| 1311 | issues, err := s.st.ListIssues(p.Repo.ID, state) | |
| 1311 | issues, err := s.st.ListIssues(p.Repo.ID, state, 0, 0) | |
| 1312 | 1312 | if err != nil { |
| 1313 | 1313 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 1314 | 1314 | return |
| @@ -1423,7 +1423,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | ||
| 1423 | 1423 | if !valid[state] { |
| 1424 | 1424 | state = "open" |
| 1425 | 1425 | } |
| 1426 | mrs, err := s.st.ListMRs(p.Repo.ID, state) | |
| 1426 | mrs, err := s.st.ListMRs(p.Repo.ID, state, 0, 0) | |
| 1427 | 1427 | if err != nil { |
| 1428 | 1428 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 1429 | 1429 | return |
internal/store/dashboard.go +15 −6
| @@ -211,6 +211,7 @@ func (s *Store) RecentBuilds(userID int64, limit int) ([]DashboardBuild, error) | ||
| 211 | 211 | |
| 212 | 212 | // FeedEvent is one line of the dashboard's activity feed. |
| 213 | 213 | type FeedEvent struct { |
| 214 | ID int64 | |
| 214 | 215 | RepoPath string |
| 215 | 216 | Actor string |
| 216 | 217 | Kind string |
| @@ -220,17 +221,25 @@ type FeedEvent struct { | ||
| 220 | 221 | |
| 221 | 222 | // RecentEvents returns activity on repositories the user can reach. Push |
| 222 | 223 | // events are excluded: they repeat what the commit lists already show. |
| 223 | func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { | |
| 224 | rows, err := s.DB.Query(` | |
| 225 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | |
| 224 | // before (an event id) starts the page strictly below it, matching the | |
| 225 | // id-descending order; 0 starts at the newest. | |
| 226 | func (s *Store) RecentEvents(userID int64, limit int, before int64) ([]FeedEvent, error) { | |
| 227 | q := ` | |
| 228 | SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name, | |
| 226 | 229 | COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at |
| 227 | 230 | FROM events e |
| 228 | 231 | JOIN repos r ON r.id = e.repo_id |
| 229 | 232 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 230 | 233 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id |
| 231 | 234 | LEFT JOIN users ac ON ac.id = e.actor_id |
| 232 | WHERE e.kind <> 'push' AND `+reachableCond+` | |
| 233 | ORDER BY e.id DESC LIMIT ?2`, userID, limit) | |
| 235 | WHERE e.kind <> 'push' AND ` + reachableCond | |
| 236 | args := []any{userID, limit} | |
| 237 | if before > 0 { | |
| 238 | q += " AND e.id < ?3" | |
| 239 | args = append(args, before) | |
| 240 | } | |
| 241 | q += " ORDER BY e.id DESC LIMIT ?2" | |
| 242 | rows, err := s.DB.Query(q, args...) | |
| 234 | 243 | if err != nil { |
| 235 | 244 | return nil, err |
| 236 | 245 | } |
| @@ -238,7 +247,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { | ||
| 238 | 247 | var out []FeedEvent |
| 239 | 248 | for rows.Next() { |
| 240 | 249 | var e FeedEvent |
| 241 | if err := rows.Scan(&e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil { | |
| 250 | if err := rows.Scan(&e.ID, &e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil { | |
| 242 | 251 | return nil, err |
| 243 | 252 | } |
| 244 | 253 | out = append(out, e) |
internal/store/issues.go +12 −2
| @@ -95,8 +95,10 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) { | ||
| 95 | 95 | return out, rows.Err() |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | // ListIssues returns issues for a repo; state is "open", "closed", or "all". | |
| 99 | func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | |
| 98 | // ListIssues returns issues for a repo; state is "open", "closed", or | |
| 99 | // "all". limit 0 means everything; before (an issue number) starts the | |
| 100 | // page strictly below it, matching the number-descending order. | |
| 101 | func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { | |
| 100 | 102 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, |
| 101 | 103 | COALESCE(m.title, ''), i.created_at, i.updated_at |
| 102 | 104 | FROM issues i JOIN users u ON u.id = i.author_id |
| @@ -107,7 +109,15 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | ||
| 107 | 109 | q += " AND i.state = ?" |
| 108 | 110 | args = append(args, state) |
| 109 | 111 | } |
| 112 | if before > 0 { | |
| 113 | q += " AND i.number < ?" | |
| 114 | args = append(args, before) | |
| 115 | } | |
| 110 | 116 | q += " ORDER BY i.number DESC" |
| 117 | if limit > 0 { | |
| 118 | q += " LIMIT ?" | |
| 119 | args = append(args, limit) | |
| 120 | } | |
| 111 | 121 | rows, err := s.DB.Query(q, args...) |
| 112 | 122 | if err != nil { |
| 113 | 123 | return nil, err |
internal/store/mrs.go +12 −1
| @@ -84,14 +84,25 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) { | ||
| 84 | 84 | return m, err |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | func (s *Store) ListMRs(repoID int64, state string) ([]MR, error) { | |
| 87 | // ListMRs returns merge requests for a repo. limit 0 means everything; | |
| 88 | // before (an MR number) starts the page strictly below it, matching the | |
| 89 | // number-descending order. | |
| 90 | func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) { | |
| 88 | 91 | q := mrSelect + " WHERE m.repo_id = ?" |
| 89 | 92 | args := []any{repoID} |
| 90 | 93 | if state != "all" { |
| 91 | 94 | q += " AND m.state = ?" |
| 92 | 95 | args = append(args, state) |
| 93 | 96 | } |
| 97 | if before > 0 { | |
| 98 | q += " AND m.number < ?" | |
| 99 | args = append(args, before) | |
| 100 | } | |
| 94 | 101 | q += " ORDER BY m.number DESC" |
| 102 | if limit > 0 { | |
| 103 | q += " LIMIT ?" | |
| 104 | args = append(args, limit) | |
| 105 | } | |
| 95 | 106 | rows, err := s.DB.Query(q, args...) |
| 96 | 107 | if err != nil { |
| 97 | 108 | return nil, err |
internal/store/repos.go +21 −6
| @@ -118,20 +118,35 @@ func (s *Store) DeleteRepo(repoID int64) error { | ||
| 118 | 118 | |
| 119 | 119 | // ListReposForUser returns repos the user owns, reaches through an org |
| 120 | 120 | // (unless the org scopes members to 'none'), has an explicit grant on, or |
| 121 | // reaches through a team. | |
| 122 | func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { | |
| 123 | rows, err := s.DB.Query(repoSelect+` | |
| 121 | // reaches through a team. limit 0 means everything; after (an owner/name | |
| 122 | // path) starts the page strictly beyond it, matching the path-ascending | |
| 123 | // order. | |
| 124 | func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) { | |
| 125 | q := repoSelect + ` | |
| 124 | 126 | LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? |
| 125 | 127 | LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? |
| 126 | 128 | LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id |
| 127 | WHERE (r.owner_kind = 'user' AND r.owner_id = ?) | |
| 129 | WHERE ((r.owner_kind = 'user' AND r.owner_id = ?) | |
| 128 | 130 | OR a.subject_id IS NOT NULL |
| 129 | 131 | OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none')) |
| 130 | 132 | OR EXISTS (SELECT 1 FROM team_repos tr |
| 131 | 133 | JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ? |
| 132 | WHERE tr.repo_id = r.id) | |
| 134 | WHERE tr.repo_id = r.id))` | |
| 135 | args := []any{userID, userID, userID, userID} | |
| 136 | if after != "" { | |
| 137 | owner, name, _ := strings.Cut(after, "/") | |
| 138 | q += ` AND (COALESCE(u.username, o.name) > ? | |
| 139 | OR (COALESCE(u.username, o.name) = ? AND r.name > ?))` | |
| 140 | args = append(args, owner, owner, name) | |
| 141 | } | |
| 142 | q += ` | |
| 133 | 143 | GROUP BY r.id |
| 134 | ORDER BY 4, r.name`, userID, userID, userID, userID) | |
| 144 | ORDER BY 4, r.name` | |
| 145 | if limit > 0 { | |
| 146 | q += " LIMIT ?" | |
| 147 | args = append(args, limit) | |
| 148 | } | |
| 149 | rows, err := s.DB.Query(q, args...) | |
| 135 | 150 | if err != nil { |
| 136 | 151 | return nil, err |
| 137 | 152 | } |