Commit 06f7e4dfaa
Verified · cmc
internal/store/issues.go +2 −54
| @@ -3,7 +3,6 @@ package store | ||
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "fmt" | |
| 7 | 6 | "strings" |
| 8 | 7 | ) |
| 9 | 8 | |
| @@ -275,26 +274,7 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error | ||
| 275 | 274 | // issue listing; ListIssues itself stays label-free for the CLI's lean |
| 276 | 275 | // list output. |
| 277 | 276 | func (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) | |
| 298 | 278 | } |
| 299 | 279 | |
| 300 | 280 | // 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) { | ||
| 321 | 301 | // resolves the org's row when the org has the name, else the repository's, |
| 322 | 302 | // creating that on first use. |
| 323 | 303 | func (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) | |
| 357 | 305 | } |
| 358 | 306 | |
| 359 | 307 | // SetIssueAssignee adds or removes an assignee by user id. |
internal/store/labels.go +107 −19
| @@ -3,28 +3,46 @@ package store | ||
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "fmt" | |
| 6 | 7 | ) |
| 7 | 8 | |
| 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. | |
| 11 | 12 | type Label struct { |
| 12 | 13 | Name string `json:"name"` |
| 13 | 14 | Color string `json:"color,omitempty"` |
| 14 | 15 | Org bool `json:"org,omitempty"` |
| 15 | 16 | Issues int64 `json:"issues"` |
| 17 | MRs int64 `json:"mrs"` | |
| 16 | 18 | } |
| 17 | 19 | |
| 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. | |
| 23 | type labelJoin struct { | |
| 24 | table string | |
| 25 | item string | |
| 26 | items string | |
| 27 | } | |
| 28 | ||
| 29 | var ( | |
| 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. | |
| 21 | 37 | func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) { |
| 22 | 38 | in, inArgs := inClause(readable) |
| 23 | 39 | q := `SELECT l.name, l.color, l.org_id IS NOT NULL, |
| 24 | 40 | (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 + `) | |
| 26 | 44 | 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...)...) | |
| 28 | 46 | if err != nil { |
| 29 | 47 | return nil, err |
| 30 | 48 | } |
| @@ -32,7 +50,7 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, | ||
| 32 | 50 | var out []Label |
| 33 | 51 | for rows.Next() { |
| 34 | 52 | 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 { | |
| 36 | 54 | return nil, err |
| 37 | 55 | } |
| 38 | 56 | out = append(out, l) |
| @@ -40,6 +58,71 @@ func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, | ||
| 40 | 58 | return out, rows.Err() |
| 41 | 59 | } |
| 42 | 60 | |
| 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. | |
| 64 | func (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. | |
| 90 | func (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 | ||
| 43 | 126 | // ListLabels lists the labels a repository sees: its org's first, then its |
| 44 | 127 | // own, each by name. |
| 45 | 128 | func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) { |
| @@ -102,8 +185,9 @@ func (s *Store) SetLabel(repo Repo, name, color string) error { | ||
| 102 | 185 | return tx.Commit() |
| 103 | 186 | } |
| 104 | 187 | |
| 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. | |
| 107 | 191 | func (s *Store) DeleteLabel(repo Repo, name string) error { |
| 108 | 192 | tx, err := s.DB.Begin() |
| 109 | 193 | if err != nil { |
| @@ -127,8 +211,9 @@ func (s *Store) DeleteLabel(repo Repo, name string) error { | ||
| 127 | 211 | } |
| 128 | 212 | |
| 129 | 213 | // 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. | |
| 132 | 217 | func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { |
| 133 | 218 | tx, err := s.DB.Begin() |
| 134 | 219 | if err != nil { |
| @@ -162,19 +247,22 @@ func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { | ||
| 162 | 247 | } |
| 163 | 248 | |
| 164 | 249 | // 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. | |
| 166 | 252 | func 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 | } | |
| 171 | 259 | } |
| 172 | 260 | _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow) |
| 173 | 261 | return err |
| 174 | 262 | } |
| 175 | 263 | |
| 176 | 264 | // DeleteOrgLabel removes an org's label from the org and from every issue |
| 177 | // under it. | |
| 265 | // and merge request under it. | |
| 178 | 266 | func (s *Store) DeleteOrgLabel(orgID int64, name string) error { |
| 179 | 267 | res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name) |
| 180 | 268 | if err != nil { |
internal/store/labels_test.go +100
| @@ -15,6 +15,8 @@ type acmeFixture struct { | ||
| 15 | 15 | app Repo |
| 16 | 16 | coreIssue int64 |
| 17 | 17 | siteIssue int64 |
| 18 | coreMR int64 | |
| 19 | siteMR int64 | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | func newAcme(t *testing.T) acmeFixture { |
| @@ -63,6 +65,21 @@ func newAcme(t *testing.T) acmeFixture { | ||
| 63 | 65 | } |
| 64 | 66 | f.coreIssue = mkIssue(f.core, "c1") |
| 65 | 67 | 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") | |
| 66 | 83 | return f |
| 67 | 84 | } |
| 68 | 85 | |
| @@ -162,6 +179,89 @@ func TestListIssueLabelsIncludesOrgRows(t *testing.T) { | ||
| 162 | 179 | } |
| 163 | 180 | } |
| 164 | 181 | |
| 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). | |
| 185 | func 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. | |
| 232 | func 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 | ||
| 165 | 265 | func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) { |
| 166 | 266 | f := newAcme(t) |
| 167 | 267 | if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil { |
internal/store/migrations/0056_mr_labels.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP 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. | |
| 3 | CREATE 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 { | ||
| 34 | 34 | SupersededBy int64 |
| 35 | 35 | CreatedAt string |
| 36 | 36 | UpdatedAt string |
| 37 | Labels []string | |
| 37 | 38 | // ReviewRequests is who has been asked, directly, for a review — the |
| 38 | 39 | // mr review request counterpart of Issue.Assignees. |
| 39 | 40 | ReviewRequests []string |
| @@ -121,12 +122,32 @@ func (s *Store) MRByNumber(repoID, number int64) (MR, error) { | ||
| 121 | 122 | if err != nil { |
| 122 | 123 | return m, err |
| 123 | 124 | } |
| 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 | } | |
| 124 | 130 | m.ReviewRequests, err = s.issueStrings(m.ID, ` |
| 125 | 131 | SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id |
| 126 | 132 | WHERE rr.mr_id = ? ORDER BY u.username`) |
| 127 | 133 | return m, err |
| 128 | 134 | } |
| 129 | 135 | |
| 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. | |
| 140 | func (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. | |
| 147 | func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error { | |
| 148 | return s.setItemLabel(mrLabelJoin, repo, mrID, name, add) | |
| 149 | } | |
| 150 | ||
| 130 | 151 | // SetMRReviewRequest adds or removes a review request by user id — the |
| 131 | 152 | // mr review request counterpart of SetIssueAssignee. |
| 132 | 153 | func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error { |
| @@ -160,6 +181,7 @@ func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) { | ||
| 160 | 181 | // too. Milestone "none" selects merge requests with no milestone. |
| 161 | 182 | type MRFilter struct { |
| 162 | 183 | State string |
| 184 | Label string | |
| 163 | 185 | Author string |
| 164 | 186 | Milestone string |
| 165 | 187 | Search string // full-text over title and body |
| @@ -180,6 +202,11 @@ func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) { | ||
| 180 | 202 | q += " AND m.state = ?" |
| 181 | 203 | args = append(args, f.State) |
| 182 | 204 | } |
| 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 | } | |
| 183 | 210 | if f.Author != "" { |
| 184 | 211 | q += " AND u.username = ?" |
| 185 | 212 | args = append(args, f.Author) |