A CLI-first git forge. cli forge git self-hosted

https://gitbay.org

Commit 06f7e4dfaa

06f7e4dfaa44f18520a705b30365166afbef452a

parent: 1c20fc5c9f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 03:01 UTC

store: labels on merge requests

Migration 0056 adds mr_labels. MR.Labels, ListMRLabels, SetMRLabel and
MRFilter.Label mirror the issue side; the join table and its item column
are the only difference, so listItemLabels and setItemLabel take them as
a labelJoin. Label.MRs counts them, and folding a repo label onto its
org's row moves mr_labels too.

Ref #231
internal/store/issues.go +2 −54
@@ -3,7 +3,6 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
76 "strings"
87)
98
@@ -275,26 +274,7 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error
275274// issue listing; ListIssues itself stays label-free for the CLI's lean
276275// list output.
277276func (s *Store) ListIssueLabels(repo Repo) (map[int64][]string, error) {
278 where, args := scopeClause("l", repo)
279 rows, err := s.DB.Query(`
280 SELECT il.issue_id, l.name FROM issue_labels il
281 JOIN labels l ON l.id = il.label_id
282 JOIN issues i ON i.id = il.issue_id
283 WHERE i.repo_id = ? AND `+where+` ORDER BY l.name`, append([]any{repo.ID}, args...)...)
284 if err != nil {
285 return nil, err
286 }
287 defer rows.Close()
288 out := map[int64][]string{}
289 for rows.Next() {
290 var id int64
291 var name string
292 if err := rows.Scan(&id, &name); err != nil {
293 return nil, err
294 }
295 out[id] = append(out[id], name)
296 }
297 return out, rows.Err()
277 return s.listItemLabels(issueLabelJoin, repo)
298278}
299279
300280// LabelColors returns the colours of the labels a repository sees, keyed
@@ -321,39 +301,7 @@ func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
321301// resolves the org's row when the org has the name, else the repository's,
322302// creating that on first use.
323303func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
324 tx, err := s.DB.Begin()
325 if err != nil {
326 return err
327 }
328 defer tx.Rollback()
329 where, args := scopeClause("l", repo)
330 if add {
331 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
332 return err
333 } else if !held {
334 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
335 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
336 return err
337 }
338 }
339 if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
340 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
341 ORDER BY l.org_id IS NULL LIMIT 1
342 ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
343 return err
344 }
345 } else {
346 res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
347 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
348 append(append([]any{issueID}, args...), name)...)
349 if err != nil {
350 return err
351 }
352 if n, _ := res.RowsAffected(); n == 0 {
353 return fmt.Errorf("label %q: %w", name, ErrNotFound)
354 }
355 }
356 return tx.Commit()
304 return s.setItemLabel(issueLabelJoin, repo, issueID, name, add)
357305}
358306
359307// SetIssueAssignee adds or removes an assignee by user id.
internal/store/labels.go +107 −19
@@ -3,28 +3,46 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
67)
78
8// Label is an issue label with its colour, "" when none was set (the web
9// then derives one from the name), and how many issues carry it. Org is
10// true for a label the repository sees through its org.
9// Label is a label with its colour, "" when none was set (the web then
10// derives one from the name), and how many issues and merge requests
11// carry it. Org is true for a label the repository sees through its org.
1112type Label struct {
1213 Name string `json:"name"`
1314 Color string `json:"color,omitempty"`
1415 Org bool `json:"org,omitempty"`
1516 Issues int64 `json:"issues"`
17 MRs int64 `json:"mrs"`
1618}
1719
18// labelRows lists labels under where, with use counted over the issues of
19// the readable repositories only, so a private repository's issues do not
20// show in a count someone outside it can see.
20// labelJoin is where a labelled thing carries its labels. Issues and
21// merge requests attach them identically, differing only in the join
22// table, its column naming the thing, and the thing's own table.
23type labelJoin struct {
24 table string
25 item string
26 items string
27}
28
29var (
30 issueLabelJoin = labelJoin{"issue_labels", "issue_id", "issues"}
31 mrLabelJoin = labelJoin{"mr_labels", "mr_id", "merge_requests"}
32)
33
34// labelRows lists labels under where, with use counted over the issues
35// and merge requests of the readable repositories only, so a private
36// repository's does not show in a count someone outside it can see.
2137func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
2238 in, inArgs := inClause(readable)
2339 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
2440 (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id
25 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `)
41 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `),
42 (SELECT COUNT(*) FROM mr_labels ml JOIN merge_requests m ON m.id = ml.mr_id
43 WHERE ml.label_id = l.id AND m.repo_id IN ` + in + `)
2644 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name`
27 rows, err := s.DB.Query(q, append(inArgs, args...)...)
45 rows, err := s.DB.Query(q, append(append(append([]any{}, inArgs...), inArgs...), args...)...)
2846 if err != nil {
2947 return nil, err
3048 }
@@ -32,7 +50,7 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
3250 var out []Label
3351 for rows.Next() {
3452 var l Label
35 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil {
53 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues, &l.MRs); err != nil {
3654 return nil, err
3755 }
3856 out = append(out, l)
@@ -40,6 +58,71 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label,
4058 return out, rows.Err()
4159}
4260
61// listItemLabels returns the label names attached to each of a
62// repository's issues or merge requests, keyed by its row id, the org's
63// labels included.
64func (s *Store) listItemLabels(j labelJoin, repo Repo) (map[int64][]string, error) {
65 where, args := scopeClause("l", repo)
66 rows, err := s.DB.Query(`
67 SELECT j.`+j.item+`, l.name FROM `+j.table+` j
68 JOIN labels l ON l.id = j.label_id
69 JOIN `+j.items+` t ON t.id = j.`+j.item+`
70 WHERE t.repo_id = ? AND `+where+` ORDER BY l.name`, append([]any{repo.ID}, args...)...)
71 if err != nil {
72 return nil, err
73 }
74 defer rows.Close()
75 out := map[int64][]string{}
76 for rows.Next() {
77 var id int64
78 var name string
79 if err := rows.Scan(&id, &name); err != nil {
80 return nil, err
81 }
82 out[id] = append(out[id], name)
83 }
84 return out, rows.Err()
85}
86
87// setItemLabel attaches (add) or detaches a label by name. Adding
88// resolves the org's row when the org has the name, else the
89// repository's, creating that on first use.
90func (s *Store) setItemLabel(j labelJoin, repo Repo, itemID int64, name string, add bool) error {
91 tx, err := s.DB.Begin()
92 if err != nil {
93 return err
94 }
95 defer tx.Rollback()
96 where, args := scopeClause("l", repo)
97 if add {
98 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
99 return err
100 } else if !held {
101 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
102 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
103 return err
104 }
105 }
106 if _, err := tx.Exec(`INSERT INTO `+j.table+` (`+j.item+`, label_id)
107 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
108 ORDER BY l.org_id IS NULL LIMIT 1
109 ON CONFLICT DO NOTHING`, append(append([]any{itemID}, args...), name)...); err != nil {
110 return err
111 }
112 } else {
113 res, err := tx.Exec(`DELETE FROM `+j.table+` WHERE `+j.item+` = ? AND label_id IN
114 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
115 append(append([]any{itemID}, args...), name)...)
116 if err != nil {
117 return err
118 }
119 if n, _ := res.RowsAffected(); n == 0 {
120 return fmt.Errorf("label %q: %w", name, ErrNotFound)
121 }
122 }
123 return tx.Commit()
124}
125
43126// ListLabels lists the labels a repository sees: its org's first, then its
44127// own, each by name.
45128func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
@@ -102,8 +185,9 @@ func (s *Store) SetLabel(repo Repo, name, color string) error {
102185 return tx.Commit()
103186}
104187
105// DeleteLabel removes the repository's label and takes it off every issue.
106// An org's label is ErrOrgScoped; no label at all is ErrNotFound.
188// DeleteLabel removes the repository's label and takes it off every issue
189// and merge request. An org's label is ErrOrgScoped; no label at all is
190// ErrNotFound.
107191func (s *Store) DeleteLabel(repo Repo, name string) error {
108192 tx, err := s.DB.Begin()
109193 if err != nil {
@@ -127,8 +211,9 @@ func (s *Store) DeleteLabel(repo Repo, name string) error {
127211}
128212
129213// SetOrgLabel creates the org's label or sets its colour. Repositories
130// under the org that hold the name are folded in: their issues move to
131// the org's row and their rows go. folded is how many were.
214// under the org that hold the name are folded in: their issues and merge
215// requests move to the org's row and their rows go. folded is how many
216// were.
132217func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
133218 tx, err := s.DB.Begin()
134219 if err != nil {
@@ -162,19 +247,22 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
162247}
163248
164249// foldLabelRow moves a repository's label onto the org's row: every issue
165// carrying it gets the org row, then the repository row goes.
250// and merge request carrying it gets the org row, then the repository row
251// goes.
166252func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error {
167 // OR IGNORE: an issue cannot carry both today, but the primary key
168 // makes the move safe if it ever did.
169 if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
170 return err
253 // OR IGNORE: nothing can carry both today, but the primary key makes
254 // the move safe if it ever did.
255 for _, table := range []string{issueLabelJoin.table, mrLabelJoin.table} {
256 if _, err := tx.Exec("UPDATE OR IGNORE "+table+" SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
257 return err
258 }
171259 }
172260 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
173261 return err
174262}
175263
176264// DeleteOrgLabel removes an org's label from the org and from every issue
177// under it.
265// and merge request under it.
178266func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
179267 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
180268 if err != nil {
internal/store/labels_test.go +100
@@ -15,6 +15,8 @@ type acmeFixture struct {
1515 app Repo
1616 coreIssue int64
1717 siteIssue int64
18 coreMR int64
19 siteMR int64
1820}
1921
2022func newAcme(t *testing.T) acmeFixture {
@@ -63,6 +65,21 @@ func newAcme(t *testing.T) acmeFixture {
6365 }
6466 f.coreIssue = mkIssue(f.core, "c1")
6567 f.siteIssue = mkIssue(f.site, "s1")
68 // Same resolution for merge requests: CreateMR returns the per-repo
69 // number, mr_labels.mr_id references merge_requests.id.
70 mkMR := func(repo Repo, title string) int64 {
71 n, err := s.CreateMR(repo.ID, f.alice, repo.ID, "topic", "main", title, "", "deadbeef", "md", false)
72 if err != nil {
73 t.Fatal(err)
74 }
75 m, err := s.MRByNumber(repo.ID, n)
76 if err != nil {
77 t.Fatal(err)
78 }
79 return m.ID
80 }
81 f.coreMR = mkMR(f.core, "c!1")
82 f.siteMR = mkMR(f.site, "s!1")
6683 return f
6784}
6885
@@ -162,6 +179,89 @@ func TestListIssueLabelsIncludesOrgRows(t *testing.T) {
162179 }
163180}
164181
182// The web merge request list reads labels per repository, the same shape
183// the issue list reads them in; an org label attached to a merge request
184// comes back from there like the repository's own (#231).
185func TestListMRLabelsIncludesOrgRows(t *testing.T) {
186 f := newAcme(t)
187 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
188 t.Fatal(err)
189 }
190 if err := f.s.SetLabel(f.core, "docs", ""); err != nil {
191 t.Fatal(err)
192 }
193 for _, name := range []string{"bug", "docs"} {
194 if err := f.s.SetMRLabel(f.core, f.coreMR, name, true); err != nil {
195 t.Fatal(err)
196 }
197 }
198 got, err := f.s.ListMRLabels(f.core)
199 if err != nil || len(got[f.coreMR]) != 2 || got[f.coreMR][0] != "bug" || got[f.coreMR][1] != "docs" {
200 t.Fatalf("core MR labels = %v, %v", got, err)
201 }
202 // Another repository under the org does not pick up core's attachment.
203 if got, _ := f.s.ListMRLabels(f.site); len(got) != 0 {
204 t.Fatalf("site MR labels = %v", got)
205 }
206 // MRByNumber carries them, and the label listing counts them apart
207 // from issues.
208 m, err := f.s.MRByNumber(f.core.ID, 1)
209 if err != nil || len(m.Labels) != 2 || m.Labels[0] != "bug" {
210 t.Fatalf("MRByNumber labels = %v, %v", m.Labels, err)
211 }
212 rows, _ := f.s.ListLabels(f.core, f.orgRepos())
213 if len(rows) != 2 || rows[0].Name != "bug" || rows[0].MRs != 1 || rows[0].Issues != 0 {
214 t.Fatalf("label rows = %+v", rows)
215 }
216 // The filter narrows to the merge requests carrying the name.
217 mrs, err := f.s.QueryMRs(f.core.ID, MRFilter{State: "all", Label: "bug"})
218 if err != nil || len(mrs) != 1 || mrs[0].ID != f.coreMR {
219 t.Fatalf("QueryMRs by label = %+v, %v", mrs, err)
220 }
221 if mrs, _ := f.s.QueryMRs(f.core.ID, MRFilter{State: "all", Label: "nope"}); len(mrs) != 0 {
222 t.Fatalf("QueryMRs by absent label = %+v", mrs)
223 }
224 // Removing a name nothing carries is not found.
225 if err := f.s.SetMRLabel(f.core, f.coreMR, "nope", false); !errors.Is(err, ErrNotFound) {
226 t.Fatalf("remove of absent label: %v, want ErrNotFound", err)
227 }
228}
229
230// Folding a repository label onto its org's row moves the merge requests
231// carrying it, not only the issues.
232func TestSetOrgLabelFoldsMRLabels(t *testing.T) {
233 f := newAcme(t)
234 if err := f.s.SetMRLabel(f.core, f.coreMR, "bug", true); err != nil {
235 t.Fatal(err)
236 }
237 if err := f.s.SetMRLabel(f.site, f.siteMR, "bug", true); err != nil {
238 t.Fatal(err)
239 }
240 folded, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000")
241 if err != nil || folded != 2 {
242 t.Fatalf("SetOrgLabel folded %d, %v; want 2", folded, err)
243 }
244 var n int
245 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n)
246 if n != 1 {
247 t.Fatalf("labels named bug after folding: %d, want 1", n)
248 }
249 // Both merge requests still carry it, now through the org's row.
250 for _, c := range []struct {
251 repo Repo
252 mr int64
253 }{{f.core, f.coreMR}, {f.site, f.siteMR}} {
254 got, _ := f.s.ListMRLabels(c.repo)
255 if len(got[c.mr]) != 1 || got[c.mr][0] != "bug" {
256 t.Fatalf("%s MR labels after folding = %v", c.repo.Name, got)
257 }
258 }
259 rows, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
260 if len(rows) != 1 || rows[0].MRs != 2 {
261 t.Fatalf("org label rows = %+v", rows)
262 }
263}
264
165265func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
166266 f := newAcme(t)
167267 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
internal/store/migrations/0056_mr_labels.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE mr_labels;
internal/store/migrations/0056_mr_labels.up.sql added +7
@@ -0,0 +1,7 @@
1-- Labels on merge requests, carried the same way issues carry them
2-- (#231). The label rows themselves are shared: repo or org scoped.
3CREATE TABLE mr_labels (
4 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
5 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
6 PRIMARY KEY (mr_id, label_id)
7);
internal/store/mrs.go +27
@@ -34,6 +34,7 @@ type MR struct {
3434 SupersededBy int64
3535 CreatedAt string
3636 UpdatedAt string
37 Labels []string
3738 // ReviewRequests is who has been asked, directly, for a review — the
3839 // mr review request counterpart of Issue.Assignees.
3940 ReviewRequests []string
@@ -121,12 +122,32 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
121122 if err != nil {
122123 return m, err
123124 }
125 if m.Labels, err = s.issueStrings(m.ID, `
126 SELECT l.name FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
127 WHERE ml.mr_id = ? ORDER BY l.name`); err != nil {
128 return m, err
129 }
124130 m.ReviewRequests, err = s.issueStrings(m.ID, `
125131 SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
126132 WHERE rr.mr_id = ? ORDER BY u.username`)
127133 return m, err
128134}
129135
136// ListMRLabels returns the label names attached to each merge request of
137// a repo, keyed by merge request id, its org's labels included. Used by
138// the web merge request listing; ListMRs itself stays label-free for the
139// CLI's lean list output.
140func (s *Store) ListMRLabels(repo Repo) (map[int64][]string, error) {
141 return s.listItemLabels(mrLabelJoin, repo)
142}
143
144// SetMRLabel attaches (add) or detaches a label by name, the issue rules
145// exactly: the org's row when the org has the name, else the
146// repository's, created on first use.
147func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error {
148 return s.setItemLabel(mrLabelJoin, repo, mrID, name, add)
149}
150
130151// SetMRReviewRequest adds or removes a review request by user id — the
131152// mr review request counterpart of SetIssueAssignee.
132153func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
@@ -160,6 +181,7 @@ func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
160181// too. Milestone "none" selects merge requests with no milestone.
161182type MRFilter struct {
162183 State string
184 Label string
163185 Author string
164186 Milestone string
165187 Search string // full-text over title and body
@@ -180,6 +202,11 @@ func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
180202 q += " AND m.state = ?"
181203 args = append(args, f.State)
182204 }
205 if f.Label != "" {
206 q += ` AND EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON l.id = ml.label_id
207 WHERE ml.mr_id = m.id AND l.name = ?)`
208 args = append(args, f.Label)
209 }
183210 if f.Author != "" {
184211 q += " AND u.username = ?"
185212 args = append(args, f.Author)