A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 160fc0ec2b

160fc0ec2bbbb4367ce5db1814c1a7404b656048

parent: 12c92d4c91

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-27T06:09:51Z

control: cursor pagination for list commands, feed command

issue list, mr list, and repo list take --limit (1-200) and --cursor.
Cursors are opaque keyset positions (base64url of kind:key), kind-checked
so a cursor minted by one command is refused by another. With either flag
present the JSON data becomes {items, next}; without them the bare-array
shape is unchanged, so existing clients keep working. Plain output gains
a trailing next line only when a further page exists.

Adds a read-only feed command exposing the dashboard's activity feed —
it had no control-plane read — paged the same way by event id, capped at
50 without flags.

Store list queries grow limit/keyset parameters; 0 values preserve the
old unbounded behavior for the web and export paths.

Closes #40
cmd/gitbay/main.go +5 −3
@@ -38,6 +38,8 @@ func main() {
3838 ),
3939 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
4040 passOpts{server: []string{"dashboard"}}),
41 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]",
42 passOpts{server: []string{"feed"}}),
4143 repoCmd(),
4244 issueCmd(),
4345 milestoneCmd(),
@@ -233,7 +235,7 @@ func repoCmd() *cobra.Command {
233235 return group("repo", "create and manage repositories",
234236 pass("create", "create a repository: gitbay repo create <owner/name> [--private]",
235237 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"}}),
237239 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
238240 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
239241 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 {
303305 return group("issue", "issues",
304306 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
305307 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}),
307309 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
308310 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
309311 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
@@ -349,7 +351,7 @@ func mrCmd() *cobra.Command {
349351 return group("mr", "merge requests",
350352 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
351353 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}),
353355 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
354356 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
355357 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 @@
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9)
10
11// pageOf unmarshals a paged --json response.
12type pageOf[T any] struct {
13 Data struct {
14 Items []T `json:"items"`
15 Next string `json:"next"`
16 } `json:"data"`
17}
18
19func 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.
30func 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 @@
1package control
2
3import (
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
20const maxPageLimit = 200
21
22func encodeCursor(kind, key string) string {
23 return base64.RawURLEncoding.EncodeToString([]byte(kind + ":" + key))
24}
25
26func 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.
40type 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.
48func (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.
57func (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.
64func 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.
99func 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.
110func (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 @@
1package control
2
3import "testing"
4
5func 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
22func 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 @@
11 package control
22
33 import (
4 "encoding/json"
45 "fmt"
56 "io"
7 "strconv"
68
79 "gitbay.org/gitbay/internal/gitutil"
810 "gitbay.org/gitbay/internal/policy"
911 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
1013 )
1114
1215 func init() {
1316 register(Command{Path: []string{"dashboard"},
1417 Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
1518 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})
1622 }
1723
1824 // 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 {
117123 }
118124 })
119125 }
126
127// feedDefaultLimit caps a bare `feed` call; pagination reaches further
128// back.
129const feedDefaultLimit = 50
130
131func 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() {
1919 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]",
2020 ReadsStdin: true, Run: runIssueCreate})
2121 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})
2323 register(Command{Path: []string{"issue", "show"},
2424 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
2525 register(Command{Path: []string{"issue", "edit"},
@@ -156,6 +156,10 @@ func runIssueCreate(c *Ctx, args []string) int {
156156 }
157157
158158 func runIssueList(c *Ctx, args []string) int {
159 args, p, code := parsePageFlags(c, args, "issue", true)
160 if code >= 0 {
161 return code
162 }
159163 state := "open"
160164 var path string
161165 for i := 0; i < len(args); i++ {
@@ -174,21 +178,24 @@ func runIssueList(c *Ctx, args []string) int {
174178 }
175179 }
176180 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>]")
178182 }
179183 repo, code := resolveRepo(c, path, policy.CanRead)
180184 if code >= 0 {
181185 return code
182186 }
183 issues, err := c.Store.ListIssues(repo.ID, state)
187 issues, err := c.Store.ListIssues(repo.ID, state, p.queryLimit(), p.keyInt())
184188 if err != nil {
185189 return c.fail(protocol.ExitFailure, "%v", err)
186190 }
191 issues, next := trimPage(p, issues, "issue", func(i store.Issue) string {
192 return strconv.FormatInt(i.Number, 10)
193 })
187194 var ds []issueOut
188195 for _, i := range issues {
189196 ds = append(ds, issueToOut(i, false))
190197 }
191 return c.emit(ds, func(w io.Writer) {
198 return c.emitPage(p, ds, next, func(w io.Writer) {
192199 for _, d := range ds {
193200 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
194201 }
internal/control/migrate.go +2 −2
@@ -88,7 +88,7 @@ func runAccountExport(c *Ctx, args []string) int {
8888 Settings: r.Settings,
8989 }
9090 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)
9292 for i := len(issues) - 1; i >= 0; i-- { // ascending numbers
9393 iss := issues[i]
9494 full, err := c.Store.IssueByNumber(r.ID, iss.Number)
@@ -104,7 +104,7 @@ func runAccountExport(c *Ctx, args []string) int {
104104 }
105105 br.Issues = append(br.Issues, bi)
106106 }
107 mrs, _ := c.Store.ListMRs(r.ID, "all")
107 mrs, _ := c.Store.ListMRs(r.ID, "all", 0, 0)
108108 for i := len(mrs) - 1; i >= 0; i-- {
109109 m := mrs[i]
110110 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() {
3030 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
3131 ReadsStdin: true, Run: runMRCreate})
3232 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})
3434 register(Command{Path: []string{"mr", "show"},
3535 Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
3636 register(Command{Path: []string{"mr", "diff"},
@@ -324,6 +324,10 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
324324 }
325325
326326 func runMRList(c *Ctx, args []string) int {
327 args, p, code := parsePageFlags(c, args, "mr", true)
328 if code >= 0 {
329 return code
330 }
327331 state := "open"
328332 var path string
329333 for i := 0; i < len(args); i++ {
@@ -343,21 +347,24 @@ func runMRList(c *Ctx, args []string) int {
343347 }
344348 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
345349 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>]")
347351 }
348352 repo, code := resolveRepo(c, path, policy.CanRead)
349353 if code >= 0 {
350354 return code
351355 }
352 mrs, err := c.Store.ListMRs(repo.ID, state)
356 mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt())
353357 if err != nil {
354358 return c.fail(protocol.ExitFailure, "%v", err)
355359 }
360 mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
361 return strconv.FormatInt(m.Number, 10)
362 })
356363 var ds []mrOut
357364 for _, m := range mrs {
358365 ds = append(ds, mrToOut(repo, m, false))
359366 }
360 return c.emit(ds, func(w io.Writer) {
367 return c.emitPage(p, ds, next, func(w io.Writer) {
361368 for _, d := range ds {
362369 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
363370 }
internal/control/repo.go +12 −4
@@ -27,7 +27,7 @@ func init() {
2727 register(Command{Path: []string{"repo", "create"},
2828 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
2929 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})
3131 register(Command{Path: []string{"repo", "show"},
3232 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
3333 register(Command{Path: []string{"repo", "transfer"},
@@ -196,10 +196,18 @@ func hostOf(siteURL string) string {
196196 }
197197
198198 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)
200207 if err != nil {
201208 return c.fail(protocol.ExitFailure, "%v", err)
202209 }
210 repos, next := trimPage(p, repos, "repo", store.Repo.Path)
203211 type out struct {
204212 Path string `json:"path"`
205213 Visibility string `json:"visibility"`
@@ -211,7 +219,7 @@ func runRepoList(c *Ctx, args []string) int {
211219 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
212220 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
213221 }
214 return c.emit(ds, func(w io.Writer) {
222 return c.emitPage(p, ds, next, func(w io.Writer) {
215223 for _, d := range ds {
216224 mark := ""
217225 if d.Archived {
@@ -706,7 +714,7 @@ func runRepoSearch(c *Ctx, args []string) int {
706714 if err != nil {
707715 return c.fail(protocol.ExitFailure, "%v", err)
708716 }
709 own, err := c.Store.ListReposForUser(c.User.ID)
717 own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
710718 if err != nil {
711719 return c.fail(protocol.ExitFailure, "%v", err)
712720 }
internal/httpd/web.go +3 −3
@@ -153,7 +153,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
153153 issues, _ := s.st.DashboardIssues(viewer.ID)
154154 reviews, _ := s.st.ReviewQueue(viewer.ID)
155155 assigned, _ := s.st.AssignedIssues(viewer.ID)
156 events, _ := s.st.RecentEvents(viewer.ID, 20)
156 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
157157 s.render(w, "dashboard.html", struct {
158158 basePage
159159 Pinned []store.Repo
@@ -1308,7 +1308,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
13081308 if state != "closed" && state != "all" {
13091309 state = "open"
13101310 }
1311 issues, err := s.st.ListIssues(p.Repo.ID, state)
1311 issues, err := s.st.ListIssues(p.Repo.ID, state, 0, 0)
13121312 if err != nil {
13131313 http.Error(w, "internal error", http.StatusInternalServerError)
13141314 return
@@ -1423,7 +1423,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
14231423 if !valid[state] {
14241424 state = "open"
14251425 }
1426 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1426 mrs, err := s.st.ListMRs(p.Repo.ID, state, 0, 0)
14271427 if err != nil {
14281428 http.Error(w, "internal error", http.StatusInternalServerError)
14291429 return
internal/store/dashboard.go +15 −6
@@ -211,6 +211,7 @@ func (s *Store) RecentBuilds(userID int64, limit int) ([]DashboardBuild, error)
211211
212212 // FeedEvent is one line of the dashboard's activity feed.
213213 type FeedEvent struct {
214 ID int64
214215 RepoPath string
215216 Actor string
216217 Kind string
@@ -220,17 +221,25 @@ type FeedEvent struct {
220221
221222 // RecentEvents returns activity on repositories the user can reach. Push
222223 // events are excluded: they repeat what the commit lists already show.
223func (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.
226func (s *Store) RecentEvents(userID int64, limit int, before int64) ([]FeedEvent, error) {
227 q := `
228 SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name,
226229 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at
227230 FROM events e
228231 JOIN repos r ON r.id = e.repo_id
229232 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
230233 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
231234 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...)
234243 if err != nil {
235244 return nil, err
236245 }
@@ -238,7 +247,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) {
238247 var out []FeedEvent
239248 for rows.Next() {
240249 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 {
242251 return nil, err
243252 }
244253 out = append(out, e)
internal/store/issues.go +12 −2
@@ -95,8 +95,10 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
9595 return out, rows.Err()
9696 }
9797
98// ListIssues returns issues for a repo; state is "open", "closed", or "all".
99func (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.
101func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) {
100102 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
101103 COALESCE(m.title, ''), i.created_at, i.updated_at
102104 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) {
107109 q += " AND i.state = ?"
108110 args = append(args, state)
109111 }
112 if before > 0 {
113 q += " AND i.number < ?"
114 args = append(args, before)
115 }
110116 q += " ORDER BY i.number DESC"
117 if limit > 0 {
118 q += " LIMIT ?"
119 args = append(args, limit)
120 }
111121 rows, err := s.DB.Query(q, args...)
112122 if err != nil {
113123 return nil, err
internal/store/mrs.go +12 −1
@@ -84,14 +84,25 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
8484 return m, err
8585 }
8686
87func (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.
90func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
8891 q := mrSelect + " WHERE m.repo_id = ?"
8992 args := []any{repoID}
9093 if state != "all" {
9194 q += " AND m.state = ?"
9295 args = append(args, state)
9396 }
97 if before > 0 {
98 q += " AND m.number < ?"
99 args = append(args, before)
100 }
94101 q += " ORDER BY m.number DESC"
102 if limit > 0 {
103 q += " LIMIT ?"
104 args = append(args, limit)
105 }
95106 rows, err := s.DB.Query(q, args...)
96107 if err != nil {
97108 return nil, err
internal/store/repos.go +21 −6
@@ -118,20 +118,35 @@ func (s *Store) DeleteRepo(repoID int64) error {
118118
119119 // ListReposForUser returns repos the user owns, reaches through an org
120120 // (unless the org scopes members to 'none'), has an explicit grant on, or
121// reaches through a team.
122func (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.
124func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
125 q := repoSelect + `
124126 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
125127 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
126128 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 = ?)
128130 OR a.subject_id IS NOT NULL
129131 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
130132 OR EXISTS (SELECT 1 FROM team_repos tr
131133 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 += `
133143 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...)
135150 if err != nil {
136151 return nil, err
137152 }