control: cursor pagination for list commands, feed command !97

merged merged by cmc on 2026-08-27 06:15 UTC · krz/gitbay:cursor-pagination into main

14 files changed, +517 −35

Layout: unified · split

cmd/gitbay/main.go +5 −3
@@ -38,6 +38,8 @@ func main() {
38 ), 38 ),
39 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", 39 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
40 passOpts{server: []string{"dashboard"}}), 40 passOpts{server: []string{"dashboard"}}),
41 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]",
42 passOpts{server: []string{"feed"}}),
41 repoCmd(), 43 repoCmd(),
42 issueCmd(), 44 issueCmd(),
43 milestoneCmd(), 45 milestoneCmd(),
@@ -233,7 +235,7 @@ func repoCmd() *cobra.Command {
233 return group("repo", "create and manage repositories", 235 return group("repo", "create and manage repositories",
234 pass("create", "create a repository: gitbay repo create <owner/name> [--private]", 236 pass("create", "create a repository: gitbay repo create <owner/name> [--private]",
235 passOpts{server: []string{"repo", "create"}}), 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 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), 239 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
238 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), 240 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
239 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), 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 return group("issue", "issues", 305 return group("issue", "issues",
304 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", 306 pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]",
305 passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), 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 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), 309 pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
308 pass("comment", "comment on an issue [--message|--file -|$EDITOR]", 310 pass("comment", "comment on an issue [--message|--file -|$EDITOR]",
309 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), 311 passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
@@ -349,7 +351,7 @@ func mrCmd() *cobra.Command {
349 return group("mr", "merge requests", 351 return group("mr", "merge requests",
350 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", 352 pass("create", "open a merge request: --source <branch> --target <branch> --title <t>",
351 passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), 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 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), 355 pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
354 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), 356 pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
355 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), 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 @@
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 @@
1package control 1package control
2 2
3import ( 3import (
4 "encoding/json"
4 "fmt" 5 "fmt"
5 "io" 6 "io"
7 "strconv"
6 8
7 "gitbay.org/gitbay/internal/gitutil" 9 "gitbay.org/gitbay/internal/gitutil"
8 "gitbay.org/gitbay/internal/policy" 10 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol" 11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
10) 13)
11 14
12func init() { 15func init() {
13 register(Command{Path: []string{"dashboard"}, 16 register(Command{Path: []string{"dashboard"},
14 Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", 17 Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
15 ReadOnly: true, Run: runDashboard}) 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// dashboardItem is one open issue or MR row, with its repo resolved so a 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.
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() {
19 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", 19 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]",
20 ReadsStdin: true, Run: runIssueCreate}) 20 ReadsStdin: true, Run: runIssueCreate})
21 register(Command{Path: []string{"issue", "list"}, 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 register(Command{Path: []string{"issue", "show"}, 23 register(Command{Path: []string{"issue", "show"},
24 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) 24 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
25 register(Command{Path: []string{"issue", "edit"}, 25 register(Command{Path: []string{"issue", "edit"},
@@ -156,6 +156,10 @@ func runIssueCreate(c *Ctx, args []string) int {
156} 156}
157 157
158func runIssueList(c *Ctx, args []string) int { 158func runIssueList(c *Ctx, args []string) int {
159 args, p, code := parsePageFlags(c, args, "issue", true)
160 if code >= 0 {
161 return code
162 }
159 state := "open" 163 state := "open"
160 var path string 164 var path string
161 for i := 0; i < len(args); i++ { 165 for i := 0; i < len(args); i++ {
@@ -174,21 +178,24 @@ func runIssueList(c *Ctx, args []string) int {
174 } 178 }
175 } 179 }
176 if path == "" || (state != "open" && state != "closed" && state != "all") { 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 repo, code := resolveRepo(c, path, policy.CanRead) 183 repo, code := resolveRepo(c, path, policy.CanRead)
180 if code >= 0 { 184 if code >= 0 {
181 return code 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 if err != nil { 188 if err != nil {
185 return c.fail(protocol.ExitFailure, "%v", err) 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 var ds []issueOut 194 var ds []issueOut
188 for _, i := range issues { 195 for _, i := range issues {
189 ds = append(ds, issueToOut(i, false)) 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 for _, d := range ds { 199 for _, d := range ds {
193 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author) 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 Settings: r.Settings, 88 Settings: r.Settings,
89 } 89 }
90 br.Topics, _ = c.Store.ListTopics(r.ID) 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 for i := len(issues) - 1; i >= 0; i-- { // ascending numbers 92 for i := len(issues) - 1; i >= 0; i-- { // ascending numbers
93 iss := issues[i] 93 iss := issues[i]
94 full, err := c.Store.IssueByNumber(r.ID, iss.Number) 94 full, err := c.Store.IssueByNumber(r.ID, iss.Number)
@@ -104,7 +104,7 @@ func runAccountExport(c *Ctx, args []string) int {
104 } 104 }
105 br.Issues = append(br.Issues, bi) 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 for i := len(mrs) - 1; i >= 0; i-- { 108 for i := len(mrs) - 1; i >= 0; i-- {
109 m := mrs[i] 109 m := mrs[i]
110 bm := bundleMR{Number: m.Number, Title: m.Title, Body: m.Body, State: m.State, 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 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", 30 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
31 ReadsStdin: true, Run: runMRCreate}) 31 ReadsStdin: true, Run: runMRCreate})
32 register(Command{Path: []string{"mr", "list"}, 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 register(Command{Path: []string{"mr", "show"}, 34 register(Command{Path: []string{"mr", "show"},
35 Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) 35 Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
36 register(Command{Path: []string{"mr", "diff"}, 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
326func runMRList(c *Ctx, args []string) int { 326func runMRList(c *Ctx, args []string) int {
327 args, p, code := parsePageFlags(c, args, "mr", true)
328 if code >= 0 {
329 return code
330 }
327 state := "open" 331 state := "open"
328 var path string 332 var path string
329 for i := 0; i < len(args); i++ { 333 for i := 0; i < len(args); i++ {
@@ -343,21 +347,24 @@ func runMRList(c *Ctx, args []string) int {
343 } 347 }
344 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} 348 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
345 if path == "" || !valid[state] { 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 repo, code := resolveRepo(c, path, policy.CanRead) 352 repo, code := resolveRepo(c, path, policy.CanRead)
349 if code >= 0 { 353 if code >= 0 {
350 return code 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 if err != nil { 357 if err != nil {
354 return c.fail(protocol.ExitFailure, "%v", err) 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 var ds []mrOut 363 var ds []mrOut
357 for _, m := range mrs { 364 for _, m := range mrs {
358 ds = append(ds, mrToOut(repo, m, false)) 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 for _, d := range ds { 368 for _, d := range ds {
362 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef) 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 register(Command{Path: []string{"repo", "create"}, 27 register(Command{Path: []string{"repo", "create"},
28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) 28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
29 register(Command{Path: []string{"repo", "list"}, 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 register(Command{Path: []string{"repo", "show"}, 31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) 32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
33 register(Command{Path: []string{"repo", "transfer"}, 33 register(Command{Path: []string{"repo", "transfer"},
@@ -196,10 +196,18 @@ func hostOf(siteURL string) string {
196} 196}
197 197
198func runRepoList(c *Ctx, args []string) int { 198func 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 if err != nil { 207 if err != nil {
201 return c.fail(protocol.ExitFailure, "%v", err) 208 return c.fail(protocol.ExitFailure, "%v", err)
202 } 209 }
210 repos, next := trimPage(p, repos, "repo", store.Repo.Path)
203 type out struct { 211 type out struct {
204 Path string `json:"path"` 212 Path string `json:"path"`
205 Visibility string `json:"visibility"` 213 Visibility string `json:"visibility"`
@@ -211,7 +219,7 @@ func runRepoList(c *Ctx, args []string) int {
211 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) 219 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
212 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) 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 for _, d := range ds { 223 for _, d := range ds {
216 mark := "" 224 mark := ""
217 if d.Archived { 225 if d.Archived {
@@ -706,7 +714,7 @@ func runRepoSearch(c *Ctx, args []string) int {
706 if err != nil { 714 if err != nil {
707 return c.fail(protocol.ExitFailure, "%v", err) 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 if err != nil { 718 if err != nil {
711 return c.fail(protocol.ExitFailure, "%v", err) 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 issues, _ := s.st.DashboardIssues(viewer.ID) 153 issues, _ := s.st.DashboardIssues(viewer.ID)
154 reviews, _ := s.st.ReviewQueue(viewer.ID) 154 reviews, _ := s.st.ReviewQueue(viewer.ID)
155 assigned, _ := s.st.AssignedIssues(viewer.ID) 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 s.render(w, "dashboard.html", struct { 157 s.render(w, "dashboard.html", struct {
158 basePage 158 basePage
159 Pinned []store.Repo 159 Pinned []store.Repo
@@ -1308,7 +1308,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1308 if state != "closed" && state != "all" { 1308 if state != "closed" && state != "all" {
1309 state = "open" 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 if err != nil { 1312 if err != nil {
1313 http.Error(w, "internal error", http.StatusInternalServerError) 1313 http.Error(w, "internal error", http.StatusInternalServerError)
1314 return 1314 return
@@ -1423,7 +1423,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1423 if !valid[state] { 1423 if !valid[state] {
1424 state = "open" 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 if err != nil { 1427 if err != nil {
1428 http.Error(w, "internal error", http.StatusInternalServerError) 1428 http.Error(w, "internal error", http.StatusInternalServerError)
1429 return 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// FeedEvent is one line of the dashboard's activity feed. 212// FeedEvent is one line of the dashboard's activity feed.
213type FeedEvent struct { 213type FeedEvent struct {
214 ID int64
214 RepoPath string 215 RepoPath string
215 Actor string 216 Actor string
216 Kind string 217 Kind string
@@ -220,17 +221,25 @@ type FeedEvent struct {
220 221
221// RecentEvents returns activity on repositories the user can reach. Push 222// RecentEvents returns activity on repositories the user can reach. Push
222// events are excluded: they repeat what the commit lists already show. 223// events are excluded: they repeat what the commit lists already show.
223func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { 224// before (an event id) starts the page strictly below it, matching the
224 rows, err := s.DB.Query(` 225// id-descending order; 0 starts at the newest.
225 SELECT COALESCE(u.username, o.name) || '/' || r.name, 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,
226 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at 229 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at
227 FROM events e 230 FROM events e
228 JOIN repos r ON r.id = e.repo_id 231 JOIN repos r ON r.id = e.repo_id
229 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id 232 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
230 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id 233 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
231 LEFT JOIN users ac ON ac.id = e.actor_id 234 LEFT JOIN users ac ON ac.id = e.actor_id
232 WHERE e.kind <> 'push' AND `+reachableCond+` 235 WHERE e.kind <> 'push' AND ` + reachableCond
233 ORDER BY e.id DESC LIMIT ?2`, userID, limit) 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 if err != nil { 243 if err != nil {
235 return nil, err 244 return nil, err
236 } 245 }
@@ -238,7 +247,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) {
238 var out []FeedEvent 247 var out []FeedEvent
239 for rows.Next() { 248 for rows.Next() {
240 var e FeedEvent 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 return nil, err 251 return nil, err
243 } 252 }
244 out = append(out, e) 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 return out, rows.Err() 95 return out, rows.Err()
96} 96}
97 97
98// ListIssues returns issues for a repo; state is "open", "closed", or "all". 98// ListIssues returns issues for a repo; state is "open", "closed", or
99func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { 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) {
100 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, 102 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
101 COALESCE(m.title, ''), i.created_at, i.updated_at 103 COALESCE(m.title, ''), i.created_at, i.updated_at
102 FROM issues i JOIN users u ON u.id = i.author_id 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 q += " AND i.state = ?" 109 q += " AND i.state = ?"
108 args = append(args, state) 110 args = append(args, state)
109 } 111 }
112 if before > 0 {
113 q += " AND i.number < ?"
114 args = append(args, before)
115 }
110 q += " ORDER BY i.number DESC" 116 q += " ORDER BY i.number DESC"
117 if limit > 0 {
118 q += " LIMIT ?"
119 args = append(args, limit)
120 }
111 rows, err := s.DB.Query(q, args...) 121 rows, err := s.DB.Query(q, args...)
112 if err != nil { 122 if err != nil {
113 return nil, err 123 return nil, err
internal/store/mrs.go +12 −1
@@ -84,14 +84,25 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
84 return m, err 84 return m, err
85} 85}
86 86
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) {
88 q := mrSelect + " WHERE m.repo_id = ?" 91 q := mrSelect + " WHERE m.repo_id = ?"
89 args := []any{repoID} 92 args := []any{repoID}
90 if state != "all" { 93 if state != "all" {
91 q += " AND m.state = ?" 94 q += " AND m.state = ?"
92 args = append(args, state) 95 args = append(args, state)
93 } 96 }
97 if before > 0 {
98 q += " AND m.number < ?"
99 args = append(args, before)
100 }
94 q += " ORDER BY m.number DESC" 101 q += " ORDER BY m.number DESC"
102 if limit > 0 {
103 q += " LIMIT ?"
104 args = append(args, limit)
105 }
95 rows, err := s.DB.Query(q, args...) 106 rows, err := s.DB.Query(q, args...)
96 if err != nil { 107 if err != nil {
97 return nil, err 108 return nil, err
internal/store/repos.go +21 −6
@@ -118,20 +118,35 @@ func (s *Store) DeleteRepo(repoID int64) error {
118 118
119// ListReposForUser returns repos the user owns, reaches through an org 119// ListReposForUser returns repos the user owns, reaches through an org
120// (unless the org scopes members to 'none'), has an explicit grant on, or 120// (unless the org scopes members to 'none'), has an explicit grant on, or
121// reaches through a team. 121// reaches through a team. limit 0 means everything; after (an owner/name
122func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { 122// path) starts the page strictly beyond it, matching the path-ascending
123 rows, err := s.DB.Query(repoSelect+` 123// order.
124func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
125 q := repoSelect + `
124 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? 126 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
125 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? 127 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
126 LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id 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 OR a.subject_id IS NOT NULL 130 OR a.subject_id IS NOT NULL
129 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none')) 131 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
130 OR EXISTS (SELECT 1 FROM team_repos tr 132 OR EXISTS (SELECT 1 FROM team_repos tr
131 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ? 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 GROUP BY r.id 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 if err != nil { 150 if err != nil {
136 return nil, err 151 return nil, err
137 } 152 }