Org labels, milestones and cross-repository closes !375

merged merged by cmc on 2026-09-11 16:54 UTC · krz/gitbay:org-scope into main

43 files changed, +5199 −152

Layout: unified · split

.gitbay/wiki/Parity.org +8
@@ -113,6 +113,9 @@ reviews, since an approval was of the diff against the old branch.
113| choose body markup | yes | yes | yes | 113| choose body markup | yes | yes | yes |
114| issue templates | yes | yes | yes | 114| issue templates | yes | yes | yes |
115| milestone create, close, reopen | yes | no | yes | 115| milestone create, close, reopen | yes | no | yes |
116| org labels: set, list, remove | yes | list | no |
117| org milestones: create, list, close, reopen | yes | list | no |
118| closes across repositories | yes | yes | yes |
116 119
117Labels are created on the fly by =issue label --add= and managed by 120Labels are created on the fly by =issue label --add= and managed by
118=label list=, =label set <label> --color rrggbb= and =label remove=, 121=label list=, =label set <label> --color rrggbb= and =label remove=,
@@ -121,6 +124,11 @@ on every chip and derives one from the name when none is set. The set
121itself is at =/<owner>/<repo>/labels=, linked from the issue list: 124itself is at =/<owner>/<repo>/labels=, linked from the issue list:
122create, recolour and remove, dispatching the same commands. 125create, recolour and remove, dispatching the same commands.
123 126
127Org labels and milestones are managed on the CLI and API only;
128=/<org>/-/labels= and =/<org>/-/milestones= show them. The repository
129label page's form exists for colour alone, and three org forms nobody
130asked for were not worth their handlers.
131
124Issue, MR and release bodies, and their comments, carry the markup they 132Issue, MR and release bodies, and their comments, carry the markup they
125were written in — =--format md|org= on create, comment and edit, stored 133were written in — =--format md|org= on create, comment and edit, stored
126alongside the text so changing a preference later cannot reinterpret 134alongside the text so changing a preference later cannot reinterpret
.gitbay/wiki/Users.org +23 −1
@@ -347,7 +347,11 @@ pages carry the discovery link.
347Commit messages act on issues when the commits land on the default 347Commit messages act on issues when the commits land on the default
348branch (direct push or MR merge): =closes/fixes/resolves #4= closes the 348branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
349issue with a linking comment, and a bare =#4= leaves a reference 349issue with a linking comment, and a bare =#4= leaves a reference
350comment. Each issue/commit pair acts once, ever. Same repository only. 350comment. Each issue/commit pair acts once, ever. =Closes owner/name#N= closes an issue in another repository when
351you hold write there; otherwise it stays a plain link. The comment left
352on the closed issue links the closing commit by its repository path, so
353closing a public repository's issue from a private one names the private
354repository there. A bare =owner/name#N= links and does nothing.
351 355
352Milestones group issues and MRs toward a release (write access to 356Milestones group issues and MRs toward a release (write access to
353manage, attach with =issue milestone= / =mr milestone=; progress shows 357manage, attach with =issue milestone= / =mr milestone=; progress shows
@@ -359,6 +363,24 @@ gitbay milestone list [--state open|closed|all]
359gitbay milestone close v1.0 / reopen v1.0 363gitbay milestone close v1.0 / reopen v1.0
360#+end_src 364#+end_src
361 365
366An org holds labels and milestones every repository under it sees
367beside its own. =issue label --add=, =issue milestone= and =mr
368milestone= resolve the org's row first; a repository cannot create a
369label or milestone with a name its org holds. Creating an org label or
370milestone whose name repositories under the org already use folds them
371in: their issues and merge requests move to the org's row. Org admins
372manage them; counts span the repositories you can read.
373
374#+begin_src sh
375gitbay org label set acme bug --color cf222e
376gitbay org label list acme / remove acme bug
377gitbay org milestone create acme v2 --due 2027-03-01
378gitbay org milestone list acme [--state open|closed|all]
379gitbay org milestone close acme v2 / reopen acme v2
380#+end_src
381
382On the web: =/acme/-/labels= and =/acme/-/milestones=, read-only.
383
362Issue templates: commit =.gitbay/issue-template.md= (and optional 384Issue templates: commit =.gitbay/issue-template.md= (and optional
363=issue-template-<name>.md= variants) to the default branch. =gitbay 385=issue-template-<name>.md= variants) to the default branch. =gitbay
364issue create= prefills =$EDITOR= with the default template, the web 386issue create= prefills =$EDITOR= with the default template, the web
cmd/gitbay/main.go +11
@@ -652,6 +652,17 @@ func orgCmd() *cobra.Command {
652 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), 652 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
653 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}), 653 pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}),
654 ), 654 ),
655 group("label", "labels every org repository sees",
656 pass("set", "create an org label or set its colour: <org> <label> [--color rrggbb|'']", passOpts{server: []string{"org", "label", "set"}}),
657 pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}),
658 pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}),
659 ),
660 group("milestone", "milestones spanning an org's repositories",
661 pass("create", "create an org milestone: <org> <title> [--description d] [--due YYYY-MM-DD]", passOpts{server: []string{"org", "milestone", "create"}}),
662 pass("list", "list org milestones with progress: <org> [--state open|closed|all]", passOpts{server: []string{"org", "milestone", "list"}}),
663 pass("close", "close an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "close"}}),
664 pass("reopen", "reopen an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "reopen"}}),
665 ),
655 group("team", "scope repository access with teams", 666 group("team", "scope repository access with teams",
656 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}), 667 pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}),
657 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}), 668 pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}),
cmd/gitbayd/hook.go +1
@@ -171,6 +171,7 @@ func hookCmd() *cobra.Command {
171 Hook: args[0], 171 Hook: args[0],
172 RepoID: repoID, 172 RepoID: repoID,
173 UserID: userID, 173 UserID: userID,
174 Scope: os.Getenv(hookd.EnvScope),
174 Updates: updates, 175 Updates: updates,
175 }, func(emit func(hookd.RawCommit) error) error { 176 }, func(emit func(hookd.RawCommit) error) error {
176 return streamIncomingCommits(updates, emit) 177 return streamIncomingCommits(updates, emit)
docs/plans/2026-09-11-org-labels-milestones-closes.md added +2691
@@ -0,0 +1,2691 @@
1# Org labels, milestones and cross-repository closes: implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Labels and milestones an org defines once for every repository under it, and `Closes owner/name#N` acting on another repository the actor can write to. Closes #203.
6
7**Architecture:** The existing `labels` and `milestones` tables gain an `org_id` beside a now-nullable `repo_id` (migration 0052), so `issue_labels` and the two `milestone_id` columns keep their ids. Store lookups take the `store.Repo` and match `repo_id = ? OR org_id = ?` for org-owned repositories. Seven `org label` / `org milestone` control commands manage org rows; the web gets two read pages under `/{org}/-/`. The closing-keyword pattern in `commitrefs.go` accepts an `owner/name` prefix and acts when the actor holds write on the target.
8
9**Tech Stack:** Go, SQLite via modernc (hand-written SQL, no ORM), Go `html/template`, the control registry in `internal/control`, the e2e harness in `e2e/`.
10
11**Spec:** `docs/specs/2026-09-11-org-labels-milestones-closes-design.md`
12
13## Global Constraints
14
15- Every capability lands as a control command first; the CLI, web and API dispatch into it. New commands need a `pass()` row in `cmd/gitbay/main.go` (a coverage test enforces this) and, if `ReadOnly`, a row in `readArgs` in `e2e/readonly_test.go`.
16- Hand-written SQL only. No ORM. Migrations are `internal/store/migrations/NNNN_name.up.sql` and `.down.sql`, embedded, run one per transaction.
17- Private repositories return not-found, never a denial that confirms a namespace. Org existence is public (`org show` answers anyone).
18- Never mention an assistant or model anywhere: commit messages, comments, docs.
19- Commit messages reference the issue: `Ref #203` on each task, `Closes #203` on the last.
20- Run locally: `go build ./... && go vet ./...` and the unit tests of the touched packages. Run at most the one e2e test you write (`go test ./e2e -run TestOrgLabels`); CI on bay1 runs the full suite.
21- Style: plain sentences in comments, no dramatic framing. Match the surrounding code.
22- Work on branch `org-scope`, which already holds the spec.
23
24One deviation from the spec's wording: the org pages get their own small templates (`orglabels.html`, `orgmilestones.html`) rather than reusing `labels.html` and `milestones.html`, whose every URL and field is a `repoPage`. Behaviour is as specified.
25
26---
27
28### Task 1: Migration 0052 and the scoped structs
29
30**Files:**
31- Create: `internal/store/migrations/0052_org_scope.up.sql`
32- Create: `internal/store/migrations/0052_org_scope.down.sql`
33- Modify: `internal/store/labels.go:1-10` (struct)
34- Modify: `internal/store/milestones.go:9-20` (struct)
35- Test: `internal/store/store_test.go`
36
37**Interfaces:**
38- Produces: `labels(id, repo_id NULL, org_id NULL, name, color)` and `milestones(id, repo_id NULL, org_id NULL, title, description, due_date, state, created_at)` with `CHECK ((repo_id IS NULL) <> (org_id IS NULL))` and partial unique indexes `labels_repo_name`, `labels_org_name`, `milestones_repo_title`, `milestones_org_title`.
39- Produces: `store.Label{Name, Color, Org bool, Issues}` and `store.Milestone{..., RepoID, OrgID, ...}`.
40
41- [ ] **Step 1: Write the failing migration test**
42
43Append to `internal/store/store_test.go`:
44
45```go
46// Migration 0052 rebuilds labels and milestones with an org scope. The
47// rebuild renames the old tables; since SQLite 3.26 a rename rewrites the
48// children's foreign keys, so issue_labels and the milestone_id columns
49// would follow labels_old unless legacy_alter_table is on for the script.
50// This checks the ids, the memberships and the foreign keys all survive.
51func TestMigration0052KeepsMembershipsAndForeignKeys(t *testing.T) {
52 s := open(t)
53 if err := s.MigrateTo(51); err != nil {
54 t.Fatal(err)
55 }
56 uid, err := s.CreateUser("alice", false)
57 if err != nil {
58 t.Fatal(err)
59 }
60 rid, err := s.CreateRepo("user", uid, "app", "public")
61 if err != nil {
62 t.Fatal(err)
63 }
64 iid, err := s.CreateIssue(rid, uid, "one", "", "md")
65 if err != nil {
66 t.Fatal(err)
67 }
68 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, name, color) VALUES (?, 'bug', '#ff0000')", rid); err != nil {
69 t.Fatal(err)
70 }
71 if _, err := s.DB.Exec("INSERT INTO issue_labels (issue_id, label_id) SELECT ?, id FROM labels WHERE name = 'bug'", iid); err != nil {
72 t.Fatal(err)
73 }
74 if _, err := s.DB.Exec("INSERT INTO milestones (repo_id, title) VALUES (?, 'v1')", rid); err != nil {
75 t.Fatal(err)
76 }
77 if _, err := s.DB.Exec("UPDATE issues SET milestone_id = (SELECT id FROM milestones WHERE title = 'v1') WHERE id = ?", iid); err != nil {
78 t.Fatal(err)
79 }
80 if err := s.MigrateTo(52); err != nil {
81 t.Fatal(err)
82 }
83 var n int
84 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id
85 WHERE il.issue_id = ? AND l.name = 'bug' AND l.repo_id = ? AND l.org_id IS NULL`, iid, rid).Scan(&n); err != nil || n != 1 {
86 t.Fatalf("label membership after 0052: %d, %v", n, err)
87 }
88 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issues i JOIN milestones m ON m.id = i.milestone_id
89 WHERE i.id = ? AND m.title = 'v1' AND m.repo_id = ?`, iid, rid).Scan(&n); err != nil || n != 1 {
90 t.Fatalf("milestone attachment after 0052: %d, %v", n, err)
91 }
92 rows, err := s.DB.Query("PRAGMA foreign_key_check")
93 if err != nil {
94 t.Fatal(err)
95 }
96 defer rows.Close()
97 if rows.Next() {
98 t.Fatal("foreign_key_check reported a violation after 0052")
99 }
100 // The scope CHECK holds: a row with neither or both scopes is refused.
101 if _, err := s.DB.Exec("INSERT INTO labels (name) VALUES ('neither')"); err == nil {
102 t.Fatal("label with no scope was accepted")
103 }
104 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, org_id, name) VALUES (?, 1, 'both')", rid); err == nil {
105 t.Fatal("label with both scopes was accepted")
106 }
107 // Down refuses while an org-scoped row exists, and works once it is gone.
108 if _, err := s.DB.Exec("INSERT INTO orgs (name) VALUES ('acme')"); err != nil {
109 t.Fatal(err)
110 }
111 if _, err := s.DB.Exec("INSERT INTO labels (org_id, name) VALUES ((SELECT id FROM orgs WHERE name = 'acme'), 'org-only')"); err != nil {
112 t.Fatal(err)
113 }
114 if err := s.MigrateTo(51); err == nil {
115 t.Fatal("down migration accepted an org-scoped label")
116 }
117 if _, err := s.DB.Exec("DELETE FROM labels WHERE org_id IS NOT NULL"); err != nil {
118 t.Fatal(err)
119 }
120 if err := s.MigrateTo(51); err != nil {
121 t.Fatalf("down migration: %v", err)
122 }
123 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?`, iid).Scan(&n); err != nil || n != 1 {
124 t.Fatalf("label membership after down: %d, %v", n, err)
125 }
126}
127```
128
129- [ ] **Step 2: Run it to verify it fails**
130
131Run: `go test ./internal/store/ -run TestMigration0052 -v`
132Expected: FAIL, "no such schema version 52".
133
134- [ ] **Step 3: Write the up migration**
135
136`internal/store/migrations/0052_org_scope.up.sql`:
137
138```sql
139-- Labels and milestones scoped to a repository or to an org (#203).
140-- Exactly one of repo_id and org_id is set. Uniqueness is per scope, as
141-- two partial indexes; the app refuses a repo name the org already holds.
142--
143-- Both tables have children (issue_labels, issues.milestone_id,
144-- merge_requests.milestone_id). Since SQLite 3.26 renaming a parent
145-- rewrites the children's foreign keys to follow it, which would bind them
146-- to the *_old tables. legacy_alter_table keeps the children naming labels
147-- and milestones, which the new tables then are. foreign_keys stays on:
148-- nothing references the *_old tables, so dropping them cascades nothing.
149PRAGMA legacy_alter_table = ON;
150
151ALTER TABLE labels RENAME TO labels_old;
152CREATE TABLE labels (
153 id INTEGER PRIMARY KEY,
154 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
155 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
156 name TEXT NOT NULL,
157 color TEXT NOT NULL DEFAULT '',
158 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
159);
160INSERT INTO labels (id, repo_id, name, color)
161 SELECT id, repo_id, name, color FROM labels_old;
162DROP TABLE labels_old;
163CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL;
164CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL;
165
166ALTER TABLE milestones RENAME TO milestones_old;
167CREATE TABLE milestones (
168 id INTEGER PRIMARY KEY,
169 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
170 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
171 title TEXT NOT NULL,
172 description TEXT NOT NULL DEFAULT '',
173 due_date TEXT NOT NULL DEFAULT '',
174 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
175 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
176 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
177);
178INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
179 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
180DROP TABLE milestones_old;
181CREATE UNIQUE INDEX milestones_repo_title ON milestones(repo_id, title) WHERE repo_id IS NOT NULL;
182CREATE UNIQUE INDEX milestones_org_title ON milestones(org_id, title) WHERE org_id IS NOT NULL;
183
184PRAGMA legacy_alter_table = OFF;
185```
186
187- [ ] **Step 4: Write the down migration**
188
189`internal/store/migrations/0052_org_scope.down.sql`. The copy into a `NOT NULL repo_id` column fails on any org-scoped row, which is the refusal.
190
191```sql
192-- Back to per-repository rows. An org-scoped row has no repository to go
193-- to; the NOT NULL on repo_id refuses the copy, which fails the migration.
194PRAGMA legacy_alter_table = ON;
195
196ALTER TABLE labels RENAME TO labels_old;
197CREATE TABLE labels (
198 id INTEGER PRIMARY KEY,
199 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
200 name TEXT NOT NULL,
201 color TEXT NOT NULL DEFAULT '',
202 UNIQUE (repo_id, name)
203);
204INSERT INTO labels (id, repo_id, name, color)
205 SELECT id, repo_id, name, color FROM labels_old;
206DROP TABLE labels_old;
207
208ALTER TABLE milestones RENAME TO milestones_old;
209CREATE TABLE milestones (
210 id INTEGER PRIMARY KEY,
211 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
212 title TEXT NOT NULL,
213 description TEXT NOT NULL DEFAULT '',
214 due_date TEXT NOT NULL DEFAULT '',
215 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
216 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
217 UNIQUE (repo_id, title)
218);
219INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
220 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
221DROP TABLE milestones_old;
222
223PRAGMA legacy_alter_table = OFF;
224```
225
226- [ ] **Step 5: Add the struct fields**
227
228In `internal/store/labels.go` replace the `Label` struct:
229
230```go
231// Label is an issue label with its colour, "" when none was set (the web
232// then derives one from the name), and how many issues carry it. Org is
233// true for a label the repository sees through its org.
234type Label struct {
235 Name string `json:"name"`
236 Color string `json:"color,omitempty"`
237 Org bool `json:"org,omitempty"`
238 Issues int64 `json:"issues"`
239}
240```
241
242In `internal/store/milestones.go` add `OrgID int64 // set instead of RepoID for an org milestone` after `RepoID`.
243
244- [ ] **Step 6: Run the test to verify it passes**
245
246Run: `go test ./internal/store/ -run TestMigration0052 -v`
247Expected: PASS. Then `go build ./...` still compiles (only fields were added).
248
249- [ ] **Step 7: Commit**
250
251```bash
252git add internal/store/migrations/0052_org_scope.up.sql internal/store/migrations/0052_org_scope.down.sql internal/store/labels.go internal/store/milestones.go internal/store/store_test.go
253git commit -m "store: migration 0052 scopes labels and milestones to a repo or an org
254
255Ref #203"
256```
257
258---
259
260### Task 2: Store: labels by scope, org labels, promote
261
262**Files:**
263- Modify: `internal/store/labels.go`
264- Modify: `internal/store/issues.go:297-346` (`LabelColors`, `SetIssueLabel`)
265- Create: `internal/store/scope.go`
266- Test: `internal/store/labels_test.go` (new)
267
268**Interfaces:**
269- Produces in `scope.go`: `var ErrOrgScoped = errors.New("held by the org")`; `func scopeClause(alias string, repo Repo) (string, []any)`; `func inClause(ids []int64) (string, []any)`.
270- Produces in `labels.go`:
271 - `func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error)` — org rows first then repo rows, each by name; `Issues` counts only issues in `readable`.
272 - `func (s *Store) LabelByName(repo Repo, name string) (Label, error)` — `ErrNotFound` when neither scope has it.
273 - `func (s *Store) SetLabel(repo Repo, name, color string) error` — `ErrOrgScoped` when the org holds the name.
274 - `func (s *Store) DeleteLabel(repo Repo, name string) error` — `ErrOrgScoped` for an org row, `ErrNotFound` for none.
275 - `func (s *Store) ListOrgLabels(orgID int64, readable []int64) ([]Label, error)`
276 - `func (s *Store) SetOrgLabel(orgID int64, name, color string) (folded int, err error)` — promotes same-named repo labels under the org; `folded` is how many repositories were folded in.
277 - `func (s *Store) DeleteOrgLabel(orgID int64, name string) error` — `ErrNotFound` when absent.
278- Produces in `issues.go`: `func (s *Store) LabelColors(repo Repo) (map[string]string, error)`; `func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error` — add resolves the org row first, else creates the repo row.
279- Consumes: Task 1's schema. Note that every `ON CONFLICT (repo_id, name)` must name the partial index's predicate: `ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL`.
280
281- [ ] **Step 1: Write the failing tests**
282
283`internal/store/labels_test.go`:
284
285```go
286package store
287
288import (
289 "errors"
290 "testing"
291)
292
293// acmeFixture: org acme owned by alice with repos acme/core and
294// acme/site, an issue in each, and alice's own alice/app.
295type acmeFixture struct {
296 s *Store
297 alice int64
298 org int64
299 core, site Repo
300 app Repo
301 coreIssue int64
302 siteIssue int64
303}
304
305func newAcme(t *testing.T) acmeFixture {
306 t.Helper()
307 s := open(t)
308 if err := s.MigrateUp(); err != nil {
309 t.Fatal(err)
310 }
311 var f acmeFixture
312 f.s = s
313 var err error
314 if f.alice, err = s.CreateUser("alice", false); err != nil {
315 t.Fatal(err)
316 }
317 if f.org, err = s.CreateOrg("acme", f.alice); err != nil {
318 t.Fatal(err)
319 }
320 mk := func(kind string, owner int64, name string) Repo {
321 id, err := s.CreateRepo(kind, owner, name, "public")
322 if err != nil {
323 t.Fatal(err)
324 }
325 r, err := s.RepoByID(id)
326 if err != nil {
327 t.Fatal(err)
328 }
329 return r
330 }
331 f.core = mk("org", f.org, "core")
332 f.site = mk("org", f.org, "site")
333 f.app = mk("user", f.alice, "app")
334 if f.coreIssue, err = s.CreateIssue(f.core.ID, f.alice, "c1", "", "md"); err != nil {
335 t.Fatal(err)
336 }
337 if f.siteIssue, err = s.CreateIssue(f.site.ID, f.alice, "s1", "", "md"); err != nil {
338 t.Fatal(err)
339 }
340 return f
341}
342
343func (f acmeFixture) orgRepos() []int64 { return []int64{f.core.ID, f.site.ID} }
344
345func TestOrgLabelSeenByEveryOrgRepo(t *testing.T) {
346 f := newAcme(t)
347 if _, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000"); err != nil {
348 t.Fatal(err)
349 }
350 if err := f.s.SetLabel(f.site, "docs", ""); err != nil {
351 t.Fatal(err)
352 }
353 // site sees the org's bug first, then its own docs; core sees only bug;
354 // alice/app, user-owned, sees nothing.
355 got, err := f.s.ListLabels(f.site, f.orgRepos())
356 if err != nil || len(got) != 2 || got[0].Name != "bug" || !got[0].Org || got[1].Name != "docs" || got[1].Org {
357 t.Fatalf("site labels = %+v, %v", got, err)
358 }
359 if got, _ := f.s.ListLabels(f.core, f.orgRepos()); len(got) != 1 || got[0].Name != "bug" {
360 t.Fatalf("core labels = %+v", got)
361 }
362 if got, _ := f.s.ListLabels(f.app, []int64{f.app.ID}); len(got) != 0 {
363 t.Fatalf("app labels = %+v", got)
364 }
365 colors, _ := f.s.LabelColors(f.core)
366 if colors["bug"] != "#ff0000" {
367 t.Fatalf("core colours = %v", colors)
368 }
369}
370
371func TestIssueLabelResolvesOrgRowFirst(t *testing.T) {
372 f := newAcme(t)
373 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
374 t.Fatal(err)
375 }
376 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil {
377 t.Fatal(err)
378 }
379 if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil {
380 t.Fatal(err)
381 }
382 // One org row, no repo rows were created on the fly.
383 var n int
384 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n)
385 if n != 1 {
386 t.Fatalf("labels named bug: %d, want 1", n)
387 }
388 // The count spans the org's readable repos.
389 got, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
390 if len(got) != 1 || got[0].Issues != 2 {
391 t.Fatalf("org labels = %+v", got)
392 }
393 got, _ = f.s.ListOrgLabels(f.org, []int64{f.core.ID})
394 if got[0].Issues != 1 {
395 t.Fatalf("org labels over core only = %+v", got)
396 }
397 // A label neither scope has is still created on the fly in the repo.
398 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "adhoc", true); err != nil {
399 t.Fatal(err)
400 }
401 if l, err := f.s.LabelByName(f.core, "adhoc"); err != nil || l.Org {
402 t.Fatalf("adhoc = %+v, %v", l, err)
403 }
404 // Removing by name works for the org row too.
405 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", false); err != nil {
406 t.Fatal(err)
407 }
408 got, _ = f.s.ListOrgLabels(f.org, f.orgRepos())
409 if got[0].Issues != 1 {
410 t.Fatalf("after detach: %+v", got)
411 }
412}
413
414func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
415 f := newAcme(t)
416 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
417 t.Fatal(err)
418 }
419 if err := f.s.SetLabel(f.core, "bug", "#00ff00"); !errors.Is(err, ErrOrgScoped) {
420 t.Fatalf("SetLabel over org name: %v, want ErrOrgScoped", err)
421 }
422 if err := f.s.DeleteLabel(f.core, "bug"); !errors.Is(err, ErrOrgScoped) {
423 t.Fatalf("DeleteLabel of org row: %v, want ErrOrgScoped", err)
424 }
425 if err := f.s.DeleteLabel(f.core, "nope"); !errors.Is(err, ErrNotFound) {
426 t.Fatalf("DeleteLabel of nothing: %v, want ErrNotFound", err)
427 }
428 // A user-owned repo is unaffected by any org.
429 if err := f.s.SetLabel(f.app, "bug", ""); err != nil {
430 t.Fatal(err)
431 }
432}
433
434func TestSetOrgLabelPromotesRepoLabels(t *testing.T) {
435 f := newAcme(t)
436 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil {
437 t.Fatal(err)
438 }
439 if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil {
440 t.Fatal(err)
441 }
442 if err := f.s.SetLabel(f.app, "bug", "#123456"); err != nil {
443 t.Fatal(err)
444 }
445 folded, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000")
446 if err != nil || folded != 2 {
447 t.Fatalf("SetOrgLabel folded %d, %v; want 2", folded, err)
448 }
449 var n int
450 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND org_id = ?", f.org).Scan(&n)
451 if n != 1 {
452 t.Fatalf("org rows named bug: %d", n)
453 }
454 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND repo_id IN (?, ?)", f.core.ID, f.site.ID).Scan(&n)
455 if n != 0 {
456 t.Fatalf("repo rows named bug left under the org: %d", n)
457 }
458 got, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
459 if len(got) != 1 || got[0].Issues != 2 || got[0].Color != "#ff0000" {
460 t.Fatalf("after promote: %+v", got)
461 }
462 // alice/app's own bug is another owner's and stays.
463 if l, err := f.s.LabelByName(f.app, "bug"); err != nil || l.Color != "#123456" {
464 t.Fatalf("app bug = %+v, %v", l, err)
465 }
466 // A second set only recolours.
467 if folded, err := f.s.SetOrgLabel(f.org, "bug", "#0000ff"); err != nil || folded != 0 {
468 t.Fatalf("second set folded %d, %v", folded, err)
469 }
470 if err := f.s.DeleteOrgLabel(f.org, "bug"); err != nil {
471 t.Fatal(err)
472 }
473 if err := f.s.DeleteOrgLabel(f.org, "bug"); !errors.Is(err, ErrNotFound) {
474 t.Fatalf("second delete: %v", err)
475 }
476 f.s.DB.QueryRow("SELECT COUNT(*) FROM issue_labels").Scan(&n)
477 if n != 0 {
478 t.Fatalf("memberships after org delete: %d", n)
479 }
480}
481```
482
483- [ ] **Step 2: Run them to verify they fail**
484
485Run: `go test ./internal/store/ -run 'OrgLabel|IssueLabelResolves|RepoLabelRefused' -v`
486Expected: build failure, `SetOrgLabel`, `ListOrgLabels`, `LabelByName`, `DeleteOrgLabel`, `ErrOrgScoped` undefined.
487
488- [ ] **Step 3: Write `scope.go`**
489
490```go
491package store
492
493import (
494 "errors"
495 "strings"
496)
497
498// ErrOrgScoped is returned when a repository-level write names a label or
499// milestone its org holds; the org commands manage those.
500var ErrOrgScoped = errors.New("held by the org")
501
502// scopeClause selects the label or milestone rows a repository sees: its
503// own, and its org's when an org owns it. alias is the table alias in the
504// query.
505func scopeClause(alias string, repo Repo) (string, []any) {
506 if repo.OwnerKind == "org" {
507 return "(" + alias + ".repo_id = ? OR " + alias + ".org_id = ?)", []any{repo.ID, repo.OwnerID}
508 }
509 return alias + ".repo_id = ?", []any{repo.ID}
510}
511
512// inClause renders ids as a parenthesised placeholder list. An empty set
513// yields (NULL), which matches nothing.
514func inClause(ids []int64) (string, []any) {
515 if len(ids) == 0 {
516 return "(NULL)", nil
517 }
518 args := make([]any, len(ids))
519 for i, id := range ids {
520 args[i] = id
521 }
522 return "(" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")", args
523}
524```
525
526- [ ] **Step 4: Rewrite `labels.go` below the struct**
527
528```go
529// labelRows lists labels under where, with use counted over the issues of
530// the readable repositories only, so a private repository's issues do not
531// show in a count someone outside it can see.
532func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
533 in, inArgs := inClause(readable)
534 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
535 (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id
536 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `)
537 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name`
538 rows, err := s.DB.Query(q, append(inArgs, args...)...)
539 if err != nil {
540 return nil, err
541 }
542 defer rows.Close()
543 var out []Label
544 for rows.Next() {
545 var l Label
546 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil {
547 return nil, err
548 }
549 out = append(out, l)
550 }
551 return out, rows.Err()
552}
553
554// ListLabels lists the labels a repository sees: its org's first, then its
555// own, each by name.
556func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
557 where, args := scopeClause("l", repo)
558 return s.labelRows(where, args, readable)
559}
560
561// ListOrgLabels lists an org's labels.
562func (s *Store) ListOrgLabels(orgID int64, readable []int64) ([]Label, error) {
563 return s.labelRows("l.org_id = ?", []any{orgID}, readable)
564}
565
566// LabelByName resolves a name the way attaching does: the org's row when
567// the org has it, else the repository's.
568func (s *Store) LabelByName(repo Repo, name string) (Label, error) {
569 where, args := scopeClause("l", repo)
570 var l Label
571 err := s.DB.QueryRow(`SELECT l.name, l.color, l.org_id IS NOT NULL FROM labels l
572 WHERE `+where+` AND l.name = ? ORDER BY l.org_id IS NULL LIMIT 1`,
573 append(args, name)...).Scan(&l.Name, &l.Color, &l.Org)
574 if errors.Is(err, sql.ErrNoRows) {
575 return l, ErrNotFound
576 }
577 return l, err
578}
579
580// orgHoldsLabel reports whether the repository's org has a label of that
581// name; always false for a user-owned repository.
582func orgHoldsLabel(q interface {
583 QueryRow(string, ...any) *sql.Row
584}, repo Repo, name string) (bool, error) {
585 if repo.OwnerKind != "org" {
586 return false, nil
587 }
588 var n int
589 err := q.QueryRow("SELECT COUNT(*) FROM labels WHERE org_id = ? AND name = ?", repo.OwnerID, name).Scan(&n)
590 return n > 0, err
591}
592
593// SetLabel creates the repository's label or sets its colour. A name the
594// org holds is refused with ErrOrgScoped.
595func (s *Store) SetLabel(repo Repo, name, color string) error {
596 if held, err := orgHoldsLabel(s.DB, repo, name); err != nil || held {
597 if err != nil {
598 return err
599 }
600 return ErrOrgScoped
601 }
602 _, err := s.DB.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?)
603 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO UPDATE SET color = excluded.color`,
604 repo.ID, name, color)
605 return err
606}
607
608// DeleteLabel removes the repository's label and takes it off every issue.
609// An org's label is ErrOrgScoped; no label at all is ErrNotFound.
610func (s *Store) DeleteLabel(repo Repo, name string) error {
611 res, err := s.DB.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repo.ID, name)
612 if err != nil {
613 return err
614 }
615 if n, _ := res.RowsAffected(); n > 0 {
616 return nil
617 }
618 if held, err := orgHoldsLabel(s.DB, repo, name); err != nil || held {
619 if err != nil {
620 return err
621 }
622 return ErrOrgScoped
623 }
624 return ErrNotFound
625}
626
627// SetOrgLabel creates the org's label or sets its colour. Repositories
628// under the org that hold the name are folded in: their issues move to
629// the org's row and their rows go. folded is how many were.
630func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
631 tx, err := s.DB.Begin()
632 if err != nil {
633 return 0, err
634 }
635 defer tx.Rollback()
636 if _, err := tx.Exec(`INSERT INTO labels (org_id, name, color) VALUES (?, ?, ?)
637 ON CONFLICT (org_id, name) WHERE org_id IS NOT NULL DO UPDATE SET color = excluded.color`,
638 orgID, name, color); err != nil {
639 return 0, err
640 }
641 var orgRow int64
642 if err := tx.QueryRow("SELECT id FROM labels WHERE org_id = ? AND name = ?", orgID, name).Scan(&orgRow); err != nil {
643 return 0, err
644 }
645 rows, err := tx.Query(`SELECT l.id FROM labels l JOIN repos r ON r.id = l.repo_id
646 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND l.name = ?`, orgID, name)
647 if err != nil {
648 return 0, err
649 }
650 var repoRows []int64
651 for rows.Next() {
652 var id int64
653 if err := rows.Scan(&id); err != nil {
654 rows.Close()
655 return 0, err
656 }
657 repoRows = append(repoRows, id)
658 }
659 rows.Close()
660 for _, id := range repoRows {
661 // OR IGNORE: an issue cannot carry both today, but the primary key
662 // makes the move safe if it ever did.
663 if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, id); err != nil {
664 return 0, err
665 }
666 if _, err := tx.Exec("DELETE FROM labels WHERE id = ?", id); err != nil {
667 return 0, err
668 }
669 }
670 return len(repoRows), tx.Commit()
671}
672
673// DeleteOrgLabel removes an org's label from the org and from every issue
674// under it.
675func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
676 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
677 if err != nil {
678 return err
679 }
680 if n, _ := res.RowsAffected(); n == 0 {
681 return ErrNotFound
682 }
683 return nil
684}
685```
686
687Add `"database/sql"` and `"errors"` to the imports of `labels.go`.
688
689- [ ] **Step 5: Update `LabelColors` and `SetIssueLabel` in `issues.go`**
690
691Replace both functions (lines 297-346):
692
693```go
694// LabelColors returns the colours of the labels a repository sees, keyed
695// by name. Labels with no stored colour map to "".
696func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
697 where, args := scopeClause("l", repo)
698 rows, err := s.DB.Query("SELECT l.name, l.color FROM labels l WHERE "+where, args...)
699 if err != nil {
700 return nil, err
701 }
702 defer rows.Close()
703 out := map[string]string{}
704 for rows.Next() {
705 var name, color string
706 if err := rows.Scan(&name, &color); err != nil {
707 return nil, err
708 }
709 out[name] = color
710 }
711 return out, rows.Err()
712}
713
714// SetIssueLabel attaches (add) or detaches a label by name. Adding
715// resolves the org's row when the org has the name, else the repository's,
716// creating that on first use.
717func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
718 tx, err := s.DB.Begin()
719 if err != nil {
720 return err
721 }
722 defer tx.Rollback()
723 where, args := scopeClause("l", repo)
724 if add {
725 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
726 return err
727 } else if !held {
728 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
729 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
730 return err
731 }
732 }
733 if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
734 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
735 ORDER BY l.org_id IS NULL LIMIT 1
736 ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
737 return err
738 }
739 } else {
740 res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
741 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
742 append(append([]any{issueID}, args...), name)...)
743 if err != nil {
744 return err
745 }
746 if n, _ := res.RowsAffected(); n == 0 {
747 return fmt.Errorf("label %q: %w", name, ErrNotFound)
748 }
749 }
750 return tx.Commit()
751}
752```
753
754SQLite needs a `WHERE` before `ON CONFLICT` after an `INSERT ... SELECT` to disambiguate; the `SELECT` above has one, so the upsert parses. If the parser rejects `ORDER BY ... LIMIT` before `ON CONFLICT`, wrap the select: `SELECT * FROM (SELECT ?, l.id FROM labels l WHERE ... ORDER BY l.org_id IS NULL LIMIT 1) WHERE true ON CONFLICT DO NOTHING`.
755
756- [ ] **Step 6: Run the store tests**
757
758Run: `go test ./internal/store/`
759Expected: the four new tests PASS; existing store tests still PASS. `go build ./...` now fails in `control` and `httpd` on the changed signatures, which Task 4 fixes. Do not fix them here.
760
761- [ ] **Step 7: Commit**
762
763```bash
764git add internal/store/scope.go internal/store/labels.go internal/store/issues.go internal/store/labels_test.go
765git commit -m "store: labels resolve through the repository's org
766
767Ref #203"
768```
769
770---
771
772### Task 3: Store: milestones by scope, org milestones, promote
773
774**Files:**
775- Modify: `internal/store/milestones.go`
776- Test: `internal/store/milestones_test.go` (new)
777
778**Interfaces:**
779- Produces:
780 - `func (s *Store) CreateMilestone(repo Repo, title, description, due string) (int64, error)` — `ErrOrgScoped` when the org holds the title; "already exists" error on a repo duplicate as today.
781 - `func (s *Store) MilestoneByTitle(repo Repo, title string) (Milestone, error)` — org row first.
782 - `func (s *Store) ListMilestones(repo Repo, state string, readable []int64) ([]Milestone, error)` — org rows first.
783 - `func (s *Store) CreateOrgMilestone(orgID int64, title, description, due string) (id int64, folded int, err error)`
784 - `func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error)`
785 - `func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error)`
786 - `SetMilestoneState`, `SetIssueMilestone`, `SetMRMilestone` unchanged.
787- Consumes: `scopeClause`, `inClause`, `ErrOrgScoped` from Task 2; `Milestone.OrgID` from Task 1.
788
789- [ ] **Step 1: Write the failing tests**
790
791`internal/store/milestones_test.go`:
792
793```go
794package store
795
796import (
797 "errors"
798 "testing"
799)
800
801func TestOrgMilestoneSpansRepos(t *testing.T) {
802 f := newAcme(t)
803 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "first", "2027-01-01")
804 if err != nil || folded != 0 || id == 0 {
805 t.Fatalf("CreateOrgMilestone: %d, %d, %v", id, folded, err)
806 }
807 // Resolves from either repo, not from alice/app.
808 m, err := f.s.MilestoneByTitle(f.core, "v1")
809 if err != nil || m.OrgID != f.org || m.RepoID != 0 {
810 t.Fatalf("core resolves v1 = %+v, %v", m, err)
811 }
812 if _, err := f.s.MilestoneByTitle(f.app, "v1"); !errors.Is(err, ErrNotFound) {
813 t.Fatalf("app resolves v1: %v", err)
814 }
815 if err := f.s.SetIssueMilestone(f.coreIssue, id); err != nil {
816 t.Fatal(err)
817 }
818 if err := f.s.SetIssueMilestone(f.siteIssue, id); err != nil {
819 t.Fatal(err)
820 }
821 if err := f.s.SetIssueState(f.siteIssue, "closed"); err != nil {
822 t.Fatal(err)
823 }
824 ms, err := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
825 if err != nil || len(ms) != 1 || ms[0].OpenItems != 1 || ms[0].ClosedItems != 1 {
826 t.Fatalf("org list = %+v, %v", ms, err)
827 }
828 // Counts stop at what the caller can read.
829 ms, _ = f.s.ListOrgMilestones(f.org, "open", []int64{f.core.ID})
830 if ms[0].OpenItems != 1 || ms[0].ClosedItems != 0 {
831 t.Fatalf("org list over core = %+v", ms)
832 }
833 // A repo's list shows the org milestone first, then its own.
834 if _, err := f.s.CreateMilestone(f.core, "core-only", "", ""); err != nil {
835 t.Fatal(err)
836 }
837 ms, _ = f.s.ListMilestones(f.core, "open", f.orgRepos())
838 if len(ms) != 2 || ms[0].Title != "v1" || ms[0].OrgID != f.org || ms[1].Title != "core-only" || ms[1].RepoID != f.core.ID {
839 t.Fatalf("core list = %+v", ms)
840 }
841 if _, err := f.s.OrgMilestoneByTitle(f.org, "core-only"); !errors.Is(err, ErrNotFound) {
842 t.Fatalf("org resolves a repo milestone: %v", err)
843 }
844}
845
846func TestRepoMilestoneRefusedWhenOrgHoldsTitle(t *testing.T) {
847 f := newAcme(t)
848 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
849 t.Fatal(err)
850 }
851 if _, err := f.s.CreateMilestone(f.core, "v1", "", ""); !errors.Is(err, ErrOrgScoped) {
852 t.Fatalf("CreateMilestone over org title: %v", err)
853 }
854 if _, err := f.s.CreateMilestone(f.app, "v1", "", ""); err != nil {
855 t.Fatalf("user repo unaffected: %v", err)
856 }
857 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err == nil {
858 t.Fatal("duplicate org milestone accepted")
859 }
860}
861
862func TestCreateOrgMilestonePromotes(t *testing.T) {
863 f := newAcme(t)
864 cid, err := f.s.CreateMilestone(f.core, "v1", "", "")
865 if err != nil {
866 t.Fatal(err)
867 }
868 sid, err := f.s.CreateMilestone(f.site, "v1", "", "")
869 if err != nil {
870 t.Fatal(err)
871 }
872 if err := f.s.SetIssueMilestone(f.coreIssue, cid); err != nil {
873 t.Fatal(err)
874 }
875 mrID, err := f.s.CreateMR(f.site.ID, f.alice, f.site.ID, "feat", "main", "t", "", "abc", "md", false)
876 if err != nil {
877 t.Fatal(err)
878 }
879 if err := f.s.SetMRMilestone(mrID, sid); err != nil {
880 t.Fatal(err)
881 }
882 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "org wide", "2027-06-01")
883 if err != nil || folded != 2 {
884 t.Fatalf("promote: folded %d, %v", folded, err)
885 }
886 var n int
887 f.s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE title = 'v1'").Scan(&n)
888 if n != 1 {
889 t.Fatalf("milestones named v1: %d", n)
890 }
891 f.s.DB.QueryRow("SELECT COUNT(*) FROM issues WHERE milestone_id = ?", id).Scan(&n)
892 if n != 1 {
893 t.Fatalf("issues on org milestone: %d", n)
894 }
895 f.s.DB.QueryRow("SELECT COUNT(*) FROM merge_requests WHERE milestone_id = ?", id).Scan(&n)
896 if n != 1 {
897 t.Fatalf("mrs on org milestone: %d", n)
898 }
899 ms, _ := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
900 if len(ms) != 1 || ms[0].OpenItems != 2 || ms[0].Description != "org wide" || ms[0].DueDate != "2027-06-01" {
901 t.Fatalf("after promote: %+v", ms)
902 }
903}
904```
905
906- [ ] **Step 2: Run them to verify they fail**
907
908Run: `go test ./internal/store/ -run 'OrgMilestone|RepoMilestoneRefused' -v`
909Expected: build failure, `CreateOrgMilestone` and friends undefined.
910
911- [ ] **Step 3: Rewrite `milestones.go` from `CreateMilestone` through `ListMilestones`**
912
913```go
914// orgHoldsMilestone reports whether the repository's org has a milestone
915// of that title; always false for a user-owned repository.
916func (s *Store) orgHoldsMilestone(repo Repo, title string) (bool, error) {
917 if repo.OwnerKind != "org" {
918 return false, nil
919 }
920 var n int
921 err := s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE org_id = ? AND title = ?", repo.OwnerID, title).Scan(&n)
922 return n > 0, err
923}
924
925// CreateMilestone creates the repository's milestone. A title the org
926// holds is refused with ErrOrgScoped.
927func (s *Store) CreateMilestone(repo Repo, title, description, due string) (int64, error) {
928 if held, err := s.orgHoldsMilestone(repo, title); err != nil || held {
929 if err != nil {
930 return 0, err
931 }
932 return 0, ErrOrgScoped
933 }
934 res, err := s.DB.Exec(
935 "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)",
936 repo.ID, title, description, due)
937 if err != nil {
938 if isUniqueErr(err) {
939 return 0, fmt.Errorf("milestone %q already exists", title)
940 }
941 return 0, err
942 }
943 return res.LastInsertId()
944}
945
946// CreateOrgMilestone creates the org's milestone. Repositories under the
947// org that hold the title are folded in: their issues and merge requests
948// move to the org's row and their rows go. folded is how many were.
949func (s *Store) CreateOrgMilestone(orgID int64, title, description, due string) (int64, int, error) {
950 tx, err := s.DB.Begin()
951 if err != nil {
952 return 0, 0, err
953 }
954 defer tx.Rollback()
955 res, err := tx.Exec(
956 "INSERT INTO milestones (org_id, title, description, due_date) VALUES (?, ?, ?, ?)",
957 orgID, title, description, due)
958 if err != nil {
959 if isUniqueErr(err) {
960 return 0, 0, fmt.Errorf("milestone %q already exists", title)
961 }
962 return 0, 0, err
963 }
964 id, err := res.LastInsertId()
965 if err != nil {
966 return 0, 0, err
967 }
968 rows, err := tx.Query(`SELECT m.id FROM milestones m JOIN repos r ON r.id = m.repo_id
969 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND m.title = ?`, orgID, title)
970 if err != nil {
971 return 0, 0, err
972 }
973 var repoRows []int64
974 for rows.Next() {
975 var rid int64
976 if err := rows.Scan(&rid); err != nil {
977 rows.Close()
978 return 0, 0, err
979 }
980 repoRows = append(repoRows, rid)
981 }
982 rows.Close()
983 for _, rid := range repoRows {
984 for _, table := range []string{"issues", "merge_requests"} {
985 if _, err := tx.Exec("UPDATE "+table+" SET milestone_id = ? WHERE milestone_id = ?", id, rid); err != nil {
986 return 0, 0, err
987 }
988 }
989 if _, err := tx.Exec("DELETE FROM milestones WHERE id = ?", rid); err != nil {
990 return 0, 0, err
991 }
992 }
993 return id, len(repoRows), tx.Commit()
994}
995
996// milestoneQuery selects milestones with their progress, counting only
997// items in the readable repositories. Its args come first in any query
998// built on it.
999func milestoneQuery(readable []int64) (string, []any) {
1000 in, args := inClause(readable)
1001 q := `
1002 SELECT m.id, COALESCE(m.repo_id, 0), COALESCE(m.org_id, 0), m.title, m.description, m.due_date, m.state, m.created_at,
1003 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open' AND i.repo_id IN ` + in + `)
1004 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone') AND r.repo_id IN ` + in + `),
1005 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed' AND i.repo_id IN ` + in + `)
1006 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed') AND r.repo_id IN ` + in + `)
1007 FROM milestones m`
1008 all := make([]any, 0, 4*len(args))
1009 for i := 0; i < 4; i++ {
1010 all = append(all, args...)
1011 }
1012 return q, all
1013}
1014
1015func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
1016 var m Milestone
1017 err := row.Scan(&m.ID, &m.RepoID, &m.OrgID, &m.Title, &m.Description, &m.DueDate, &m.State,
1018 &m.CreatedAt, &m.OpenItems, &m.ClosedItems)
1019 return m, err
1020}
1021
1022// milestoneByTitle resolves a title under where. The org's row comes
1023// first when both scopes are in play; creation keeps that from happening.
1024func (s *Store) milestoneByTitle(where string, args []any) (Milestone, error) {
1025 q, qargs := milestoneQuery(nil)
1026 m, err := scanMilestone(s.DB.QueryRow(q+" WHERE "+where+" ORDER BY m.org_id IS NULL LIMIT 1", append(qargs, args...)...))
1027 if errors.Is(err, sql.ErrNoRows) {
1028 return m, ErrNotFound
1029 }
1030 return m, err
1031}
1032
1033// MilestoneByTitle resolves a title the way attaching does: the org's
1034// milestone when the org has it, else the repository's. Progress counts
1035// are not populated here; list for those.
1036func (s *Store) MilestoneByTitle(repo Repo, title string) (Milestone, error) {
1037 where, args := scopeClause("m", repo)
1038 return s.milestoneByTitle(where+" AND m.title = ?", append(args, title))
1039}
1040
1041func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error) {
1042 return s.milestoneByTitle("m.org_id = ? AND m.title = ?", []any{orgID, title})
1043}
1044
1045func (s *Store) listMilestones(where string, args []any, state string, readable []int64) ([]Milestone, error) {
1046 q, qargs := milestoneQuery(readable)
1047 q += " WHERE " + where
1048 qargs = append(qargs, args...)
1049 if state != "all" {
1050 q += " AND m.state = ?"
1051 qargs = append(qargs, state)
1052 }
1053 q += " ORDER BY m.org_id IS NULL, m.due_date = '', m.due_date, m.title"
1054 rows, err := s.DB.Query(q, qargs...)
1055 if err != nil {
1056 return nil, err
1057 }
1058 defer rows.Close()
1059 var out []Milestone
1060 for rows.Next() {
1061 m, err := scanMilestone(rows)
1062 if err != nil {
1063 return nil, err
1064 }
1065 out = append(out, m)
1066 }
1067 return out, rows.Err()
1068}
1069
1070// ListMilestones lists the milestones a repository sees, the org's first,
1071// with progress counted over the readable repositories.
1072func (s *Store) ListMilestones(repo Repo, state string, readable []int64) ([]Milestone, error) {
1073 where, args := scopeClause("m", repo)
1074 return s.listMilestones(where, args, state, readable)
1075}
1076
1077// ListOrgMilestones lists an org's milestones with progress across the
1078// readable repositories under it.
1079func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error) {
1080 return s.listMilestones("m.org_id = ?", []any{orgID}, state, readable)
1081}
1082```
1083
1084Delete the old `milestoneSelect` constant. Keep `SetMilestoneState`, `SetIssueMilestone`, `SetMRMilestone`, `setItemMilestone` as they are.
1085
1086- [ ] **Step 4: Run the store tests**
1087
1088Run: `go test ./internal/store/`
1089Expected: all PASS, including `TestMigration0052...` and the label tests.
1090
1091- [ ] **Step 5: Commit**
1092
1093```bash
1094git add internal/store/milestones.go internal/store/milestones_test.go
1095git commit -m "store: milestones resolve through the repository's org
1096
1097Ref #203"
1098```
1099
1100---
1101
1102### Task 4: Callers compile; repo-level refusals; readable-scope helper
1103
1104**Files:**
1105- Create: `internal/control/scope.go`
1106- Modify: `internal/control/label.go:33-119`
1107- Modify: `internal/control/milestone.go:44-66, 68-88, 118-141, 181-190`
1108- Modify: `internal/control/issue.go:391, 396`
1109- Modify: `internal/control/ghimport.go:259`
1110- Modify: `internal/control/migrate.go:250`
1111- Modify: `internal/httpd/labels.go:20, 31`
1112- Modify: `internal/httpd/web.go:705, 1606-1618, 1675, 1700, 1713`
1113- Test: `internal/control/orgscope_test.go` (new)
1114
1115**Interfaces:**
1116- Produces in `internal/control/scope.go`:
1117 - `func ReadableOrgRepoIDs(st *store.Store, user store.User, orgID int64) ([]int64, error)` — ids of the org's repositories `user` can read (`policy.CanRead` with `AccessRole`; user with ID 0 is anonymous).
1118 - `func ReadableScope(st *store.Store, user store.User, repo store.Repo) ([]int64, error)` — `ReadableOrgRepoIDs` for an org-owned repo, `[]int64{repo.ID}` otherwise.
1119 - `func orgScopedMsg(repo store.Repo, noun, name, cmd string) string` — the refusal text: `"%s is an org %s of %s; manage it with org %s %s %s"`.
1120- Consumes: Task 2 and Task 3 signatures.
1121
1122- [ ] **Step 1: Write the failing tests**
1123
1124`internal/control/orgscope_test.go`:
1125
1126```go
1127package control
1128
1129import (
1130 "bytes"
1131 "strings"
1132 "testing"
1133
1134 "gitbay.org/gitbay/internal/config"
1135 "gitbay.org/gitbay/internal/protocol"
1136 "gitbay.org/gitbay/internal/store"
1137)
1138
1139// orgFixture: alice admins org acme with acme/core (public) and acme/priv
1140// (private); bob is a plain member; carol is outside. alice also owns
1141// alice/app.
1142type orgFixture struct {
1143 st *store.Store
1144 alice, bob, carol int64
1145 org int64
1146 core, priv, app store.Repo
1147}
1148
1149func newOrgFixture(t *testing.T) orgFixture {
1150 t.Helper()
1151 st, err := store.Open(":memory:")
1152 if err != nil {
1153 t.Fatal(err)
1154 }
1155 t.Cleanup(func() { st.Close() })
1156 if err := st.MigrateUp(); err != nil {
1157 t.Fatal(err)
1158 }
1159 var f orgFixture
1160 f.st = st
1161 user := func(name string) int64 {
1162 id, err := st.CreateUser(name, false)
1163 if err != nil {
1164 t.Fatal(err)
1165 }
1166 return id
1167 }
1168 f.alice, f.bob, f.carol = user("alice"), user("bob"), user("carol")
1169 if f.org, err = st.CreateOrg("acme", f.alice); err != nil {
1170 t.Fatal(err)
1171 }
1172 if err := st.SetOrgMember(f.org, f.bob, "member"); err != nil {
1173 t.Fatal(err)
1174 }
1175 repo := func(kind string, owner int64, name, vis string) store.Repo {
1176 id, err := st.CreateRepo(kind, owner, name, vis)
1177 if err != nil {
1178 t.Fatal(err)
1179 }
1180 r, _ := st.RepoByID(id)
1181 return r
1182 }
1183 f.core = repo("org", f.org, "core", "public")
1184 f.priv = repo("org", f.org, "priv", "private")
1185 f.app = repo("user", f.alice, "app", "public")
1186 return f
1187}
1188
1189func (f orgFixture) ctx(uid int64) (*Ctx, *bytes.Buffer) {
1190 var out bytes.Buffer
1191 name := map[int64]string{f.alice: "alice", f.bob: "bob", f.carol: "carol"}[uid]
1192 return &Ctx{
1193 User: store.User{ID: uid, Username: name},
1194 Scope: "full",
1195 Source: "SHA256:session",
1196 Store: f.st,
1197 Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}},
1198 Stdin: strings.NewReader(""),
1199 Stdout: &out,
1200 Stderr: &out,
1201 JSON: true,
1202 }, &out
1203}
1204
1205func TestReadableOrgRepoIDs(t *testing.T) {
1206 f := newOrgFixture(t)
1207 ids, err := ReadableOrgRepoIDs(f.st, store.User{ID: f.bob, Username: "bob"}, f.org)
1208 if err != nil || len(ids) != 2 {
1209 t.Fatalf("member reads %v, %v; want both", ids, err)
1210 }
1211 ids, _ = ReadableOrgRepoIDs(f.st, store.User{ID: f.carol, Username: "carol"}, f.org)
1212 if len(ids) != 1 || ids[0] != f.core.ID {
1213 t.Fatalf("outsider reads %v; want core only", ids)
1214 }
1215 ids, _ = ReadableOrgRepoIDs(f.st, store.User{}, f.org)
1216 if len(ids) != 1 || ids[0] != f.core.ID {
1217 t.Fatalf("anonymous reads %v; want core only", ids)
1218 }
1219 ids, _ = ReadableScope(f.st, store.User{ID: f.alice, Username: "alice"}, f.app)
1220 if len(ids) != 1 || ids[0] != f.app.ID {
1221 t.Fatalf("user repo scope %v; want itself", ids)
1222 }
1223}
1224
1225func TestRepoLabelCommandsRefuseOrgNames(t *testing.T) {
1226 f := newOrgFixture(t)
1227 if _, err := f.st.SetOrgLabel(f.org, "bug", ""); err != nil {
1228 t.Fatal(err)
1229 }
1230 c, out := f.ctx(f.alice)
1231 if code := runLabelSet(c, []string{"acme/core", "bug", "--color", "ff0000"}); code != protocol.ExitFailure ||
1232 !strings.Contains(out.String(), "org label set acme bug") {
1233 t.Fatalf("label set over org name: exit %d %s", code, out.String())
1234 }
1235 out.Reset()
1236 if code := runLabelRemove(c, []string{"acme/core", "bug"}); code != protocol.ExitFailure ||
1237 !strings.Contains(out.String(), "org label remove acme bug") {
1238 t.Fatalf("label remove of org row: exit %d %s", code, out.String())
1239 }
1240 out.Reset()
1241 // issue label --add resolves to the org row, and label list marks it.
1242 iid, _ := f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
1243 _ = iid
1244 if code := runIssueLabel(c, []string{"acme/core", "1", "--add", "bug"}); code != protocol.ExitOK {
1245 t.Fatalf("issue label: exit %d %s", code, out.String())
1246 }
1247 out.Reset()
1248 if code := runLabelList(c, []string{"acme/core"}); code != protocol.ExitOK ||
1249 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"issues":1`) {
1250 t.Fatalf("label list: exit %d %s", code, out.String())
1251 }
1252}
1253
1254func TestRepoMilestoneCommandsRefuseOrgTitles(t *testing.T) {
1255 f := newOrgFixture(t)
1256 if _, _, err := f.st.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
1257 t.Fatal(err)
1258 }
1259 c, out := f.ctx(f.alice)
1260 if code := runMilestoneCreate(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
1261 !strings.Contains(out.String(), "org milestone create acme v1") {
1262 t.Fatalf("milestone create over org title: exit %d %s", code, out.String())
1263 }
1264 out.Reset()
1265 if code := runMilestoneClose(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
1266 !strings.Contains(out.String(), "org milestone close acme v1") {
1267 t.Fatalf("milestone close of org row: exit %d %s", code, out.String())
1268 }
1269 out.Reset()
1270 // Attaching by title from a repo resolves the org milestone.
1271 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
1272 if code := runIssueMilestone(c, []string{"acme/core", "1", "v1"}); code != protocol.ExitOK {
1273 t.Fatalf("issue milestone: exit %d %s", code, out.String())
1274 }
1275 out.Reset()
1276 if code := runMilestoneList(c, []string{"acme/core"}); code != protocol.ExitOK ||
1277 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"open":1`) {
1278 t.Fatalf("milestone list: exit %d %s", code, out.String())
1279 }
1280}
1281```
1282
1283- [ ] **Step 2: Run them to verify they fail**
1284
1285Run: `go test ./internal/control/ -run 'ReadableOrgRepoIDs|RefuseOrg' -v`
1286Expected: build failure (`ReadableOrgRepoIDs` undefined, plus the package does not compile against Task 2/3 signatures yet).
1287
1288- [ ] **Step 3: Write `internal/control/scope.go`**
1289
1290```go
1291package control
1292
1293import (
1294 "fmt"
1295
1296 "gitbay.org/gitbay/internal/policy"
1297 "gitbay.org/gitbay/internal/store"
1298)
1299
1300// ReadableOrgRepoIDs is the org's repositories user may read. Counts on
1301// org labels and milestones are taken over these, so a private
1302// repository's issues never show in a number someone outside it sees. A
1303// zero user is anonymous.
1304func ReadableOrgRepoIDs(st *store.Store, user store.User, orgID int64) ([]int64, error) {
1305 repos, err := st.ListReposForOwner("org", orgID)
1306 if err != nil {
1307 return nil, err
1308 }
1309 var ids []int64
1310 for _, r := range repos {
1311 grant := ""
1312 if user.ID != 0 {
1313 if grant, err = st.AccessRole(r.ID, user.ID); err != nil {
1314 return nil, err
1315 }
1316 }
1317 if policy.CanRead(user, r, grant) {
1318 ids = append(ids, r.ID)
1319 }
1320 }
1321 return ids, nil
1322}
1323
1324// ReadableScope is the set a repository's label and milestone counts
1325// span: its org's readable repositories, or just itself when a user owns
1326// it. The caller has already been allowed to read repo.
1327func ReadableScope(st *store.Store, user store.User, repo store.Repo) ([]int64, error) {
1328 if repo.OwnerKind == "org" {
1329 return ReadableOrgRepoIDs(st, user, repo.OwnerID)
1330 }
1331 return []int64{repo.ID}, nil
1332}
1333
1334// orgScopedMsg names the org command that manages a row a repository
1335// command was asked to change.
1336func orgScopedMsg(repo store.Repo, noun, name, verb string) string {
1337 return fmt.Sprintf("%s is an org %s of %s; manage it with org %s %s %s %s", name, noun, repo.OwnerName, noun, verb, repo.OwnerName, name)
1338}
1339```
1340
1341- [ ] **Step 4: Update `label.go`**
1342
1343In `runLabelList`, replace the `ListLabels` call:
1344
1345```go
1346 readable, err := ReadableScope(c.Store, c.User, repo)
1347 if err != nil {
1348 return c.fail(protocol.ExitFailure, "%v", err)
1349 }
1350 labels, err := c.Store.ListLabels(repo, readable)
1351```
1352
1353and print the mark in the plain output: `fmt.Fprintf(w, "%s\t%s\t%d%s\n", l.Name, l.Color, l.Issues, map[bool]string{true: "\torg"}[l.Org])`.
1354
1355In `runLabelSet`, replace the colour-keeping block and the store call:
1356
1357```go
1358 if !colorSet {
1359 // Keep the colour it has, if any; this is "make sure it exists".
1360 if l, err := c.Store.LabelByName(repo, name); err == nil && !l.Org {
1361 color = l.Color
1362 }
1363 }
1364 if err := c.Store.SetLabel(repo, name, color); err != nil {
1365 if errors.Is(err, store.ErrOrgScoped) {
1366 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", name, "set"))
1367 }
1368 return c.fail(protocol.ExitFailure, "%v", err)
1369 }
1370```
1371
1372In `runLabelRemove`:
1373
1374```go
1375 if err := c.Store.DeleteLabel(repo, args[1]); err != nil {
1376 if errors.Is(err, store.ErrOrgScoped) {
1377 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", args[1], "remove"))
1378 }
1379 if errors.Is(err, store.ErrNotFound) {
1380 return c.fail(protocol.ExitNotFound, "no label %q in %s", args[1], repo.Path())
1381 }
1382 return c.fail(protocol.ExitFailure, "%v", err)
1383 }
1384```
1385
1386- [ ] **Step 5: Update `milestone.go`**
1387
1388`runMilestoneCreate`:
1389
1390```go
1391 if _, err := c.Store.CreateMilestone(repo, title, description, due); err != nil {
1392 if errors.Is(err, store.ErrOrgScoped) {
1393 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", title, "create"))
1394 }
1395 return c.failErr(err)
1396 }
1397```
1398
1399`runMilestoneList`: compute `readable` with `ReadableScope` as in label list, call `c.Store.ListMilestones(repo, state, readable)`, add `Org bool `json:"org,omitempty"`` to the `out` struct after `State`, fill it with `m.OrgID != 0`, and append `\torg` to the plain line when set.
1400
1401`setMilestoneState`, after `MilestoneByTitle(repo, args[1])`:
1402
1403```go
1404 if m.OrgID != 0 {
1405 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
1406 }
1407```
1408
1409`setItemMilestone`: `c.Store.MilestoneByTitle(repo, title)`.
1410
1411- [ ] **Step 6: Update the remaining callers**
1412
1413- `internal/control/issue.go:391,396`: `c.Store.SetIssueLabel(repo, issue.ID, l, true)` / `false`.
1414- `internal/control/ghimport.go:259` and `internal/control/migrate.go:250`: `SetIssueLabel(repo, iss.ID, ...)`. Check each has a `repo store.Repo` in scope; both do, it is what `repo.ID` came from.
1415- `internal/httpd/web.go:1606`: `func (s *Server) labelColors(repo store.Repo) map[string]template.CSS` with `s.st.LabelColors(repo)`; callers at 1675 and 1713 pass `p.Repo`. Split the colour derivation into `func colorStyles(stored map[string]string) map[string]template.CSS` (the loop body as it stands) so Task 8 can reuse it for the org page; `labelColors` becomes `stored, _ := s.st.LabelColors(repo); return colorStyles(stored)`.
1416- `internal/httpd/web.go:705`: `readable, _ := control.ReadableOrgRepoIDs(...)` is wrong for a repo page; use `readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)` then `s.st.ListMilestones(p.Repo, state, readable)`. Same at 1700 (`"open"`).
1417- `internal/httpd/labels.go:20`: `readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)` then `s.st.ListLabels(p.Repo, readable)`; line 31 `s.labelColors(p.Repo)`.
1418
1419`httpd` already imports `control`; `web.go` needs no new import.
1420
1421- [ ] **Step 7: Build, vet, test**
1422
1423Run: `go build ./... && go vet ./... && go test ./internal/control/ ./internal/httpd/ ./internal/store/`
1424Expected: all PASS. `TestReadableOrgRepoIDs`, `TestRepoLabelCommandsRefuseOrgNames`, `TestRepoMilestoneCommandsRefuseOrgTitles` PASS.
1425
1426- [ ] **Step 8: Commit**
1427
1428```bash
1429git add internal/control/scope.go internal/control/label.go internal/control/milestone.go internal/control/issue.go internal/control/ghimport.go internal/control/migrate.go internal/httpd/labels.go internal/httpd/web.go internal/control/orgscope_test.go
1430git commit -m "control, web: repository commands see org labels and milestones and refuse to change them
1431
1432Ref #203"
1433```
1434
1435---
1436
1437### Task 5: `org label set|list|remove`
1438
1439**Files:**
1440- Create: `internal/control/orglabel.go`
1441- Modify: `cmd/gitbay/main.go:642-670` (org group)
1442- Modify: `e2e/readonly_test.go:85+` (`readArgs`)
1443- Test: `internal/control/orglabel_test.go` (new)
1444
1445**Interfaces:**
1446- Produces: commands `org label set <org> <label> [--color rrggbb|'']`, `org label list <org>` (ReadOnly), `org label remove <org> <label>`; `runOrgLabelSet`, `runOrgLabelList`, `runOrgLabelRemove`.
1447- Produces: `func orgReader(c *Ctx, name string) (store.Org, []int64, int)` — resolves an org for a read: not-found when absent; members pass; an outsider passes only if some repository under it is readable, else `ExitDenied` "labels and milestones of %s are visible to its members". Returns the readable ids.
1448- Consumes: `orgAdmin` from `org.go`, `ReadableOrgRepoIDs` from Task 4, store functions from Task 2, `labelColorPat` from `label.go`.
1449
1450- [ ] **Step 1: Write the failing tests**
1451
1452`internal/control/orglabel_test.go`:
1453
1454```go
1455package control
1456
1457import (
1458 "strings"
1459 "testing"
1460
1461 "gitbay.org/gitbay/internal/protocol"
1462)
1463
1464func TestOrgLabelSetListRemove(t *testing.T) {
1465 f := newOrgFixture(t)
1466 // Two repos already hold bug; the org set folds them in.
1467 f.st.SetLabel(f.core, "bug", "")
1468 f.st.SetLabel(f.priv, "bug", "")
1469 c, out := f.ctx(f.alice)
1470 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "ff0000"}); code != protocol.ExitOK ||
1471 !strings.Contains(out.String(), `"folded":2`) {
1472 t.Fatalf("set: exit %d %s", code, out.String())
1473 }
1474 out.Reset()
1475 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK ||
1476 !strings.Contains(out.String(), `"name":"bug"`) || !strings.Contains(out.String(), `"color":"#ff0000"`) {
1477 t.Fatalf("list: exit %d %s", code, out.String())
1478 }
1479 out.Reset()
1480 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitOK {
1481 t.Fatalf("remove: exit %d %s", code, out.String())
1482 }
1483 out.Reset()
1484 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitNotFound {
1485 t.Fatalf("second remove: exit %d %s", code, out.String())
1486 }
1487}
1488
1489func TestOrgLabelWritesNeedOrgAdmin(t *testing.T) {
1490 f := newOrgFixture(t)
1491 c, out := f.ctx(f.bob)
1492 if code := runOrgLabelSet(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
1493 t.Fatalf("member set: exit %d %s", code, out.String())
1494 }
1495 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
1496 t.Fatalf("member remove: exit %d %s", code, out.String())
1497 }
1498 c, out = f.ctx(f.alice)
1499 if code := runOrgLabelSet(c, []string{"nope", "bug"}); code != protocol.ExitNotFound {
1500 t.Fatalf("missing org: exit %d %s", code, out.String())
1501 }
1502 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "zz"}); code != protocol.ExitUsage {
1503 t.Fatalf("bad colour: exit %d %s", code, out.String())
1504 }
1505}
1506
1507func TestOrgLabelListVisibility(t *testing.T) {
1508 f := newOrgFixture(t)
1509 f.st.SetOrgLabel(f.org, "bug", "")
1510 // Members read; an outsider reads because acme/core is public.
1511 for _, uid := range []int64{f.bob, f.carol} {
1512 c, out := f.ctx(uid)
1513 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK {
1514 t.Fatalf("user %d list: exit %d %s", uid, code, out.String())
1515 }
1516 }
1517 // With every repo private, the outsider is refused, not told the org
1518 // is missing.
1519 f.st.SetRepoVisibility(f.core.ID, "private")
1520 c, out := f.ctx(f.carol)
1521 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitDenied ||
1522 !strings.Contains(out.String(), "visible to its members") {
1523 t.Fatalf("outsider list: exit %d %s", code, out.String())
1524 }
1525}
1526```
1527
1528- [ ] **Step 2: Run them to verify they fail**
1529
1530Run: `go test ./internal/control/ -run OrgLabel -v`
1531Expected: build failure, `runOrgLabelSet` undefined.
1532
1533- [ ] **Step 3: Write `orglabel.go`**
1534
1535```go
1536package control
1537
1538import (
1539 "errors"
1540 "fmt"
1541 "io"
1542 "strings"
1543
1544 "gitbay.org/gitbay/internal/protocol"
1545 "gitbay.org/gitbay/internal/store"
1546)
1547
1548func init() {
1549 register(Command{Path: []string{"org", "label", "set"},
1550 Summary: "create an org label every org repository sees, or set its colour; folds in same-named repo labels",
1551 Usage: "org label set <org> <label> [--color rrggbb|'']", Run: runOrgLabelSet})
1552 register(Command{Path: []string{"org", "label", "list"},
1553 Summary: "list an org's labels with use across the repositories you can read",
1554 Usage: "org label list <org>", ReadOnly: true, Run: runOrgLabelList})
1555 register(Command{Path: []string{"org", "label", "remove"},
1556 Summary: "remove an org label from the org and from every issue under it",
1557 Usage: "org label remove <org> <label>", Run: runOrgLabelRemove})
1558}
1559
1560// orgReader resolves an org for a read of its labels or milestones.
1561// Members read; an outsider reads when some repository under the org is
1562// readable, and is refused rather than told the org is missing otherwise,
1563// since an org's existence is public anyway. The readable ids come back
1564// because every read counts over them.
1565func orgReader(c *Ctx, name string) (store.Org, []int64, int) {
1566 org, err := c.Store.OrgByName(name)
1567 if errors.Is(err, store.ErrNotFound) {
1568 return org, nil, c.fail(protocol.ExitNotFound, "no organization %q", name)
1569 }
1570 if err != nil {
1571 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
1572 }
1573 readable, err := ReadableOrgRepoIDs(c.Store, c.User, org.ID)
1574 if err != nil {
1575 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
1576 }
1577 role, err := c.Store.OrgRole(org.ID, c.User.ID)
1578 if err != nil {
1579 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
1580 }
1581 if role == "" && len(readable) == 0 {
1582 return org, nil, c.fail(protocol.ExitDenied, "labels and milestones of %s are visible to its members", name)
1583 }
1584 return org, readable, -1
1585}
1586
1587func runOrgLabelSet(c *Ctx, args []string) int {
1588 const usage = "usage: org label set <org> <label> [--color rrggbb|'']"
1589 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: usage})
1590 if err != nil {
1591 return c.fail(protocol.ExitUsage, "%v", err)
1592 }
1593 orgName, name := f.pos(0), f.pos(1)
1594 color, colorSet := strings.ToLower(f.Value("--color")), f.Has("--color")
1595 if orgName == "" || name == "" {
1596 return c.fail(protocol.ExitUsage, usage)
1597 }
1598 if name == "" || len(name) > 50 {
1599 return c.fail(protocol.ExitUsage, "a label is 1 to 50 characters")
1600 }
1601 if colorSet && color != "" {
1602 if !labelColorPat.MatchString(color) {
1603 return c.fail(protocol.ExitUsage, "--color takes rrggbb (with or without #), or '' to clear")
1604 }
1605 color = "#" + strings.TrimPrefix(color, "#")
1606 }
1607 org, code := orgAdmin(c, orgName)
1608 if code >= 0 {
1609 return code
1610 }
1611 if !colorSet {
1612 // Keep the colour it has, if any; this is "make sure it exists".
1613 if labels, err := c.Store.ListOrgLabels(org.ID, nil); err == nil {
1614 for _, l := range labels {
1615 if l.Name == name {
1616 color = l.Color
1617 }
1618 }
1619 }
1620 }
1621 folded, err := c.Store.SetOrgLabel(org.ID, name, color)
1622 if err != nil {
1623 return c.fail(protocol.ExitFailure, "%v", err)
1624 }
1625 return c.emit(struct {
1626 Name string `json:"name"`
1627 Color string `json:"color,omitempty"`
1628 Folded int `json:"folded"`
1629 }{name, color, folded}, func(w io.Writer) {
1630 if color == "" {
1631 fmt.Fprintf(w, "org label %s on %s, no colour set", name, org.Name)
1632 } else {
1633 fmt.Fprintf(w, "org label %s on %s is %s", name, org.Name, color)
1634 }
1635 if folded > 0 {
1636 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
1637 }
1638 fmt.Fprintln(w)
1639 })
1640}
1641
1642func runOrgLabelList(c *Ctx, args []string) int {
1643 if len(args) != 1 {
1644 return c.fail(protocol.ExitUsage, "usage: org label list <org>")
1645 }
1646 org, readable, code := orgReader(c, args[0])
1647 if code >= 0 {
1648 return code
1649 }
1650 labels, err := c.Store.ListOrgLabels(org.ID, readable)
1651 if err != nil {
1652 return c.fail(protocol.ExitFailure, "%v", err)
1653 }
1654 return c.emit(labels, func(w io.Writer) {
1655 for _, l := range labels {
1656 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues)
1657 }
1658 })
1659}
1660
1661func runOrgLabelRemove(c *Ctx, args []string) int {
1662 if len(args) != 2 {
1663 return c.fail(protocol.ExitUsage, "usage: org label remove <org> <label>")
1664 }
1665 org, code := orgAdmin(c, args[0])
1666 if code >= 0 {
1667 return code
1668 }
1669 if err := c.Store.DeleteOrgLabel(org.ID, args[1]); err != nil {
1670 if errors.Is(err, store.ErrNotFound) {
1671 return c.fail(protocol.ExitNotFound, "no org label %q on %s", args[1], org.Name)
1672 }
1673 return c.fail(protocol.ExitFailure, "%v", err)
1674 }
1675 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
1676 fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
1677 })
1678}
1679```
1680
1681`orgAdmin` in `org.go` uses `c.fail(protocol.ExitNotFound, ...)` for a missing org and `ExitDenied` for a non-admin; both tests above rely on that.
1682
1683- [ ] **Step 4: Add the CLI rows**
1684
1685In `cmd/gitbay/main.go`, inside `orgCmd()`'s `group("org", ...)` after the `members` group:
1686
1687```go
1688 group("label", "labels every org repository sees",
1689 pass("set", "create an org label or set its colour: <org> <label> [--color rrggbb|'']", passOpts{server: []string{"org", "label", "set"}}),
1690 pass("list", "list org labels with use across readable repositories: <org>", passOpts{server: []string{"org", "label", "list"}}),
1691 pass("remove", "remove an org label everywhere: <org> <label>", passOpts{server: []string{"org", "label", "remove"}}),
1692 ),
1693```
1694
1695In `e2e/readonly_test.go` add to `readArgs` after `"org team show"`:
1696
1697```go
1698 "org label list": {"theorg"},
1699```
1700
1701- [ ] **Step 5: Run the tests**
1702
1703Run: `go build ./... && go test ./internal/control/ -run 'OrgLabel' -v && go test ./cmd/gitbay/`
1704Expected: PASS, including the CLI coverage test.
1705
1706- [ ] **Step 6: Commit**
1707
1708```bash
1709git add internal/control/orglabel.go internal/control/orglabel_test.go cmd/gitbay/main.go e2e/readonly_test.go
1710git commit -m "control, cli: org label set, list, remove
1711
1712Ref #203"
1713```
1714
1715---
1716
1717### Task 6: `org milestone create|list|close|reopen`
1718
1719**Files:**
1720- Modify: `internal/control/orglabel.go` (append)
1721- Modify: `cmd/gitbay/main.go` (org group)
1722- Modify: `e2e/readonly_test.go` (`readArgs`)
1723- Test: `internal/control/orglabel_test.go` (append)
1724
1725**Interfaces:**
1726- Produces: `org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]`, `org milestone list <org> [--state open|closed|all]` (ReadOnly), `org milestone close|reopen <org> <title>`; `runOrgMilestoneCreate`, `runOrgMilestoneList`, `runOrgMilestoneClose`, `runOrgMilestoneReopen`.
1727- Consumes: `orgReader` and `orgAdmin`; `duePat` from `milestone.go`; store functions from Task 3.
1728
1729- [ ] **Step 1: Write the failing tests**
1730
1731Append to `internal/control/orglabel_test.go`:
1732
1733```go
1734func TestOrgMilestoneLifecycle(t *testing.T) {
1735 f := newOrgFixture(t)
1736 f.st.CreateMilestone(f.core, "v1", "", "")
1737 c, out := f.ctx(f.alice)
1738 if code := runOrgMilestoneCreate(c, []string{"acme", "v1", "--due", "2027-01-01"}); code != protocol.ExitOK ||
1739 !strings.Contains(out.String(), `"folded":1`) {
1740 t.Fatalf("create: exit %d %s", code, out.String())
1741 }
1742 out.Reset()
1743 if code := runOrgMilestoneCreate(c, []string{"acme", "v1"}); code != protocol.ExitFailure {
1744 t.Fatalf("duplicate create: exit %d %s", code, out.String())
1745 }
1746 out.Reset()
1747 if code := runOrgMilestoneCreate(c, []string{"acme", "v2", "--due", "soon"}); code != protocol.ExitUsage {
1748 t.Fatalf("bad due: exit %d %s", code, out.String())
1749 }
1750 out.Reset()
1751 // An issue in each repo attaches by title; progress spans both.
1752 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
1753 f.st.CreateIssue(f.priv.ID, f.alice, "p1", "", "md")
1754 runIssueMilestone(c, []string{"acme/core", "1", "v1"})
1755 runIssueMilestone(c, []string{"acme/priv", "1", "v1"})
1756 out.Reset()
1757 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK ||
1758 !strings.Contains(out.String(), `"open":2`) || !strings.Contains(out.String(), `"due":"2027-01-01"`) {
1759 t.Fatalf("list: exit %d %s", code, out.String())
1760 }
1761 out.Reset()
1762 // carol reads only the public repo's count.
1763 cc, cout := f.ctx(f.carol)
1764 if code := runOrgMilestoneList(cc, []string{"acme"}); code != protocol.ExitOK || !strings.Contains(cout.String(), `"open":1`) {
1765 t.Fatalf("outsider list: exit %d %s", code, cout.String())
1766 }
1767 if code := runOrgMilestoneClose(c, []string{"acme", "v1"}); code != protocol.ExitOK {
1768 t.Fatalf("close: exit %d %s", code, out.String())
1769 }
1770 out.Reset()
1771 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK || strings.Contains(out.String(), `"title":"v1"`) {
1772 t.Fatalf("closed still listed as open: %s", out.String())
1773 }
1774 out.Reset()
1775 if code := runOrgMilestoneReopen(c, []string{"acme", "v1"}); code != protocol.ExitOK {
1776 t.Fatalf("reopen: exit %d %s", code, out.String())
1777 }
1778 out.Reset()
1779 if code := runOrgMilestoneClose(c, []string{"acme", "nope"}); code != protocol.ExitNotFound {
1780 t.Fatalf("close missing: exit %d %s", code, out.String())
1781 }
1782 bc, bout := f.ctx(f.bob)
1783 if code := runOrgMilestoneClose(bc, []string{"acme", "v1"}); code != protocol.ExitDenied {
1784 t.Fatalf("member close: exit %d %s", code, bout.String())
1785 }
1786}
1787```
1788
1789- [ ] **Step 2: Run it to verify it fails**
1790
1791Run: `go test ./internal/control/ -run OrgMilestoneLifecycle -v`
1792Expected: build failure, `runOrgMilestoneCreate` undefined.
1793
1794- [ ] **Step 3: Append to `orglabel.go`**
1795
1796Add to `init()`:
1797
1798```go
1799 register(Command{Path: []string{"org", "milestone", "create"},
1800 Summary: "create an org milestone spanning every org repository; folds in same-titled repo milestones",
1801 Usage: "org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]", Run: runOrgMilestoneCreate})
1802 register(Command{Path: []string{"org", "milestone", "list"},
1803 Summary: "list an org's milestones with progress across the repositories you can read",
1804 Usage: "org milestone list <org> [--state open|closed|all]", ReadOnly: true, Run: runOrgMilestoneList})
1805 register(Command{Path: []string{"org", "milestone", "close"},
1806 Summary: "close an org milestone",
1807 Usage: "org milestone close <org> <title>", Run: runOrgMilestoneClose})
1808 register(Command{Path: []string{"org", "milestone", "reopen"},
1809 Summary: "reopen an org milestone",
1810 Usage: "org milestone reopen <org> <title>", Run: runOrgMilestoneReopen})
1811```
1812
1813And the functions:
1814
1815```go
1816func runOrgMilestoneCreate(c *Ctx, args []string) int {
1817 const usage = "usage: org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]"
1818 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: usage})
1819 if err != nil {
1820 return c.fail(protocol.ExitUsage, "%v", err)
1821 }
1822 orgName, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
1823 if orgName == "" || title == "" {
1824 return c.fail(protocol.ExitUsage, usage)
1825 }
1826 if due != "" && !duePat.MatchString(due) {
1827 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
1828 }
1829 org, code := orgAdmin(c, orgName)
1830 if code >= 0 {
1831 return code
1832 }
1833 _, folded, err := c.Store.CreateOrgMilestone(org.ID, title, description, due)
1834 if err != nil {
1835 return c.failErr(err)
1836 }
1837 return c.emit(struct {
1838 Milestone string `json:"milestone"`
1839 Folded int `json:"folded"`
1840 }{title, folded}, func(w io.Writer) {
1841 fmt.Fprintf(w, "created org milestone %q on %s", title, org.Name)
1842 if folded > 0 {
1843 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
1844 }
1845 fmt.Fprintln(w)
1846 })
1847}
1848
1849func runOrgMilestoneList(c *Ctx, args []string) int {
1850 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
1851 if err != nil {
1852 return c.fail(protocol.ExitUsage, "%v", err)
1853 }
1854 state, orgName := "open", f.pos(0)
1855 if f.Has("--state") {
1856 state = f.Value("--state")
1857 }
1858 if orgName == "" || (state != "open" && state != "closed" && state != "all") {
1859 return c.fail(protocol.ExitUsage, "usage: org milestone list <org> [--state open|closed|all]")
1860 }
1861 org, readable, code := orgReader(c, orgName)
1862 if code >= 0 {
1863 return code
1864 }
1865 ms, err := c.Store.ListOrgMilestones(org.ID, state, readable)
1866 if err != nil {
1867 return c.fail(protocol.ExitFailure, "%v", err)
1868 }
1869 type out struct {
1870 Title string `json:"title"`
1871 Description string `json:"description,omitempty"`
1872 Due string `json:"due,omitempty"`
1873 State string `json:"state"`
1874 Open int `json:"open"`
1875 Closed int `json:"closed"`
1876 }
1877 var ds []out
1878 for _, m := range ms {
1879 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OpenItems, m.ClosedItems})
1880 }
1881 return c.emit(ds, func(w io.Writer) {
1882 for _, d := range ds {
1883 due := d.Due
1884 if due == "" {
1885 due = "-"
1886 }
1887 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed\n", d.Title, d.State, due, d.Open, d.Closed)
1888 }
1889 })
1890}
1891
1892func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") }
1893func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") }
1894
1895func setOrgMilestoneState(c *Ctx, args []string, state string) int {
1896 verb := "close"
1897 if state == "open" {
1898 verb = "reopen"
1899 }
1900 if len(args) != 2 {
1901 return c.fail(protocol.ExitUsage, "usage: org milestone %s <org> <title>", verb)
1902 }
1903 org, code := orgAdmin(c, args[0])
1904 if code >= 0 {
1905 return code
1906 }
1907 m, err := c.Store.OrgMilestoneByTitle(org.ID, args[1])
1908 if errors.Is(err, store.ErrNotFound) {
1909 return c.fail(protocol.ExitNotFound, "no org milestone %q on %s", args[1], org.Name)
1910 }
1911 if err != nil {
1912 return c.fail(protocol.ExitFailure, "%v", err)
1913 }
1914 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
1915 return c.fail(protocol.ExitFailure, "%v", err)
1916 }
1917 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
1918 fmt.Fprintf(w, "%sd org milestone %q on %s\n", verb, m.Title, org.Name)
1919 })
1920}
1921```
1922
1923- [ ] **Step 4: CLI rows and the read-only table**
1924
1925In `orgCmd()` after the `label` group:
1926
1927```go
1928 group("milestone", "milestones spanning an org's repositories",
1929 pass("create", "create an org milestone: <org> <title> [--description d] [--due YYYY-MM-DD]", passOpts{server: []string{"org", "milestone", "create"}}),
1930 pass("list", "list org milestones with progress: <org> [--state open|closed|all]", passOpts{server: []string{"org", "milestone", "list"}}),
1931 pass("close", "close an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "close"}}),
1932 pass("reopen", "reopen an org milestone: <org> <title>", passOpts{server: []string{"org", "milestone", "reopen"}}),
1933 ),
1934```
1935
1936In `e2e/readonly_test.go` `readArgs`: `"org milestone list": {"theorg"},`.
1937
1938- [ ] **Step 5: Run the tests**
1939
1940Run: `go build ./... && go vet ./... && go test ./internal/control/ ./cmd/gitbay/`
1941Expected: PASS.
1942
1943- [ ] **Step 6: Commit**
1944
1945```bash
1946git add internal/control/orglabel.go internal/control/orglabel_test.go cmd/gitbay/main.go e2e/readonly_test.go
1947git commit -m "control, cli: org milestone create, list, close, reopen
1948
1949Ref #203"
1950```
1951
1952---
1953
1954### Task 7: Cross-repository closes
1955
1956**Files:**
1957- Modify: `internal/control/commitrefs.go`
1958- Modify: `internal/control/commitrefs_test.go`
1959
1960**Interfaces:**
1961- Produces: `type closeRef struct { Path string; N int64 }`; `func closingRefs(text string) []closeRef`; `func closeTarget(st *store.Store, source store.Repo, actorID int64, path string) (store.Repo, bool)`; `func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha string, number int64, close bool, subject, author string)`.
1962- `ProcessCommitMessages` and `ProcessMRDescription` keep their signatures; callers in `internal/hookd/hookd.go:240` and `internal/control/mr.go:1211-1212` do not change.
1963
1964- [ ] **Step 1: Update the unit test and add the cross-repo cases**
1965
1966Replace `internal/control/commitrefs_test.go`:
1967
1968```go
1969package control
1970
1971import (
1972 "slices"
1973 "strings"
1974 "testing"
1975
1976 "gitbay.org/gitbay/internal/store"
1977)
1978
1979// The same keyword set has to work wherever the intent is written: a
1980// commit message, or a merge request title or body.
1981func TestClosingRefs(t *testing.T) {
1982 for _, tc := range []struct {
1983 name string
1984 text string
1985 want []closeRef
1986 }{
1987 {"closes", "Closes #50", []closeRef{{"", 50}}},
1988 {"lowercase and fix", "fixes #7", []closeRef{{"", 7}}},
1989 {"resolved", "resolved: #12", []closeRef{{"", 12}}},
1990 {"several", "Closes #1\n\nAlso fixes #2 and resolves #3", []closeRef{{"", 1}, {"", 2}, {"", 3}}},
1991 {"repeats collapse", "closes #4, closes #4", []closeRef{{"", 4}}},
1992 {"bare references do not close", "see #9 for context", nil},
1993 {"cross-repo carries the path", "closes krz/other#3", []closeRef{{"krz/other", 3}}},
1994 {"same number in two repos", "closes #3, closes krz/other#3", []closeRef{{"", 3}, {"krz/other", 3}}},
1995 {"keyword must be its own word", "unclosed #5", nil},
1996 } {
1997 t.Run(tc.name, func(t *testing.T) {
1998 got := closingRefs(tc.text)
1999 slices.SortFunc(got, func(a, b closeRef) int {
2000 if a.Path != b.Path {
2001 return strings.Compare(a.Path, b.Path)
2002 }
2003 return int(a.N - b.N)
2004 })
2005 if !slices.Equal(got, tc.want) {
2006 t.Errorf("closingRefs(%q) = %v, want %v", tc.text, got, tc.want)
2007 }
2008 })
2009 }
2010}
2011
2012// A merged merge request's description closes an issue in another
2013// repository only when the merger holds write there. This drives the
2014// same target resolution the commit path uses, without needing git.
2015func TestMRDescriptionClosesAcrossRepos(t *testing.T) {
2016 f := newOrgFixture(t)
2017 libIssue, _ := f.st.CreateIssue(f.priv.ID, f.alice, "in priv", "", "md")
2018 appIssue, _ := f.st.CreateIssue(f.app.ID, f.alice, "in app", "", "md")
2019 _ = libIssue
2020 _ = appIssue
2021 mr := func(n int64, title string) store.MR {
2022 return store.MR{Number: n, Title: title, Body: ""}
2023 }
2024 // carol cannot write acme/priv: the issue stays open and no comment
2025 // lands.
2026 ProcessMRDescription(f.st, f.app, mr(1, "Closes acme/priv#1"), f.carol)
2027 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
2028 t.Fatal("outsider closed a private repo's issue")
2029 }
2030 // alice can: it closes with a comment naming the source repository.
2031 ProcessMRDescription(f.st, f.app, mr(2, "Closes acme/priv#1"), f.alice)
2032 iss, _ := f.st.IssueByNumber(f.priv.ID, 1)
2033 if iss.State != "closed" {
2034 t.Fatal("writer did not close across repos")
2035 }
2036 comments, _ := f.st.ListIssueComments(iss.ID)
2037 if len(comments) != 1 || !strings.Contains(comments[0].Body, "(/alice/app/mrs/2)") {
2038 t.Fatalf("close comment = %+v", comments)
2039 }
2040 // An unknown path is text; a bare #N still acts in the source repo.
2041 ProcessMRDescription(f.st, f.app, mr(3, "Closes nobody/nothing#1 and closes #1"), f.alice)
2042 if iss, _ := f.st.IssueByNumber(f.app.ID, 1); iss.State != "closed" {
2043 t.Fatal("bare #N stopped working")
2044 }
2045}
2046```
2047
2048- [ ] **Step 2: Run to verify it fails**
2049
2050Run: `go test ./internal/control/ -run 'ClosingRefs|MRDescriptionCloses' -v`
2051Expected: build failure, `closeRef` undefined.
2052
2053- [ ] **Step 3: Change `commitrefs.go`**
2054
2055Replace the pattern comment and vars:
2056
2057```go
2058// closePat matches closing keywords, with an optional owner/name before
2059// the number for an issue in another repository; refPat matches any bare
2060// same-repo reference. A cross-repo close acts only when the actor holds
2061// write on the target (closeTarget); a bare cross-repo reference stays
2062// display-only.
2063var (
2064 closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+(?:([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*))?#(\d+)\b`)
2065 refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`)
2066)
2067
2068// closeRef is one closing reference: Path is "" for the same repository.
2069type closeRef struct {
2070 Path string
2071 N int64
2072}
2073```
2074
2075Replace `closingRefs`:
2076
2077```go
2078// closingRefs returns the references a text closes, in no order.
2079func closingRefs(text string) []closeRef {
2080 seen := map[closeRef]bool{}
2081 var out []closeRef
2082 for _, g := range closePat.FindAllStringSubmatch(text, -1) {
2083 n, err := strconv.ParseInt(g[2], 10, 64)
2084 if err != nil {
2085 continue
2086 }
2087 ref := closeRef{Path: strings.ToLower(g[1]), N: n}
2088 if seen[ref] {
2089 continue
2090 }
2091 seen[ref] = true
2092 out = append(out, ref)
2093 }
2094 return out
2095}
2096
2097// closeTarget resolves where a closing reference acts: the source
2098// repository for a bare #N, or the named repository when the actor holds
2099// write there. false means the reference stays text; nothing is logged
2100// above debug, since a refusal must not confirm the target exists.
2101func closeTarget(st *store.Store, source store.Repo, actorID int64, path string) (store.Repo, bool) {
2102 if path == "" {
2103 return source, true
2104 }
2105 target, err := st.RepoByPath(path)
2106 if err != nil {
2107 return store.Repo{}, false
2108 }
2109 actor, err := st.UserByID(actorID)
2110 if err != nil {
2111 return store.Repo{}, false
2112 }
2113 grant, err := st.AccessRole(target.ID, actorID)
2114 if err != nil {
2115 return store.Repo{}, false
2116 }
2117 if !policy.CanWrite(actor, target, grant) {
2118 slog.Debug("commit refs: cross-repo close refused", "source", source.Path(), "target", path)
2119 return store.Repo{}, false
2120 }
2121 return target, true
2122}
2123```
2124
2125Add `"gitbay.org/gitbay/internal/policy"` to the imports.
2126
2127In `ProcessCommitMessages`, replace the body of the per-message loop:
2128
2129```go
2130 for _, m := range msgs {
2131 closes := closingRefs(m.Message)
2132 local := map[int64]bool{}
2133 for _, ref := range closes {
2134 if ref.Path == "" {
2135 local[ref.N] = true
2136 }
2137 }
2138 refs := map[int64]bool{}
2139 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
2140 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !local[n] {
2141 refs[n] = true
2142 }
2143 }
2144 subject, _, _ := strings.Cut(m.Message, "\n")
2145 author := authorLink(st, m.AuthorName, m.AuthorEmail)
2146 for _, ref := range closes {
2147 target, ok := closeTarget(st, repo, actorID, ref.Path)
2148 if !ok {
2149 continue
2150 }
2151 actOnIssue(st, repo, target, actorID, m.SHA, ref.N, true, subject, author)
2152 }
2153 for n := range refs {
2154 actOnIssue(st, repo, repo, actorID, m.SHA, n, false, subject, author)
2155 }
2156 }
2157```
2158
2159In `ProcessMRDescription`, replace the loop:
2160
2161```go
2162 for _, ref := range closingRefs(mr.Title + "\n" + mr.Body) {
2163 target, ok := closeTarget(st, repo, actorID, ref.Path)
2164 if !ok {
2165 continue
2166 }
2167 issue, err := st.IssueByNumber(target.ID, ref.N)
2168 if err != nil || issue.State != "open" {
2169 continue // no such issue, or a commit already closed it
2170 }
2171 fresh, err := st.TryRecordCommitRef(issue.ID, mrRefKey(mr.Number))
2172 if err != nil || !fresh {
2173 continue // this merge request already acted on this issue
2174 }
2175 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
2176 slog.Error("mr refs: closing issue", "issue", ref.N, "err", err)
2177 continue
2178 }
2179 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
2180 st.AddIssueSystemComment(issue.ID, actorID,
2181 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
2182 st.RecordEvent(target.ID, actorID, "issue.closed",
2183 fmt.Sprintf(`{"number":%d,"mr":%d}`, ref.N, mr.Number))
2184 }
2185```
2186
2187`mrRefKey` is per merge request number; a merge request that closes issues in two repositories records `mr-N` against each issue id, which is distinct rows, so the dedup still holds.
2188
2189Change `actOnIssue` to take `source, target store.Repo`: the issue lookup and the event use `target.ID`; the commit link uses `source.Path()`:
2190
2191```go
2192func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
2193 issue, err := st.IssueByNumber(target.ID, number)
2194 if err != nil {
2195 return // no such issue: the reference is just text
2196 }
2197 fresh, err := st.TryRecordCommitRef(issue.ID, sha)
2198 if err != nil || !fresh {
2199 return
2200 }
2201 short := sha
2202 if len(short) > 10 {
2203 short = short[:10]
2204 }
2205 // Informational system entries, not comments from the pusher; the
2206 // linked sha renders clickable on the web.
2207 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, source.Path(), sha)
2208 if close && issue.State == "open" {
2209 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
2210 slog.Error("commit refs: closing issue", "issue", number, "err", err)
2211 return
2212 }
2213 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
2214 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
2215 return
2216 }
2217 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
2218}
2219```
2220
2221- [ ] **Step 4: Run the tests**
2222
2223Run: `go build ./... && go vet ./... && go test ./internal/control/ -run 'ClosingRefs|MRDescriptionCloses|CommitRef' -v`
2224Expected: PASS.
2225
2226- [ ] **Step 5: Commit**
2227
2228```bash
2229git add internal/control/commitrefs.go internal/control/commitrefs_test.go
2230git commit -m "control: Closes owner/name#N acts on a repository the actor can write to
2231
2232Ref #203"
2233```
2234
2235---
2236
2237### Task 8: Web: org pages and the org mark
2238
2239**Files:**
2240- Create: `internal/httpd/orglabels.go`
2241- Create: `internal/web/templates/orglabels.html`
2242- Create: `internal/web/templates/orgmilestones.html`
2243- Modify: `internal/httpd/routes.go:58-74` (two GET routes)
2244- Modify: `internal/web/templates/labels.html`
2245- Modify: `internal/web/templates/milestones.html`
2246- Modify: `internal/web/templates/owner.html:3-9`
2247- Test: `httpd` has no in-process server fixture; the e2e in Task 10 exercises these pages, and this task's check is `go build` plus `go test ./internal/httpd/`, which parses the template set.
2248
2249**Interfaces:**
2250- Produces: `GET /{owner}/-/labels` → `s.orgLabels`, `GET /{owner}/-/milestones` → `s.orgMilestones`; 404 for a user owner, an unknown org, or an org the viewer is not a member of with no readable repository.
2251- Consumes: `control.ReadableOrgRepoIDs`, `colorStyles` from Task 4, `store.ListOrgLabels`, `store.ListOrgMilestones`, `OrgRole`.
2252
2253- [ ] **Step 1: Routes**
2254
2255In `internal/httpd/routes.go` after the `/{owner}/activity.atom` route:
2256
2257```go
2258 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
2259 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
2260```
2261
2262`-` cannot start a repository name (`policy.namePat`), so these shadow nothing and `TestReservedNames...` needs no change.
2263
2264- [ ] **Step 2: Handlers**
2265
2266`internal/httpd/orglabels.go`:
2267
2268```go
2269package httpd
2270
2271import (
2272 "html/template"
2273 "net/http"
2274
2275 "gitbay.org/gitbay/internal/control"
2276 "gitbay.org/gitbay/internal/store"
2277)
2278
2279// orgScope resolves the org for its labels or milestones page. Members
2280// see it; anyone else only when some repository under the org is
2281// readable. Everything else is not found, the same answer as for a
2282// user owner or an unknown name.
2283func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool) {
2284 viewer := s.viewer(r)
2285 org, err := s.st.OrgByName(r.PathValue("owner"))
2286 if err != nil {
2287 s.notFound(w, r)
2288 return org, viewer, nil, false
2289 }
2290 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID)
2291 if err != nil {
2292 http.Error(w, "internal error", http.StatusInternalServerError)
2293 return org, viewer, nil, false
2294 }
2295 role := ""
2296 if viewer.ID != 0 {
2297 role, _ = s.st.OrgRole(org.ID, viewer.ID)
2298 }
2299 if role == "" && len(readable) == 0 {
2300 s.notFound(w, r)
2301 return org, viewer, nil, false
2302 }
2303 return org, viewer, readable, true
2304}
2305
2306func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
2307 org, viewer, readable, ok := s.orgScope(w, r)
2308 if !ok {
2309 return
2310 }
2311 labels, err := s.st.ListOrgLabels(org.ID, readable)
2312 if err != nil {
2313 http.Error(w, "internal error", http.StatusInternalServerError)
2314 return
2315 }
2316 stored := make(map[string]string, len(labels))
2317 for _, l := range labels {
2318 stored[l.Name] = l.Color
2319 }
2320 s.render(w, "orglabels.html", struct {
2321 basePage
2322 Org string
2323 Labels []store.Label
2324 LabelColors map[string]template.CSS
2325 }{s.baseFor(viewer), org.Name, labels, colorStyles(stored)})
2326}
2327
2328func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
2329 org, viewer, readable, ok := s.orgScope(w, r)
2330 if !ok {
2331 return
2332 }
2333 state := r.URL.Query().Get("state")
2334 if state != "closed" && state != "all" {
2335 state = "open"
2336 }
2337 ms, err := s.st.ListOrgMilestones(org.ID, state, readable)
2338 if err != nil {
2339 http.Error(w, "internal error", http.StatusInternalServerError)
2340 return
2341 }
2342 type msView struct {
2343 store.Milestone
2344 Percent int
2345 }
2346 var views []msView
2347 for _, m := range ms {
2348 v := msView{Milestone: m}
2349 if total := m.OpenItems + m.ClosedItems; total > 0 {
2350 v.Percent = m.ClosedItems * 100 / total
2351 }
2352 views = append(views, v)
2353 }
2354 s.render(w, "orgmilestones.html", struct {
2355 basePage
2356 Org string
2357 State string
2358 Milestones []msView
2359 }{s.baseFor(viewer), org.Name, state, views})
2360}
2361```
2362
2363- [ ] **Step 3: Templates**
2364
2365`internal/web/templates/orglabels.html`:
2366
2367```html
2368{{define "title"}}labels · {{.Org}}{{end}}
2369{{define "content"}}
2370<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1>
2371<p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} &lt;label&gt;</code>; counts span the repositories you can read.</p>
2372{{if .Labels}}<div class="tablewrap"><table class="keys">
2373<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th></tr>
2374{{range .Labels}}<tr>
2375 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td>
2376 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td>
2377 <td>{{.Issues}}</td>
2378</tr>
2379{{end}}</table></div>
2380{{else}}<p class="none">No org labels yet.</p>{{end}}
2381{{end}}
2382```
2383
2384`internal/web/templates/orgmilestones.html`:
2385
2386```html
2387{{define "title"}}milestones · {{.Org}}{{end}}
2388{{define "content"}}
2389<div class="listhead">
2390 <h1><a href="/{{.Org}}">{{.Org}}</a> milestones</h1>
2391 <nav class="filters">
2392 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
2393 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
2394 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
2395 </nav>
2396</div>
2397<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p>
2398<ul class="milestonelist">
2399{{range .Milestones}}<li>
2400 <div class="msmain">
2401 <p class="title">{{.Title}} <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span></p>
2402 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
2403 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
2404 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
2405 </div>
2406</li>
2407{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones — create one with <code>gitbay org milestone create {{.Org}} "v1.0"</code></li>{{end}}
2408</ul>
2409{{end}}
2410```
2411
2412In `labels.html`, mark org rows and drop their forms. Replace the `{{range .Labels}}<tr>` row with:
2413
2414```html
2415{{range .Labels}}<tr>
2416 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td>
2417 <td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
2418 <input type="hidden" name="name" value="{{.Name}}">
2419 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
2420 <button type="submit" class="btn">Save</button>
2421 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>
2422 <td>{{.Issues}}</td>
2423 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
2424 <input type="hidden" name="action" value="remove">
2425 <input type="hidden" name="name" value="{{.Name}}">
2426 <button type="submit" class="linklike">Remove</button>
2427 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
2428</tr>
2429```
2430
2431In `milestones.html`, in the `<p class="title">` line, after the state chip add `{{if .OrgID}} <span class="chip chip-neutral">org</span>{{end}}`.
2432
2433In `owner.html`, after the `{{if .Members}}...{{end}}` line inside `profilehead`:
2434
2435```html
2436{{if eq .Kind "org"}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
2437```
2438
2439- [ ] **Step 4: Build and run the httpd tests**
2440
2441Run: `go build ./... && go test ./internal/httpd/`
2442Expected: PASS. The template set parses at start-up, so a syntax error surfaces here.
2443
2444- [ ] **Step 5: Commit**
2445
2446```bash
2447git add internal/httpd/orglabels.go internal/httpd/routes.go internal/web/templates/orglabels.html internal/web/templates/orgmilestones.html internal/web/templates/labels.html internal/web/templates/milestones.html internal/web/templates/owner.html
2448git commit -m "web: org label and milestone pages under /{org}/-/, org mark on repository pages
2449
2450Ref #203"
2451```
2452
2453---
2454
2455### Task 9: Docs
2456
2457**Files:**
2458- Modify: `.gitbay/wiki/Users.org:350-360` (after the milestones block) and the commit-references paragraph ending "Same repository only." (near line 340)
2459- Modify: `.gitbay/wiki/Parity.org:99-125`
2460
2461- [ ] **Step 1: Users**
2462
2463Replace the sentence `Same repository only.` in the commit-references paragraph with:
2464
2465```
2466=Closes owner/name#N= closes an issue in another repository when
2467you hold write there; otherwise it stays a plain link. A bare
2468=owner/name#N= links and does nothing.
2469```
2470
2471After the milestones `#+end_src` block add:
2472
2473```
2474An org holds labels and milestones every repository under it sees
2475beside its own. =issue label --add=, =issue milestone= and =mr
2476milestone= resolve the org's row first; a repository cannot create a
2477label or milestone with a name its org holds. Creating an org label or
2478milestone whose name repositories under the org already use folds them
2479in: their issues and merge requests move to the org's row. Org admins
2480manage them; counts span the repositories you can read.
2481
2482#+begin_src sh
2483gitbay org label set acme bug --color cf222e
2484gitbay org label list acme / remove acme bug
2485gitbay org milestone create acme v2 --due 2027-03-01
2486gitbay org milestone list acme [--state open|closed|all]
2487gitbay org milestone close acme v2 / reopen acme v2
2488#+end_src
2489
2490On the web: =/acme/-/labels= and =/acme/-/milestones=, read-only.
2491```
2492
2493- [ ] **Step 2: Parity**
2494
2495After the `| milestone create, close, reopen | yes | no | yes |` row add:
2496
2497```
2498| org labels: set, list, remove | yes | list | no |
2499| org milestones: create, list, close, reopen | yes | list | no |
2500| closes across repositories | yes | yes | yes |
2501```
2502
2503("list" in the web column means the read page only.) After the paragraph that starts `Labels are created on the fly` add:
2504
2505```
2506Org labels and milestones are managed on the CLI and API only;
2507=/<org>/-/labels= and =/<org>/-/milestones= show them. The repository
2508label page's form exists for colour alone, and three org forms nobody
2509asked for were not worth their handlers.
2510```
2511
2512- [ ] **Step 3: Commit**
2513
2514```bash
2515git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org
2516git commit -m "wiki: org labels, milestones and cross-repository closes
2517
2518Ref #203"
2519```
2520
2521---
2522
2523### Task 10: End-to-end test
2524
2525**Files:**
2526- Create: `e2e/orglabels_test.go`
2527
2528**Interfaces:**
2529- Consumes: the harness in `e2e/ssh_test.go` (`startInstance`, `inst.newKey`, `inst.admin`, `inst.ssh`, `inst.get`, `inst.gitEnv`, `inst.sshURL`, `mustGit`).
2530
2531- [ ] **Step 1: Write the test**
2532
2533```go
2534package e2e
2535
2536import (
2537 "os"
2538 "path/filepath"
2539 "strings"
2540 "testing"
2541)
2542
2543// An org's labels and milestones reach every repository under it; a
2544// commit in one repository closes an issue in another; the org pages
2545// answer members and outsiders as their access allows.
2546func TestOrgLabelsMilestonesAndCrossRepoCloses(t *testing.T) {
2547 inst := startInstance(t)
2548 aliceKey := inst.newKey(t, "alice")
2549 carolKey := inst.newKey(t, "carol")
2550 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
2551 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub", "--email", "carol@example.test", "--verified")
2552 must := func(key string, args ...string) string {
2553 t.Helper()
2554 out, errOut, code := inst.ssh(t, key, "", args...)
2555 if code != 0 {
2556 t.Fatalf("%v: exit %d %s", args, code, errOut)
2557 }
2558 return out
2559 }
2560 must(aliceKey, "org", "create", "acme")
2561 must(aliceKey, "repo", "create", "acme/lib")
2562 must(aliceKey, "repo", "create", "acme/widget", "--private")
2563 must(aliceKey, "issue", "create", "acme/lib", "--title", "'lib one'")
2564 must(aliceKey, "issue", "create", "acme/widget", "--title", "'widget one'")
2565
2566 // Repo labels in both, then the org set folds them in.
2567 must(aliceKey, "issue", "label", "acme/lib", "1", "--add", "bug")
2568 must(aliceKey, "issue", "label", "acme/widget", "1", "--add", "bug")
2569 out := must(aliceKey, "org", "label", "set", "acme", "bug", "--color", "ff0000", "--json")
2570 if !strings.Contains(out, `"folded":2`) {
2571 t.Fatalf("org label set: %s", out)
2572 }
2573 out = must(aliceKey, "label", "list", "acme/lib", "--json")
2574 if !strings.Contains(out, `"org":true`) || !strings.Contains(out, `"issues":2`) {
2575 t.Fatalf("lib label list: %s", out)
2576 }
2577 if _, errOut, code := inst.ssh(t, aliceKey, "", "label", "set", "acme/lib", "bug"); code == 0 || !strings.Contains(errOut, "org label set acme bug") {
2578 t.Fatalf("repo label set over org name: exit %d %s", code, errOut)
2579 }
2580
2581 // An org milestone attaches from both repositories and counts across.
2582 must(aliceKey, "org", "milestone", "create", "acme", "v1", "--due", "2027-01-01")
2583 must(aliceKey, "issue", "milestone", "acme/lib", "1", "v1")
2584 must(aliceKey, "issue", "milestone", "acme/widget", "1", "v1")
2585 out = must(aliceKey, "org", "milestone", "list", "acme", "--json")
2586 if !strings.Contains(out, `"open":2`) {
2587 t.Fatalf("org milestone list: %s", out)
2588 }
2589 out = must(aliceKey, "issue", "list", "acme/lib", "--milestone", "v1", "--json")
2590 if !strings.Contains(out, `"number":1`) {
2591 t.Fatalf("issue list filtered by org milestone: %s", out)
2592 }
2593
2594 // A push to acme/lib closes acme/widget#1 and leaves a comment there.
2595 work := t.TempDir()
2596 env := inst.gitEnv(aliceKey)
2597 mustGit(t, work, env, "clone", inst.sshURL("acme/lib"), "w")
2598 dir := filepath.Join(work, "w")
2599 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
2600 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
2601 mustGit(t, dir, env, "add", ".")
2602 mustGit(t, dir, env, "commit", "-q", "-m", "fix the widget\n\nCloses acme/widget#1")
2603 mustGit(t, dir, env, "push", "-q", "origin", "main")
2604 out = must(aliceKey, "issue", "show", "acme/widget", "1", "--json")
2605 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "](/acme/lib/commit/") {
2606 t.Fatalf("widget#1 after cross-repo close: %s", out)
2607 }
2608 out = must(aliceKey, "org", "milestone", "list", "acme", "--json")
2609 if !strings.Contains(out, `"open":1`) || !strings.Contains(out, `"closed":1`) {
2610 t.Fatalf("org milestone progress after close: %s", out)
2611 }
2612
2613 // carol is outside: she reads the org pages because acme/lib is public,
2614 // and the counts stop at it.
2615 out = must(carolKey, "org", "milestone", "list", "acme", "--json")
2616 if !strings.Contains(out, `"open":1`) || !strings.Contains(out, `"closed":0`) {
2617 t.Fatalf("outsider progress: %s", out)
2618 }
2619 if status, body := inst.get(t, "/acme/-/labels"); status != 200 || !strings.Contains(body, ">bug<") {
2620 t.Fatalf("org labels page: %d", status)
2621 }
2622 if status, body := inst.get(t, "/acme/-/milestones"); status != 200 || !strings.Contains(body, "v1") || !strings.Contains(body, "1 closed, 1 open") {
2623 t.Fatalf("org milestones page: %d\n%s", status, body)
2624 }
2625 if status, body := inst.get(t, "/acme/lib/labels"); status != 200 || !strings.Contains(body, `chip-neutral">org<`) {
2626 t.Fatalf("repo labels page lacks the org mark: %d", status)
2627 }
2628 // carol cannot close into the private repo from a repo she owns.
2629 must(carolKey, "repo", "create", "carol/own")
2630 must(aliceKey, "issue", "create", "acme/widget", "--title", "'widget two'")
2631 cwork := t.TempDir()
2632 cenv := inst.gitEnv(carolKey)
2633 mustGit(t, cwork, cenv, "clone", inst.sshURL("carol/own"), "w")
2634 cdir := filepath.Join(cwork, "w")
2635 os.WriteFile(filepath.Join(cdir, "a.txt"), []byte("a\n"), 0o644)
2636 mustGit(t, cdir, cenv, "checkout", "-q", "-b", "main")
2637 mustGit(t, cdir, cenv, "add", ".")
2638 mustGit(t, cdir, cenv, "commit", "-q", "-m", "sneaky\n\nCloses acme/widget#2")
2639 mustGit(t, cdir, cenv, "push", "-q", "origin", "main")
2640 out = must(aliceKey, "issue", "show", "acme/widget", "2", "--json")
2641 if !strings.Contains(out, `"state":"open"`) || strings.Contains(out, "sneaky") {
2642 t.Fatalf("outsider acted on a private repo's issue: %s", out)
2643 }
2644 // With the public repo gone private, the org pages are not found for
2645 // an anonymous reader.
2646 must(aliceKey, "repo", "settings", "visibility", "acme/lib", "private")
2647 if status, _ := inst.get(t, "/acme/-/labels"); status != 404 {
2648 t.Fatalf("private org labels page for anonymous: %d", status)
2649 }
2650}
2651```
2652
2653- [ ] **Step 2: Run it**
2654
2655Run: `go test ./e2e -run TestOrgLabelsMilestonesAndCrossRepoCloses -v`
2656Expected: PASS. It needs real `git`, `ssh` and `sshd`, as every e2e test does. Fix whatever it finds in the earlier tasks; adjust JSON field assertions to the actual output rather than loosening them.
2657
2658- [ ] **Step 3: Commit**
2659
2660```bash
2661git add e2e/orglabels_test.go
2662git commit -m "e2e: org labels, milestones and a cross-repository close
2663
2664Closes #203"
2665```
2666
2667---
2668
2669### Task 11: Merge request
2670
2671- [ ] **Step 1: Rebase and push**
2672
2673```bash
2674git fetch -q origin && git rebase origin/main && git push -u origin org-scope
2675```
2676
2677- [ ] **Step 2: Open the MR**
2678
2679```bash
2680gitbay mr create --source org-scope --target main --title "Org labels, milestones and cross-repository closes" --file - <<'EOF'
2681Migration 0052 scopes `labels` and `milestones` to a repository or an org. Every repository under an org sees the org's rows beside its own; `org label set|list|remove` and `org milestone create|list|close|reopen` manage them, folding in same-named repository rows on create. `Closes owner/name#N` in a commit on the default branch or a merged merge request closes that issue when the actor holds write there. Read pages at `/{org}/-/labels` and `/{org}/-/milestones`.
2682
2683Spec: docs/specs/2026-09-11-org-labels-milestones-closes-design.md
2684
2685Closes #203
2686EOF
2687```
2688
2689- [ ] **Step 3: CI, then merge**
2690
2691Wait for the `build` and `test` jobs on bay1. Then `gitbay mr merge <n> --strategy ff` and delete the branch locally and on the forge. The CHANGELOG entry is written at release time under the next minor version, as v1.18.1's was.
docs/specs/2026-09-11-org-labels-milestones-closes-design.md added +215
@@ -0,0 +1,215 @@
1# Org-level labels and milestones, cross-repository closes
2
3Closes #203 (ref #185). Labels and milestones an org defines once for every
4repository under it, and `Closes owner/name#N` acting on another repository
5the actor can write to.
6
7## Problem
8
9Labels and milestones are rows keyed on `repo_id`; `closes #N` acts in the
10repository the commit landed in (`internal/control/commitrefs.go`). An org
11with several repositories recreates its labels in each, keeps a milestone
12per repository for one release, and cannot close `ttorg/widget#1` from a
13commit to `ttorg/lib`. The web already links `owner/name#N` across
14repositories (`internal/autolink`, with a read check); only the action is
15missing.
16
17## Decision
18
19All three move beyond the repository:
20
21- An org holds labels and milestones. Every repository owned by the org
22 sees them beside its own. Org rows are managed by org admins through
23 `org label` and `org milestone`.
24- `Closes owner/name#N` in a commit on the default branch, or in a merged
25 merge request's title or body, closes that issue when the pusher or
26 merger holds write on the target. Otherwise the text stays a plain
27 autolink.
28
29Decisions taken on the way, with the alternatives rejected:
30
31- **Scope columns on the existing tables**, not separate `org_labels` and
32 `org_milestones` tables. `issue_labels` and the two `milestone_id` columns
33 keep pointing at the same ids, so attaching, filtering and counting do
34 not fork into two sources. The cost is a table rebuild in the migration.
35- **Inherited, not templated.** An org label is one row every repository
36 reads, not a copy made at repository creation. Copies drift, which is
37 what #203 complains about.
38- **Any writable target for closes**, not same-org only. Write on the
39 target is the permission `issue close` needs there; the org boundary
40 would be narrower than the model and one more rule to explain.
41- **Org admins manage org rows.** Repository write is enough for repo rows
42 today; the org's rows affect every repository, so the org's admin role
43 is the gate.
44- **Promote on org create.** `org label set bug` when repositories under
45 the org already hold `bug` folds them into the org row rather than
46 refusing. Refusing would make the migrant's first command fail against
47 exactly the duplication they came to remove.
48- **Org pages under `/{org}/-/`.** A hyphen cannot start a repository
49 name, so `/{org}/-/labels` shadows nothing and reserves nothing.
50- **Org writes are CLI and API only.** The repository label page's form
51 exists for colour alone; three org forms nobody asked for are not
52 worth their handlers. Recorded in Parity as deliberate.
53
54## Data
55
56Migration 0052 rebuilds `labels` and `milestones` the way 0041 rebuilt
57`commit_statuses`: rename, create, copy with ids, drop. Unlike
58`commit_statuses`, both tables have children (`issue_labels`,
59`issues.milestone_id`, `merge_requests.milestone_id`), and since SQLite
603.26 `ALTER TABLE RENAME` rewrites a child's foreign key to follow the
61renamed parent, which would leave the children pointing at `labels_old`.
62The script therefore brackets the renames with `PRAGMA legacy_alter_table
63= ON` and `= OFF`, which a transaction allows; the children keep naming
64`labels` and `milestones` and bind to the new tables. The migration
65file's first line, `-- foreign_keys: off`, has the migration runner
66switch foreign keys off on a pinned connection for that step, because
67rebuilding a parent table with children otherwise loses the children's
68rows. The runner checks `foreign_key_check` is empty once the step
69commits and foreign keys are back on; the migration test asserts it
70too.
71
72```sql
73CREATE TABLE labels (
74 id INTEGER PRIMARY KEY,
75 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
76 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
77 name TEXT NOT NULL,
78 color TEXT NOT NULL DEFAULT '',
79 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
80);
81CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL;
82CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL;
83```
84
85`milestones` keeps `title`, `description`, `due_date`, `state`, `created_at`
86and gets the same `repo_id`/`org_id` pair, CHECK and two partial unique
87indexes in place of `UNIQUE (repo_id, title)`.
88
89The down migration recreates the old shape and fails if any org-scoped row
90exists; there is no repository to give such a row to.
91
92`store.Label` and `store.Milestone` gain `OrgID int64` beside `RepoID`.
93
94## Resolution
95
96Store lookups that today take a `repoID` take the `store.Repo` and derive
97the scope: `repo_id = ?` for a user-owned repository, `repo_id = ? OR
98org_id = ?` with `repo.OwnerID` when `repo.OwnerKind == "org"`.
99
100- **Listing** for a repository returns org rows then repo rows, each by
101 name. `label list`, `milestone list` and the web pages mark org rows.
102- **Attaching** by name (`issue label --add`, `issue milestone`, `mr
103 milestone`) resolves the org row when one exists, else the repo row.
104 `issue label --add` still creates a repo label on the fly when neither
105 exists.
106- **Repo-level create** (`label set`, `milestone create`) is refused when
107 the org holds the name: `bug is an org label; set it with org label set
108 <org> bug`. Exit 1. `label remove`, `milestone close` and `milestone
109 reopen` refuse an org row the same way.
110- **Org-level create** when repositories under the org hold the name
111 promotes, in one transaction: insert the org row, repoint
112 `issue_labels.label_id` (or `issues.milestone_id` and
113 `merge_requests.milestone_id`) from each repo row to it, delete the repo
114 rows. The colour, description and due date are the ones on the command.
115 The reply names how many repositories were folded in.
116- **Filtering** (`--label`, `--milestone`, the web filters) resolves the
117 name the same way, so an org milestone filters a repository's list like
118 a repo one.
119- **Counting.** An org label's use count and an org milestone's open and
120 closed totals span the org's repositories the caller can read. The store
121 takes the readable repository ids the caller already gets for `repo
122 list` and restricts the count subqueries to `repo_id IN (...)`. An
123 anonymous web viewer counts public repositories only.
124
125## Commands
126
127One file, `internal/control/orglabel.go`.
128
129```
130org label set <org> <label> [--color rrggbb|'']
131org label list <org> ReadOnly
132org label remove <org> <label>
133org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]
134org milestone list <org> [--state open|closed|all] ReadOnly
135org milestone close <org> <title>
136org milestone reopen <org> <title>
137```
138
139Writes require org admin, via `OrgRole`, the gate `org members add` uses.
140Reads require membership or a public repository under the org; an outsider
141gets "denied", not "no organization", as `org show` answers today. `org
142label set` on an existing org label sets the colour. `org label remove`
143takes the label off every issue in the org through the existing cascade.
144
145JSON: `org label list` returns `[{name, color, uses}]`; `org milestone
146list` returns the milestone rows with `open` and `closed` counts, as
147`milestone list` does. Each command gets a `pass()` in `cmd/gitbay/main.go`.
148
149## Closes
150
151`closePat` gains an optional path prefix:
152
153```
154(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+(?:([a-z0-9][a-z0-9._-]*)/([a-z0-9][a-z0-9._-]*))?#(\d+)\b
155```
156
157`ProcessCommitMessages` and `ProcessMRDescription` resolve a prefixed match
158with `RepoByPath`, look up the actor's grant on the target with
159`AccessRole`, and act only if `policy.CanWrite`. An unknown path or a
160refused target does nothing and logs nothing above debug; the text remains
161an autolink only readers see. On success `actOnIssue` runs against the
162target: the issue closes, the system comment links the source commit by
163full path, the `issue.closed` event lands on the target's feed, and the
164once-per-(issue, sha) record applies. Bare `owner/name#N` without a
165keyword stays display-only.
166
167## Web
168
169- `GET /{owner}/-/labels` and `GET /{owner}/-/milestones` for an org,
170 rendered from `labels.html` and `milestones.html` with the org as scope
171 and no edit form, linked from the org page. 404 for a user owner, and
172 for an org the viewer cannot see any repository of.
173- Repository label and milestone pages show org rows with an "org" mark
174 and no edit control.
175- Issue and merge request lists, filters and the milestone picker need
176 only the store change; templates gain the mark.
177- No new event kinds; label and milestone changes are configuration.
178
179## Docs
180
181- Users: an "Org labels and milestones" paragraph after the milestones
182 one, and `Closes owner/name#N` in the commit-references paragraph.
183- Parity: rows for `org label set/list/remove`, `org milestone
184 create/list/close/reopen` (CLI yes, web read-only, API yes) and
185 cross-repo closes; org writes recorded as deliberately CLI-only.
186- FAQ: nothing, the question no longer needs a "not planned" answer.
187- CHANGELOG entry under the next version.
188
189## Tests
190
191- Store: migration 0052 over seeded repo labels and milestones attached
192 to issues and MRs, ids and memberships intact and `PRAGMA
193 foreign_key_check` empty; promote folding two
194 repositories' `bug` into one org row; repo-level create refused against
195 an org name; counts restricted to a readable set.
196- Control: per command, org admin versus member on writes, outsider
197 wording on reads; `issue label --add` resolving to the org row;
198 `--milestone` filtering by an org milestone in `issue list` and `mr
199 list`.
200- Closes, in `commitrefs_test.go`: prefixed close with write on the
201 target closes; without write leaves it open; unknown path ignored; plain
202 `#N` unchanged; once per issue and sha; the MR description path.
203- e2e, `e2e/orglabels_test.go`: an org with two repositories, `org label
204 set` then an issue in each carrying it, an org milestone with progress
205 across both, a push to one repository closing an issue in the other, the
206 two org pages for a member, and a private org's pages for an outsider.
207- `TestReadOnlyCommandsWriteNothing` and the CLI coverage test cover the
208 new commands without additions.
209
210## Rollout
211
212One MR. Migration 0052 runs on daemon start; the rebuild copies every row
213once and is fast at this scale. No config, no runner change, no client
214change. Ships in the next minor version, since it adds commands and a
215migration.
e2e/milestone_test.go +1 −1
@@ -53,7 +53,7 @@ func TestMilestonesAndTemplates(t *testing.T) {
53 "--description", "'first release'", "--due", "2027-01-01"); code != 0 { 53 "--description", "'first release'", "--due", "2027-01-01"); code != 0 {
54 t.Fatalf("milestone create: %s", errOut) 54 t.Fatalf("milestone create: %s", errOut)
55 } 55 }
56 if _, _, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v1.0"); code != 2 { 56 if _, _, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v1.0"); code != 1 {
57 t.Fatal("duplicate milestone accepted") 57 t.Fatal("duplicate milestone accepted")
58 } 58 }
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v2.0", "--due", "soon"); code != 2 || !strings.Contains(errOut, "YYYY-MM-DD") { 59 if _, errOut, code := inst.ssh(t, aliceKey, "", "milestone", "create", "alice/app", "v2.0", "--due", "soon"); code != 2 || !strings.Contains(errOut, "YYYY-MM-DD") {
e2e/orglabels_test.go added +118
@@ -0,0 +1,118 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// An org's labels and milestones reach every repository under it; a
11// commit in one repository closes an issue in another; the org pages
12// answer members and outsiders as their access allows.
13func TestOrgLabelsMilestonesAndCrossRepoCloses(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 carolKey := inst.newKey(t, "carol")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
18 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub", "--email", "carol@example.test", "--verified")
19 must := func(key string, args ...string) string {
20 t.Helper()
21 out, errOut, code := inst.ssh(t, key, "", args...)
22 if code != 0 {
23 t.Fatalf("%v: exit %d %s", args, code, errOut)
24 }
25 return out
26 }
27 must(aliceKey, "org", "create", "acme")
28 must(aliceKey, "repo", "create", "acme/lib")
29 must(aliceKey, "repo", "create", "acme/widget", "--private")
30 must(aliceKey, "issue", "create", "acme/lib", "--title", "'lib one'")
31 must(aliceKey, "issue", "create", "acme/widget", "--title", "'widget one'")
32
33 // Repo labels in both, then the org set folds them in.
34 must(aliceKey, "issue", "label", "acme/lib", "1", "--add", "bug")
35 must(aliceKey, "issue", "label", "acme/widget", "1", "--add", "bug")
36 out := must(aliceKey, "org", "label", "set", "acme", "bug", "--color", "ff0000", "--json")
37 if !strings.Contains(out, `"folded":2`) {
38 t.Fatalf("org label set: %s", out)
39 }
40 out = must(aliceKey, "label", "list", "acme/lib", "--json")
41 if !strings.Contains(out, `"org":true`) || !strings.Contains(out, `"issues":2`) {
42 t.Fatalf("lib label list: %s", out)
43 }
44 if _, errOut, code := inst.ssh(t, aliceKey, "", "label", "set", "acme/lib", "bug"); code == 0 || !strings.Contains(errOut, "org label set acme bug") {
45 t.Fatalf("repo label set over org name: exit %d %s", code, errOut)
46 }
47
48 // An org milestone attaches from both repositories and counts across.
49 must(aliceKey, "org", "milestone", "create", "acme", "v1", "--due", "2027-01-01")
50 must(aliceKey, "issue", "milestone", "acme/lib", "1", "v1")
51 must(aliceKey, "issue", "milestone", "acme/widget", "1", "v1")
52 out = must(aliceKey, "org", "milestone", "list", "acme", "--json")
53 if !strings.Contains(out, `"open":2`) {
54 t.Fatalf("org milestone list: %s", out)
55 }
56 out = must(aliceKey, "issue", "list", "acme/lib", "--milestone", "v1", "--json")
57 if !strings.Contains(out, `"number":1`) {
58 t.Fatalf("issue list filtered by org milestone: %s", out)
59 }
60
61 // A push to acme/lib closes acme/widget#1 and leaves a comment there.
62 work := t.TempDir()
63 env := inst.gitEnv(aliceKey)
64 mustGit(t, work, env, "clone", inst.sshURL("acme/lib"), "w")
65 dir := filepath.Join(work, "w")
66 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
67 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
68 mustGit(t, dir, env, "add", ".")
69 mustGit(t, dir, env, "commit", "-q", "-m", "fix the widget\n\nCloses acme/widget#1")
70 mustGit(t, dir, env, "push", "-q", "origin", "main")
71 out = must(aliceKey, "issue", "show", "acme/widget", "1", "--json")
72 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "](/acme/lib/commit/") {
73 t.Fatalf("widget#1 after cross-repo close: %s", out)
74 }
75 out = must(aliceKey, "org", "milestone", "list", "acme", "--json")
76 if !strings.Contains(out, `"open":1`) || !strings.Contains(out, `"closed":1`) {
77 t.Fatalf("org milestone progress after close: %s", out)
78 }
79
80 // carol is outside: her counts stop at acme/lib, the public one. The
81 // page checks below are anonymous, which acme/lib being public allows
82 // just the same.
83 out = must(carolKey, "org", "milestone", "list", "acme", "--json")
84 if !strings.Contains(out, `"open":1`) || !strings.Contains(out, `"closed":0`) {
85 t.Fatalf("outsider progress: %s", out)
86 }
87 if status, body := inst.get(t, "/acme/-/labels"); status != 200 || !strings.Contains(body, ">bug<") {
88 t.Fatalf("org labels page: %d", status)
89 }
90 if status, body := inst.get(t, "/acme/-/milestones"); status != 200 || !strings.Contains(body, "v1") || !strings.Contains(body, "0 closed, 1 open") {
91 t.Fatalf("org milestones page: %d\n%s", status, body)
92 }
93 if status, body := inst.get(t, "/acme/lib/labels"); status != 200 || !strings.Contains(body, `chip-neutral">org<`) {
94 t.Fatalf("repo labels page lacks the org mark: %d", status)
95 }
96 // carol cannot close into the private repo from a repo she owns.
97 must(carolKey, "repo", "create", "carol/own")
98 must(aliceKey, "issue", "create", "acme/widget", "--title", "'widget two'")
99 cwork := t.TempDir()
100 cenv := inst.gitEnv(carolKey)
101 mustGit(t, cwork, cenv, "clone", inst.sshURL("carol/own"), "w")
102 cdir := filepath.Join(cwork, "w")
103 os.WriteFile(filepath.Join(cdir, "a.txt"), []byte("a\n"), 0o644)
104 mustGit(t, cdir, cenv, "checkout", "-q", "-b", "main")
105 mustGit(t, cdir, cenv, "add", ".")
106 mustGit(t, cdir, cenv, "commit", "-q", "-m", "sneaky\n\nCloses acme/widget#2")
107 mustGit(t, cdir, cenv, "push", "-q", "origin", "main")
108 out = must(aliceKey, "issue", "show", "acme/widget", "2", "--json")
109 if !strings.Contains(out, `"state":"open"`) || strings.Contains(out, "sneaky") {
110 t.Fatalf("outsider acted on a private repo's issue: %s", out)
111 }
112 // With the public repo gone private, the org pages are not found for
113 // an anonymous reader.
114 must(aliceKey, "repo", "settings", "visibility", "acme/lib", "private")
115 if status, _ := inst.get(t, "/acme/-/labels"); status != 404 {
116 t.Fatalf("private org labels page for anonymous: %d", status)
117 }
118}
e2e/readonly_test.go +2
@@ -107,6 +107,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
107 "org members list": {"theorg"}, 107 "org members list": {"theorg"},
108 "org team list": {"theorg"}, 108 "org team list": {"theorg"},
109 "org team show": {"theorg", "core"}, 109 "org team show": {"theorg", "core"},
110 "org label list": {"theorg"},
111 "org milestone list": {"theorg"},
110 "repo search": {"app"}, 112 "repo search": {"app"},
111 "repo show": {"alice/app"}, 113 "repo show": {"alice/app"},
112 "repo access list": {"alice/app"}, 114 "repo access list": {"alice/app"},
internal/control/commitrefs.go +108 −32
@@ -8,49 +8,83 @@ import (
8 "strings" 8 "strings"
9 9
10 "gitbay.org/gitbay/internal/gitutil" 10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/store" 12 "gitbay.org/gitbay/internal/store"
12) 13)
13 14
14// closePat matches closing keywords; refPat matches any same-repo issue 15// closePat matches closing keywords, with an optional owner/name before
15// reference. Cross-repo references stay display-only (autolink) — acting 16// the number for an issue in another repository; refPat matches any bare
16// across repositories would need its own authorization story. 17// same-repo reference. A cross-repo close acts only when the actor holds
18// write on the target (closeTarget); a bare cross-repo reference stays
19// display-only.
17var ( 20var (
18 closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+#(\d+)\b`) 21 closePat = regexp.MustCompile(`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+(?:([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*))?#(\d+)\b`)
19 refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`) 22 refPat = regexp.MustCompile(`(^|[\s([{:])#(\d+)\b`)
20) 23)
21 24
25// closeRef is one closing reference: Path is "" for the same repository.
26type closeRef struct {
27 Path string
28 N int64
29}
30
22const maxMessageCommits = 100 31const maxMessageCommits = 100
23 32
24// ProcessCommitMessages acts on issue references in commits that just 33// ProcessCommitMessages acts on issue references in commits that just
25// landed on the default branch (old..new): closing keywords close the 34// landed on the default branch (old..new): closing keywords close the
26// issue, bare #N leaves a reference comment. Each (issue, sha) pair acts 35// issue, bare #N leaves a reference comment. Each (issue, sha) pair acts
27// at most once, ever. actorID — the pusher or merger — authorizes and 36// at most once, ever. actorID — the pusher or merger — authorizes and
28// signs the resulting comments; failures are logged, never fatal, because 37// signs the resulting comments, and scope is the key they used, which a
29// this runs after the push or merge already succeeded. 38// cross-repo close is checked against too; failures are logged, never
30func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, old, new string) { 39// fatal, because this runs after the push or merge already succeeded.
40func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, scope, old, new string) {
31 msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits) 41 msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits)
32 if err != nil { 42 if err != nil {
33 slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err) 43 slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err)
34 return 44 return
35 } 45 }
46 // Commits in one push name the same repositories over and over, and
47 // each resolution is three queries; keep the answers, refusals too.
48 resolved := map[string]struct {
49 repo store.Repo
50 ok bool
51 }{}
52 target := func(path string) (store.Repo, bool) {
53 if r, seen := resolved[path]; seen {
54 return r.repo, r.ok
55 }
56 t, ok := closeTarget(st, repo, actorID, scope, path)
57 resolved[path] = struct {
58 repo store.Repo
59 ok bool
60 }{t, ok}
61 return t, ok
62 }
36 for _, m := range msgs { 63 for _, m := range msgs {
37 closes := map[int64]bool{} 64 closes := closingRefs(m.Message)
38 for _, n := range closingRefs(m.Message) { 65 local := map[int64]bool{}
39 closes[n] = true 66 for _, ref := range closes {
67 if ref.Path == "" {
68 local[ref.N] = true
69 }
40 } 70 }
41 refs := map[int64]bool{} 71 refs := map[int64]bool{}
42 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) { 72 for _, g := range refPat.FindAllStringSubmatch(m.Message, -1) {
43 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !closes[n] { 73 if n, err := strconv.ParseInt(g[2], 10, 64); err == nil && !local[n] {
44 refs[n] = true 74 refs[n] = true
45 } 75 }
46 } 76 }
47 subject, _, _ := strings.Cut(m.Message, "\n") 77 subject, _, _ := strings.Cut(m.Message, "\n")
48 author := authorLink(st, m.AuthorName, m.AuthorEmail) 78 author := authorLink(st, m.AuthorName, m.AuthorEmail)
49 for n := range closes { 79 for _, ref := range closes {
50 actOnIssue(st, repo, actorID, m.SHA, n, true, subject, author) 80 t, ok := target(ref.Path)
81 if !ok {
82 continue
83 }
84 actOnIssue(st, repo, t, actorID, m.SHA, ref.N, true, subject, author)
51 } 85 }
52 for n := range refs { 86 for n := range refs {
53 actOnIssue(st, repo, actorID, m.SHA, n, false, subject, author) 87 actOnIssue(st, repo, repo, actorID, m.SHA, n, false, subject, author)
54 } 88 }
55 } 89 }
56} 90}
@@ -65,9 +99,13 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
65// key is per merge request rather than the merged sha, because sharing 99// key is per merge request rather than the merged sha, because sharing
66// the sha let a bare "#N" in a commit message claim it first and silently 100// the sha let a bare "#N" in a commit message claim it first and silently
67// suppress the close. 101// suppress the close.
68func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64) { 102func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64, scope string) {
69 for _, n := range closingRefs(mr.Title + "\n" + mr.Body) { 103 for _, ref := range closingRefs(mr.Title + "\n" + mr.Body) {
70 issue, err := st.IssueByNumber(repo.ID, n) 104 target, ok := closeTarget(st, repo, actorID, scope, ref.Path)
105 if !ok {
106 continue
107 }
108 issue, err := st.IssueByNumber(target.ID, ref.N)
71 if err != nil || issue.State != "open" { 109 if err != nil || issue.State != "open" {
72 continue // no such issue, or a commit already closed it 110 continue // no such issue, or a commit already closed it
73 } 111 }
@@ -76,14 +114,14 @@ func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID
76 continue // this merge request already acted on this issue 114 continue // this merge request already acted on this issue
77 } 115 }
78 if err := st.SetIssueState(issue.ID, "closed"); err != nil { 116 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
79 slog.Error("mr refs: closing issue", "issue", n, "err", err) 117 slog.Error("mr refs: closing issue", "issue", ref.N, "err", err)
80 continue 118 continue
81 } 119 }
82 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number) 120 link := fmt.Sprintf("[!%d](/%s/mrs/%d)", mr.Number, repo.Path(), mr.Number)
83 st.AddIssueSystemComment(issue.ID, actorID, 121 st.AddIssueSystemComment(issue.ID, actorID,
84 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title)) 122 fmt.Sprintf("closed by merge request %s: %s", link, mr.Title))
85 st.RecordEvent(repo.ID, actorID, "issue.closed", 123 st.RecordEvent(target.ID, actorID, "issue.closed",
86 fmt.Sprintf(`{"number":%d,"mr":%d}`, n, mr.Number)) 124 fmt.Sprintf(`{"number":%d,"mr":%d}`, ref.N, mr.Number))
87 } 125 }
88} 126}
89 127
@@ -93,21 +131,59 @@ func mrRefKey(number int64) string {
93 return fmt.Sprintf("mr-%d", number) 131 return fmt.Sprintf("mr-%d", number)
94} 132}
95 133
96// closingRefs returns the issue numbers a text closes, in no order. 134// closingRefs returns the references a text closes, in no order.
97func closingRefs(text string) []int64 { 135func closingRefs(text string) []closeRef {
98 seen := map[int64]bool{} 136 seen := map[closeRef]bool{}
99 var out []int64 137 var out []closeRef
100 for _, g := range closePat.FindAllStringSubmatch(text, -1) { 138 for _, g := range closePat.FindAllStringSubmatch(text, -1) {
101 n, err := strconv.ParseInt(g[1], 10, 64) 139 n, err := strconv.ParseInt(g[2], 10, 64)
102 if err != nil || seen[n] { 140 if err != nil {
103 continue 141 continue
104 } 142 }
105 seen[n] = true 143 ref := closeRef{Path: strings.ToLower(g[1]), N: n}
106 out = append(out, n) 144 if seen[ref] {
145 continue
146 }
147 seen[ref] = true
148 out = append(out, ref)
107 } 149 }
108 return out 150 return out
109} 151}
110 152
153// closeTarget resolves where a closing reference acts: the source
154// repository for a bare #N, or the named repository when the actor holds
155// write there with a key whose scope reaches it. false means the
156// reference stays text; nothing is logged above debug, since a refusal
157// must not confirm the target exists.
158func closeTarget(st *store.Store, source store.Repo, actorID int64, scope, path string) (store.Repo, bool) {
159 if path == "" {
160 return source, true
161 }
162 target, err := st.RepoByPath(path)
163 if err != nil {
164 return store.Repo{}, false
165 }
166 actor, err := st.UserByID(actorID)
167 if err != nil {
168 return store.Repo{}, false
169 }
170 grant, err := st.AccessRole(target.ID, actorID)
171 if err != nil {
172 return store.Repo{}, false
173 }
174 // The account's access and the key's reach both have to hold: a deploy
175 // key is bound to one repository and inherits nothing from whoever
176 // registered it, so its scope allows no write anywhere else.
177 if !policy.CanWrite(actor, target, grant) || !policy.ScopeAllowsGit(scope, target.Path(), true) {
178 slog.Debug("commit refs: cross-repo close refused", "source", source.Path(), "target", path)
179 return store.Repo{}, false
180 }
181 if target.Settings.Archived {
182 return store.Repo{}, false
183 }
184 return target, true
185}
186
111// RecordLandedCommits attributes commits that just landed on the default 187// RecordLandedCommits attributes commits that just landed on the default
112// branch to accounts by verified author email, for the activity graph. 188// branch to accounts by verified author email, for the activity graph.
113// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable 189// Dedup by (repo, sha) makes rebases and re-runs harmless; unresolvable
@@ -137,8 +213,8 @@ func authorLink(st *store.Store, name, email string) string {
137 return name 213 return name
138} 214}
139 215
140func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) { 216func actOnIssue(st *store.Store, source, target store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
141 issue, err := st.IssueByNumber(repo.ID, number) 217 issue, err := st.IssueByNumber(target.ID, number)
142 if err != nil { 218 if err != nil {
143 return // no such issue: the reference is just text 219 return // no such issue: the reference is just text
144 } 220 }
@@ -152,14 +228,14 @@ func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, num
152 } 228 }
153 // Informational system entries, not comments from the pusher; the 229 // Informational system entries, not comments from the pusher; the
154 // linked sha renders clickable on the web. 230 // linked sha renders clickable on the web.
155 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, repo.Path(), sha) 231 link := fmt.Sprintf("[%s](/%s/commit/%s)", short, source.Path(), sha)
156 if close && issue.State == "open" { 232 if close && issue.State == "open" {
157 if err := st.SetIssueState(issue.ID, "closed"); err != nil { 233 if err := st.SetIssueState(issue.ID, "closed"); err != nil {
158 slog.Error("commit refs: closing issue", "issue", number, "err", err) 234 slog.Error("commit refs: closing issue", "issue", number, "err", err)
159 return 235 return
160 } 236 }
161 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject)) 237 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
162 st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) 238 st.RecordEvent(target.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
163 return 239 return
164 } 240 }
165 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject)) 241 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
internal/control/commitrefs_test.go +71 −8
@@ -1,8 +1,12 @@
1package control 1package control
2 2
3import ( 3import (
4 "fmt"
4 "slices" 5 "slices"
6 "strings"
5 "testing" 7 "testing"
8
9 "gitbay.org/gitbay/internal/store"
6) 10)
7 11
8// The same keyword set has to work wherever the intent is written: a 12// The same keyword set has to work wherever the intent is written: a
@@ -11,23 +15,82 @@ func TestClosingRefs(t *testing.T) {
11 for _, tc := range []struct { 15 for _, tc := range []struct {
12 name string 16 name string
13 text string 17 text string
14 want []int64 18 want []closeRef
15 }{ 19 }{
16 {"closes", "Closes #50", []int64{50}}, 20 {"closes", "Closes #50", []closeRef{{"", 50}}},
17 {"lowercase and fix", "fixes #7", []int64{7}}, 21 {"lowercase and fix", "fixes #7", []closeRef{{"", 7}}},
18 {"resolved", "resolved: #12", []int64{12}}, 22 {"resolved", "resolved: #12", []closeRef{{"", 12}}},
19 {"several", "Closes #1\n\nAlso fixes #2 and resolves #3", []int64{1, 2, 3}}, 23 {"several", "Closes #1\n\nAlso fixes #2 and resolves #3", []closeRef{{"", 1}, {"", 2}, {"", 3}}},
20 {"repeats collapse", "closes #4, closes #4", []int64{4}}, 24 {"repeats collapse", "closes #4, closes #4", []closeRef{{"", 4}}},
21 {"bare references do not close", "see #9 for context", nil}, 25 {"bare references do not close", "see #9 for context", nil},
22 {"cross-repo stays display-only", "closes krz/other#3", nil}, 26 {"cross-repo carries the path", "closes krz/other#3", []closeRef{{"krz/other", 3}}},
27 {"same number in two repos", "closes #3, closes krz/other#3", []closeRef{{"", 3}, {"krz/other", 3}}},
23 {"keyword must be its own word", "unclosed #5", nil}, 28 {"keyword must be its own word", "unclosed #5", nil},
24 } { 29 } {
25 t.Run(tc.name, func(t *testing.T) { 30 t.Run(tc.name, func(t *testing.T) {
26 got := closingRefs(tc.text) 31 got := closingRefs(tc.text)
27 slices.Sort(got) 32 slices.SortFunc(got, func(a, b closeRef) int {
33 if a.Path != b.Path {
34 return strings.Compare(a.Path, b.Path)
35 }
36 return int(a.N - b.N)
37 })
28 if !slices.Equal(got, tc.want) { 38 if !slices.Equal(got, tc.want) {
29 t.Errorf("closingRefs(%q) = %v, want %v", tc.text, got, tc.want) 39 t.Errorf("closingRefs(%q) = %v, want %v", tc.text, got, tc.want)
30 } 40 }
31 }) 41 })
32 } 42 }
33} 43}
44
45// A merged merge request's description closes an issue in another
46// repository only when the merger holds write there. This drives the
47// same target resolution the commit path uses, without needing git.
48func TestMRDescriptionClosesAcrossRepos(t *testing.T) {
49 f := newOrgFixture(t)
50 libIssue, _ := f.st.CreateIssue(f.priv.ID, f.alice, "in priv", "", "md")
51 appIssue, _ := f.st.CreateIssue(f.app.ID, f.alice, "in app", "", "md")
52 _ = libIssue
53 _ = appIssue
54 mr := func(n int64, title string) store.MR {
55 return store.MR{Number: n, Title: title, Body: ""}
56 }
57 // carol cannot write acme/priv: the issue stays open and no comment
58 // lands.
59 ProcessMRDescription(f.st, f.app, mr(1, "Closes acme/priv#1"), f.carol, "full")
60 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
61 t.Fatal("outsider closed a private repo's issue")
62 }
63 // A deploy key on alice/app is bound to alice/app: alice's own access
64 // to acme/priv is not the key's to use.
65 deploy := fmt.Sprintf("deploy:%d:rw", f.app.ID)
66 ProcessMRDescription(f.st, f.app, mr(2, "Closes acme/priv#1"), f.alice, deploy)
67 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
68 t.Fatal("a deploy key closed an issue outside its binding")
69 }
70 // An archived target is read-only, cross-repo closes included.
71 if _, err := f.st.UpdateRepoSettings(f.priv.ID, func(rs *store.RepoSettings) { rs.Archived = true }); err != nil {
72 t.Fatal(err)
73 }
74 ProcessMRDescription(f.st, f.app, mr(3, "Closes acme/priv#1"), f.alice, "full")
75 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
76 t.Fatal("an archived repository's issue was closed")
77 }
78 if _, err := f.st.UpdateRepoSettings(f.priv.ID, func(rs *store.RepoSettings) { rs.Archived = false }); err != nil {
79 t.Fatal(err)
80 }
81 // alice can: it closes with a comment naming the source repository.
82 ProcessMRDescription(f.st, f.app, mr(4, "Closes acme/priv#1"), f.alice, "full")
83 iss, _ := f.st.IssueByNumber(f.priv.ID, 1)
84 if iss.State != "closed" {
85 t.Fatal("writer did not close across repos")
86 }
87 comments, _ := f.st.ListIssueComments(iss.ID)
88 if len(comments) != 1 || !strings.Contains(comments[0].Body, "(/alice/app/mrs/4)") {
89 t.Fatalf("close comment = %+v", comments)
90 }
91 // An unknown path is text; a bare #N still acts in the source repo.
92 ProcessMRDescription(f.st, f.app, mr(5, "Closes nobody/nothing#1 and closes #1"), f.alice, "full")
93 if iss, _ := f.st.IssueByNumber(f.app.ID, 1); iss.State != "closed" {
94 t.Fatal("bare #N stopped working")
95 }
96}
internal/control/ghimport.go +1 −1
@@ -256,7 +256,7 @@ func runImportIssues(c *Ctx, args []string) int {
256 return c.fail(protocol.ExitFailure, "%v", err) 256 return c.fail(protocol.ExitFailure, "%v", err)
257 } 257 }
258 for _, l := range it.Labels { 258 for _, l := range it.Labels {
259 c.Store.SetIssueLabel(repo.ID, iss.ID, l.Name, true) 259 c.Store.SetIssueLabel(repo, iss.ID, l.Name, true)
260 } 260 }
261 if it.State != "open" { 261 if it.State != "open" {
262 c.Store.SetIssueState(iss.ID, "closed") 262 c.Store.SetIssueState(iss.ID, "closed")
internal/control/issue.go +2 −2
@@ -388,12 +388,12 @@ func runIssueLabel(c *Ctx, args []string) int {
388 return code 388 return code
389 } 389 }
390 for _, l := range adds { 390 for _, l := range adds {
391 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, true); err != nil { 391 if err := c.Store.SetIssueLabel(repo, issue.ID, l, true); err != nil {
392 return c.fail(protocol.ExitFailure, "%v", err) 392 return c.fail(protocol.ExitFailure, "%v", err)
393 } 393 }
394 } 394 }
395 for _, l := range removes { 395 for _, l := range removes {
396 if err := c.Store.SetIssueLabel(repo.ID, issue.ID, l, false); err != nil { 396 if err := c.Store.SetIssueLabel(repo, issue.ID, l, false); err != nil {
397 if errors.Is(err, store.ErrNotFound) { 397 if errors.Is(err, store.ErrNotFound) {
398 return c.fail(protocol.ExitNotFound, "%v", err) 398 return c.fail(protocol.ExitNotFound, "%v", err)
399 } 399 }
internal/control/label.go +16 −10
@@ -37,13 +37,17 @@ func runLabelList(c *Ctx, args []string) int {
37 if code >= 0 { 37 if code >= 0 {
38 return code 38 return code
39 } 39 }
40 labels, err := c.Store.ListLabels(repo.ID) 40 readable, err := ReadableScope(c.Store, c.User, repo)
41 if err != nil {
42 return c.fail(protocol.ExitFailure, "%v", err)
43 }
44 labels, err := c.Store.ListLabels(repo, readable)
41 if err != nil { 45 if err != nil {
42 return c.fail(protocol.ExitFailure, "%v", err) 46 return c.fail(protocol.ExitFailure, "%v", err)
43 } 47 }
44 return c.emit(labels, func(w io.Writer) { 48 return c.emit(labels, func(w io.Writer) {
45 for _, l := range labels { 49 for _, l := range labels {
46 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues) 50 fmt.Fprintf(w, "%s\t%s\t%d%s\n", l.Name, l.Color, l.Issues, map[bool]string{true: "\torg"}[l.Org])
47 } 51 }
48 }) 52 })
49} 53}
@@ -78,15 +82,14 @@ func runLabelSet(c *Ctx, args []string) int {
78 } 82 }
79 if !colorSet { 83 if !colorSet {
80 // Keep the colour it has, if any; this is "make sure it exists". 84 // Keep the colour it has, if any; this is "make sure it exists".
81 if labels, err := c.Store.ListLabels(repo.ID); err == nil { 85 if l, err := c.Store.LabelByName(repo, name); err == nil && !l.Org {
82 for _, l := range labels { 86 color = l.Color
83 if l.Name == name {
84 color = l.Color
85 }
86 }
87 } 87 }
88 } 88 }
89 if err := c.Store.SetLabel(repo.ID, name, color); err != nil { 89 if err := c.Store.SetLabel(repo, name, color); err != nil {
90 if errors.Is(err, store.ErrOrgScoped) {
91 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", name, "set"))
92 }
90 return c.fail(protocol.ExitFailure, "%v", err) 93 return c.fail(protocol.ExitFailure, "%v", err)
91 } 94 }
92 return c.emit(store.Label{Name: name, Color: color}, func(w io.Writer) { 95 return c.emit(store.Label{Name: name, Color: color}, func(w io.Writer) {
@@ -109,7 +112,10 @@ func runLabelRemove(c *Ctx, args []string) int {
109 if code := refuseArchived(c, repo); code >= 0 { 112 if code := refuseArchived(c, repo); code >= 0 {
110 return code 113 return code
111 } 114 }
112 if err := c.Store.DeleteLabel(repo.ID, args[1]); err != nil { 115 if err := c.Store.DeleteLabel(repo, args[1]); err != nil {
116 if errors.Is(err, store.ErrOrgScoped) {
117 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "label", args[1], "remove"))
118 }
113 if errors.Is(err, store.ErrNotFound) { 119 if errors.Is(err, store.ErrNotFound) {
114 return c.fail(protocol.ExitNotFound, "no label %q in %s", args[1], repo.Path()) 120 return c.fail(protocol.ExitNotFound, "no label %q in %s", args[1], repo.Path())
115 } 121 }
internal/control/migrate.go +1 −1
@@ -247,7 +247,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
247 return c.fail(protocol.ExitFailure, "%v", err) 247 return c.fail(protocol.ExitFailure, "%v", err)
248 } 248 }
249 for _, l := range bi.Labels { 249 for _, l := range bi.Labels {
250 c.Store.SetIssueLabel(repo.ID, iss.ID, l, true) 250 c.Store.SetIssueLabel(repo, iss.ID, l, true)
251 } 251 }
252 if bi.State != "open" { 252 if bi.State != "open" {
253 c.Store.SetIssueState(iss.ID, "closed") 253 c.Store.SetIssueState(iss.ID, "closed")
internal/control/milestone.go +29 −7
@@ -60,8 +60,13 @@ func runMilestoneCreate(c *Ctx, args []string) int {
60 if code := refuseArchived(c, repo); code >= 0 { 60 if code := refuseArchived(c, repo); code >= 0 {
61 return code 61 return code
62 } 62 }
63 if _, err := c.Store.CreateMilestone(repo.ID, title, description, due); err != nil { 63 if _, err := c.Store.CreateMilestone(repo, title, description, due); err != nil {
64 return c.failErr(err) 64 if errors.Is(err, store.ErrOrgScoped) {
65 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", title, "create"))
66 }
67 // A duplicate title is a failure, not a usage error, which is what
68 // failErr would make of it; org milestone create answers the same.
69 return c.fail(protocol.ExitFailure, "%v", err)
65 } 70 }
66 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) { 71 return c.emit(map[string]string{"milestone": title}, func(w io.Writer) {
67 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path()) 72 fmt.Fprintf(w, "created milestone %q on %s\n", title, repo.Path())
@@ -84,21 +89,31 @@ func runMilestoneList(c *Ctx, args []string) int {
84 if code >= 0 { 89 if code >= 0 {
85 return code 90 return code
86 } 91 }
87 ms, err := c.Store.ListMilestones(repo.ID, state) 92 readable, err := ReadableScope(c.Store, c.User, repo)
93 if err != nil {
94 return c.fail(protocol.ExitFailure, "%v", err)
95 }
96 ms, err := c.Store.ListMilestones(repo, state, readable)
88 if err != nil { 97 if err != nil {
89 return c.fail(protocol.ExitFailure, "%v", err) 98 return c.fail(protocol.ExitFailure, "%v", err)
90 } 99 }
100 return emitMilestones(c, ms)
101}
102
103// emitMilestones renders a milestone list for the caller, JSON or plain.
104func emitMilestones(c *Ctx, ms []store.Milestone) int {
91 type out struct { 105 type out struct {
92 Title string `json:"title"` 106 Title string `json:"title"`
93 Description string `json:"description,omitempty"` 107 Description string `json:"description,omitempty"`
94 Due string `json:"due,omitempty"` 108 Due string `json:"due,omitempty"`
95 State string `json:"state"` 109 State string `json:"state"`
110 Org bool `json:"org,omitempty"`
96 Open int `json:"open"` 111 Open int `json:"open"`
97 Closed int `json:"closed"` 112 Closed int `json:"closed"`
98 } 113 }
99 var ds []out 114 var ds []out
100 for _, m := range ms { 115 for _, m := range ms {
101 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OpenItems, m.ClosedItems}) 116 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
102 } 117 }
103 return c.emit(ds, func(w io.Writer) { 118 return c.emit(ds, func(w io.Writer) {
104 for _, d := range ds { 119 for _, d := range ds {
@@ -106,7 +121,11 @@ func runMilestoneList(c *Ctx, args []string) int {
106 if due == "" { 121 if due == "" {
107 due = "-" 122 due = "-"
108 } 123 }
109 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed\n", d.Title, d.State, due, d.Open, d.Closed) 124 mark := ""
125 if d.Org {
126 mark = "\torg"
127 }
128 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed%s\n", d.Title, d.State, due, d.Open, d.Closed, mark)
110 } 129 }
111 }) 130 })
112} 131}
@@ -129,10 +148,13 @@ func setMilestoneState(c *Ctx, args []string, state string) int {
129 if code := refuseArchived(c, repo); code >= 0 { 148 if code := refuseArchived(c, repo); code >= 0 {
130 return code 149 return code
131 } 150 }
132 m, err := c.Store.MilestoneByTitle(repo.ID, args[1]) 151 m, err := c.Store.MilestoneByTitle(repo, args[1])
133 if err != nil { 152 if err != nil {
134 return milestoneErr(c, repo, args[1], err) 153 return milestoneErr(c, repo, args[1], err)
135 } 154 }
155 if m.OrgID != 0 {
156 return c.fail(protocol.ExitFailure, "%s", orgScopedMsg(repo, "milestone", m.Title, verb))
157 }
136 if err := c.Store.SetMilestoneState(m.ID, state); err != nil { 158 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
137 return c.fail(protocol.ExitFailure, "%v", err) 159 return c.fail(protocol.ExitFailure, "%v", err)
138 } 160 }
@@ -184,7 +206,7 @@ func runMRMilestone(c *Ctx, args []string) int {
184func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int { 206func setItemMilestone(c *Ctx, repo store.Repo, noun string, number int64, title string, set func(int64) error) int {
185 var id int64 207 var id int64
186 if title != "none" { 208 if title != "none" {
187 m, err := c.Store.MilestoneByTitle(repo.ID, title) 209 m, err := c.Store.MilestoneByTitle(repo, title)
188 if err != nil { 210 if err != nil {
189 return milestoneErr(c, repo, title, err) 211 return milestoneErr(c, repo, title, err)
190 } 212 }
internal/control/mr.go +2 −2
@@ -1208,8 +1208,8 @@ func runMRMerge(c *Ctx, args []string) int {
1208 // description is scanned after them, so a commit wins the attribution 1208 // description is scanned after them, so a commit wins the attribution
1209 // when both name the same issue. 1209 // when both name the same issue.
1210 if mr.TargetRef == repo.DefaultBranch { 1210 if mr.TargetRef == repo.DefaultBranch {
1211 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) 1211 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, targetSHA, newSHA)
1212 ProcessMRDescription(c.Store, repo, mr, c.User.ID) 1212 ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope)
1213 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) 1213 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1214 } 1214 }
1215 // A merge moves the ref directly, so it never reaches post-receive and 1215 // A merge moves the ref directly, so it never reaches post-receive and
internal/control/orglabel.go added +241
@@ -0,0 +1,241 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"org", "label", "set"},
15 Summary: "create an org label every org repository sees, or set its colour; folds in same-named repo labels",
16 Usage: "org label set <org> <label> [--color rrggbb|'']", Run: runOrgLabelSet})
17 register(Command{Path: []string{"org", "label", "list"},
18 Summary: "list an org's labels with use across the repositories you can read",
19 Usage: "org label list <org>", ReadOnly: true, Run: runOrgLabelList})
20 register(Command{Path: []string{"org", "label", "remove"},
21 Summary: "remove an org label from the org and from every issue under it",
22 Usage: "org label remove <org> <label>", Run: runOrgLabelRemove})
23 register(Command{Path: []string{"org", "milestone", "create"},
24 Summary: "create an org milestone spanning every org repository; folds in same-titled repo milestones",
25 Usage: "org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]", Run: runOrgMilestoneCreate})
26 register(Command{Path: []string{"org", "milestone", "list"},
27 Summary: "list an org's milestones with progress across the repositories you can read",
28 Usage: "org milestone list <org> [--state open|closed|all]", ReadOnly: true, Run: runOrgMilestoneList})
29 register(Command{Path: []string{"org", "milestone", "close"},
30 Summary: "close an org milestone",
31 Usage: "org milestone close <org> <title>", Run: runOrgMilestoneClose})
32 register(Command{Path: []string{"org", "milestone", "reopen"},
33 Summary: "reopen an org milestone",
34 Usage: "org milestone reopen <org> <title>", Run: runOrgMilestoneReopen})
35}
36
37// orgReader resolves an org for a read of its labels or milestones.
38// Members read; an outsider reads when some repository under the org is
39// readable, and is refused rather than told the org is missing otherwise,
40// since an org's existence is public anyway. The readable ids come back
41// because every read counts over them.
42func orgReader(c *Ctx, name string) (store.Org, []int64, int) {
43 org, err := c.Store.OrgByName(name)
44 if errors.Is(err, store.ErrNotFound) {
45 return org, nil, c.fail(protocol.ExitNotFound, "no organization %q", name)
46 }
47 if err != nil {
48 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
49 }
50 readable, err := ReadableOrgRepoIDs(c.Store, c.User, org.ID)
51 if err != nil {
52 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
53 }
54 role, err := c.Store.OrgRole(org.ID, c.User.ID)
55 if err != nil {
56 return org, nil, c.fail(protocol.ExitFailure, "%v", err)
57 }
58 if role == "" && len(readable) == 0 {
59 return org, nil, c.fail(protocol.ExitDenied, "labels and milestones of %s are visible to its members", name)
60 }
61 return org, readable, -1
62}
63
64func runOrgLabelSet(c *Ctx, args []string) int {
65 const usage = "usage: org label set <org> <label> [--color rrggbb|'']"
66 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: usage})
67 if err != nil {
68 return c.fail(protocol.ExitUsage, "%v", err)
69 }
70 orgName, name := f.pos(0), f.pos(1)
71 color, colorSet := strings.ToLower(f.Value("--color")), f.Has("--color")
72 if orgName == "" || name == "" {
73 return c.fail(protocol.ExitUsage, usage)
74 }
75 if name == "" || len(name) > 50 {
76 return c.fail(protocol.ExitUsage, "a label is 1 to 50 characters")
77 }
78 if colorSet && color != "" {
79 if !labelColorPat.MatchString(color) {
80 return c.fail(protocol.ExitUsage, "--color takes rrggbb (with or without #), or '' to clear")
81 }
82 color = "#" + strings.TrimPrefix(color, "#")
83 }
84 org, code := orgAdmin(c, orgName)
85 if code >= 0 {
86 return code
87 }
88 if !colorSet {
89 // Keep the colour it has, if any; this is "make sure it exists".
90 if labels, err := c.Store.ListOrgLabels(org.ID, nil); err == nil {
91 for _, l := range labels {
92 if l.Name == name {
93 color = l.Color
94 }
95 }
96 }
97 }
98 folded, err := c.Store.SetOrgLabel(org.ID, name, color)
99 if err != nil {
100 return c.fail(protocol.ExitFailure, "%v", err)
101 }
102 return c.emit(struct {
103 Name string `json:"name"`
104 Color string `json:"color,omitempty"`
105 Folded int `json:"folded"`
106 }{name, color, folded}, func(w io.Writer) {
107 if color == "" {
108 fmt.Fprintf(w, "org label %s on %s, no colour set", name, org.Name)
109 } else {
110 fmt.Fprintf(w, "org label %s on %s is %s", name, org.Name, color)
111 }
112 if folded > 0 {
113 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
114 }
115 fmt.Fprintln(w)
116 })
117}
118
119func runOrgLabelList(c *Ctx, args []string) int {
120 if len(args) != 1 {
121 return c.fail(protocol.ExitUsage, "usage: org label list <org>")
122 }
123 org, readable, code := orgReader(c, args[0])
124 if code >= 0 {
125 return code
126 }
127 labels, err := c.Store.ListOrgLabels(org.ID, readable)
128 if err != nil {
129 return c.fail(protocol.ExitFailure, "%v", err)
130 }
131 return c.emit(labels, func(w io.Writer) {
132 for _, l := range labels {
133 fmt.Fprintf(w, "%s\t%s\t%d\n", l.Name, l.Color, l.Issues)
134 }
135 })
136}
137
138func runOrgLabelRemove(c *Ctx, args []string) int {
139 if len(args) != 2 {
140 return c.fail(protocol.ExitUsage, "usage: org label remove <org> <label>")
141 }
142 org, code := orgAdmin(c, args[0])
143 if code >= 0 {
144 return code
145 }
146 if err := c.Store.DeleteOrgLabel(org.ID, args[1]); err != nil {
147 if errors.Is(err, store.ErrNotFound) {
148 return c.fail(protocol.ExitNotFound, "no org label %q on %s", args[1], org.Name)
149 }
150 return c.fail(protocol.ExitFailure, "%v", err)
151 }
152 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
153 fmt.Fprintf(w, "removed org label %s from %s\n", args[1], org.Name)
154 })
155}
156
157func runOrgMilestoneCreate(c *Ctx, args []string) int {
158 const usage = "usage: org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]"
159 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: usage})
160 if err != nil {
161 return c.fail(protocol.ExitUsage, "%v", err)
162 }
163 orgName, title, description, due := f.pos(0), f.pos(1), f.Value("--description"), f.Value("--due")
164 if orgName == "" || title == "" {
165 return c.fail(protocol.ExitUsage, usage)
166 }
167 if due != "" && !duePat.MatchString(due) {
168 return c.fail(protocol.ExitUsage, "--due must be YYYY-MM-DD")
169 }
170 org, code := orgAdmin(c, orgName)
171 if code >= 0 {
172 return code
173 }
174 _, folded, err := c.Store.CreateOrgMilestone(org.ID, title, description, due)
175 if err != nil {
176 return c.fail(protocol.ExitFailure, "%v", err)
177 }
178 return c.emit(struct {
179 Milestone string `json:"milestone"`
180 Folded int `json:"folded"`
181 }{title, folded}, func(w io.Writer) {
182 fmt.Fprintf(w, "created org milestone %q on %s", title, org.Name)
183 if folded > 0 {
184 fmt.Fprintf(w, "; folded in %d repositor%s", folded, map[bool]string{true: "y", false: "ies"}[folded == 1])
185 }
186 fmt.Fprintln(w)
187 })
188}
189
190func runOrgMilestoneList(c *Ctx, args []string) int {
191 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
192 if err != nil {
193 return c.fail(protocol.ExitUsage, "%v", err)
194 }
195 state, orgName := "open", f.pos(0)
196 if f.Has("--state") {
197 state = f.Value("--state")
198 }
199 if orgName == "" || (state != "open" && state != "closed" && state != "all") {
200 return c.fail(protocol.ExitUsage, "usage: org milestone list <org> [--state open|closed|all]")
201 }
202 org, readable, code := orgReader(c, orgName)
203 if code >= 0 {
204 return code
205 }
206 ms, err := c.Store.ListOrgMilestones(org.ID, state, readable)
207 if err != nil {
208 return c.fail(protocol.ExitFailure, "%v", err)
209 }
210 return emitMilestones(c, ms)
211}
212
213func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") }
214func runOrgMilestoneReopen(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "open") }
215
216func setOrgMilestoneState(c *Ctx, args []string, state string) int {
217 verb := "close"
218 if state == "open" {
219 verb = "reopen"
220 }
221 if len(args) != 2 {
222 return c.fail(protocol.ExitUsage, "usage: org milestone %s <org> <title>", verb)
223 }
224 org, code := orgAdmin(c, args[0])
225 if code >= 0 {
226 return code
227 }
228 m, err := c.Store.OrgMilestoneByTitle(org.ID, args[1])
229 if errors.Is(err, store.ErrNotFound) {
230 return c.fail(protocol.ExitNotFound, "no org milestone %q on %s", args[1], org.Name)
231 }
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 if err := c.Store.SetMilestoneState(m.ID, state); err != nil {
236 return c.fail(protocol.ExitFailure, "%v", err)
237 }
238 return c.emit(map[string]string{"milestone": m.Title, "state": state}, func(w io.Writer) {
239 fmt.Fprintf(w, "%sd org milestone %q on %s\n", verb, m.Title, org.Name)
240 })
241}
internal/control/orglabel_test.go added +125
@@ -0,0 +1,125 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10func TestOrgLabelSetListRemove(t *testing.T) {
11 f := newOrgFixture(t)
12 // Two repos already hold bug; the org set folds them in.
13 f.st.SetLabel(f.core, "bug", "")
14 f.st.SetLabel(f.priv, "bug", "")
15 c, out := f.ctx(f.alice)
16 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "ff0000"}); code != protocol.ExitOK ||
17 !strings.Contains(out.String(), `"folded":2`) {
18 t.Fatalf("set: exit %d %s", code, out.String())
19 }
20 out.Reset()
21 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK ||
22 !strings.Contains(out.String(), `"name":"bug"`) || !strings.Contains(out.String(), `"color":"#ff0000"`) {
23 t.Fatalf("list: exit %d %s", code, out.String())
24 }
25 out.Reset()
26 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitOK {
27 t.Fatalf("remove: exit %d %s", code, out.String())
28 }
29 out.Reset()
30 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitNotFound {
31 t.Fatalf("second remove: exit %d %s", code, out.String())
32 }
33}
34
35func TestOrgLabelWritesNeedOrgAdmin(t *testing.T) {
36 f := newOrgFixture(t)
37 c, out := f.ctx(f.bob)
38 if code := runOrgLabelSet(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
39 t.Fatalf("member set: exit %d %s", code, out.String())
40 }
41 if code := runOrgLabelRemove(c, []string{"acme", "bug"}); code != protocol.ExitDenied {
42 t.Fatalf("member remove: exit %d %s", code, out.String())
43 }
44 c, out = f.ctx(f.alice)
45 if code := runOrgLabelSet(c, []string{"nope", "bug"}); code != protocol.ExitNotFound {
46 t.Fatalf("missing org: exit %d %s", code, out.String())
47 }
48 if code := runOrgLabelSet(c, []string{"acme", "bug", "--color", "zz"}); code != protocol.ExitUsage {
49 t.Fatalf("bad colour: exit %d %s", code, out.String())
50 }
51}
52
53func TestOrgLabelListVisibility(t *testing.T) {
54 f := newOrgFixture(t)
55 f.st.SetOrgLabel(f.org, "bug", "")
56 // Members read; an outsider reads because acme/core is public.
57 for _, uid := range []int64{f.bob, f.carol} {
58 c, out := f.ctx(uid)
59 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitOK {
60 t.Fatalf("user %d list: exit %d %s", uid, code, out.String())
61 }
62 }
63 // With every repo private, the outsider is refused, not told the org
64 // is missing.
65 f.st.SetRepoVisibility(f.core.ID, "private")
66 c, out := f.ctx(f.carol)
67 if code := runOrgLabelList(c, []string{"acme"}); code != protocol.ExitDenied ||
68 !strings.Contains(out.String(), "visible to its members") {
69 t.Fatalf("outsider list: exit %d %s", code, out.String())
70 }
71}
72
73func TestOrgMilestoneLifecycle(t *testing.T) {
74 f := newOrgFixture(t)
75 f.st.CreateMilestone(f.core, "v1", "", "")
76 c, out := f.ctx(f.alice)
77 if code := runOrgMilestoneCreate(c, []string{"acme", "v1", "--due", "2027-01-01"}); code != protocol.ExitOK ||
78 !strings.Contains(out.String(), `"folded":1`) {
79 t.Fatalf("create: exit %d %s", code, out.String())
80 }
81 out.Reset()
82 if code := runOrgMilestoneCreate(c, []string{"acme", "v1"}); code != protocol.ExitFailure {
83 t.Fatalf("duplicate create: exit %d %s", code, out.String())
84 }
85 out.Reset()
86 if code := runOrgMilestoneCreate(c, []string{"acme", "v2", "--due", "soon"}); code != protocol.ExitUsage {
87 t.Fatalf("bad due: exit %d %s", code, out.String())
88 }
89 out.Reset()
90 // An issue in each repo attaches by title; progress spans both.
91 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
92 f.st.CreateIssue(f.priv.ID, f.alice, "p1", "", "md")
93 runIssueMilestone(c, []string{"acme/core", "1", "v1"})
94 runIssueMilestone(c, []string{"acme/priv", "1", "v1"})
95 out.Reset()
96 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK ||
97 !strings.Contains(out.String(), `"open":2`) || !strings.Contains(out.String(), `"due":"2027-01-01"`) {
98 t.Fatalf("list: exit %d %s", code, out.String())
99 }
100 out.Reset()
101 // carol reads only the public repo's count.
102 cc, cout := f.ctx(f.carol)
103 if code := runOrgMilestoneList(cc, []string{"acme"}); code != protocol.ExitOK || !strings.Contains(cout.String(), `"open":1`) {
104 t.Fatalf("outsider list: exit %d %s", code, cout.String())
105 }
106 if code := runOrgMilestoneClose(c, []string{"acme", "v1"}); code != protocol.ExitOK {
107 t.Fatalf("close: exit %d %s", code, out.String())
108 }
109 out.Reset()
110 if code := runOrgMilestoneList(c, []string{"acme"}); code != protocol.ExitOK || strings.Contains(out.String(), `"title":"v1"`) {
111 t.Fatalf("closed still listed as open: %s", out.String())
112 }
113 out.Reset()
114 if code := runOrgMilestoneReopen(c, []string{"acme", "v1"}); code != protocol.ExitOK {
115 t.Fatalf("reopen: exit %d %s", code, out.String())
116 }
117 out.Reset()
118 if code := runOrgMilestoneClose(c, []string{"acme", "nope"}); code != protocol.ExitNotFound {
119 t.Fatalf("close missing: exit %d %s", code, out.String())
120 }
121 bc, bout := f.ctx(f.bob)
122 if code := runOrgMilestoneClose(bc, []string{"acme", "v1"}); code != protocol.ExitDenied {
123 t.Fatalf("member close: exit %d %s", code, bout.String())
124 }
125}
internal/control/orgscope_test.go added +176
@@ -0,0 +1,176 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// orgFixture: alice admins org acme with acme/core (public) and acme/priv
14// (private); bob is a plain member; carol is outside. alice also owns
15// alice/app.
16type orgFixture struct {
17 st *store.Store
18 alice, bob, carol int64
19 org int64
20 core, priv, app store.Repo
21}
22
23func newOrgFixture(t *testing.T) orgFixture {
24 t.Helper()
25 st, err := store.Open(":memory:")
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { st.Close() })
30 if err := st.MigrateUp(); err != nil {
31 t.Fatal(err)
32 }
33 var f orgFixture
34 f.st = st
35 user := func(name string) int64 {
36 id, err := st.CreateUser(name, false)
37 if err != nil {
38 t.Fatal(err)
39 }
40 return id
41 }
42 f.alice, f.bob, f.carol = user("alice"), user("bob"), user("carol")
43 if f.org, err = st.CreateOrg("acme", f.alice); err != nil {
44 t.Fatal(err)
45 }
46 if err := st.SetOrgMember(f.org, f.bob, "member"); err != nil {
47 t.Fatal(err)
48 }
49 repo := func(kind string, owner int64, name, vis string) store.Repo {
50 id, err := st.CreateRepo(kind, owner, name, vis)
51 if err != nil {
52 t.Fatal(err)
53 }
54 r, _ := st.RepoByID(id)
55 return r
56 }
57 f.core = repo("org", f.org, "core", "public")
58 f.priv = repo("org", f.org, "priv", "private")
59 f.app = repo("user", f.alice, "app", "public")
60 return f
61}
62
63func (f orgFixture) ctx(uid int64) (*Ctx, *bytes.Buffer) {
64 var out bytes.Buffer
65 name := map[int64]string{f.alice: "alice", f.bob: "bob", f.carol: "carol"}[uid]
66 return &Ctx{
67 User: store.User{ID: uid, Username: name},
68 Scope: "full",
69 Source: "SHA256:session",
70 Store: f.st,
71 Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}},
72 Stdin: strings.NewReader(""),
73 Stdout: &out,
74 Stderr: &out,
75 JSON: true,
76 }, &out
77}
78
79func TestReadableOrgRepoIDs(t *testing.T) {
80 f := newOrgFixture(t)
81 ids, err := ReadableOrgRepoIDs(f.st, store.User{ID: f.bob, Username: "bob"}, f.org)
82 if err != nil || len(ids) != 2 {
83 t.Fatalf("member reads %v, %v; want both", ids, err)
84 }
85 ids, _ = ReadableOrgRepoIDs(f.st, store.User{ID: f.carol, Username: "carol"}, f.org)
86 if len(ids) != 1 || ids[0] != f.core.ID {
87 t.Fatalf("outsider reads %v; want core only", ids)
88 }
89 ids, _ = ReadableOrgRepoIDs(f.st, store.User{}, f.org)
90 if len(ids) != 1 || ids[0] != f.core.ID {
91 t.Fatalf("anonymous reads %v; want core only", ids)
92 }
93 ids, _ = ReadableScope(f.st, store.User{ID: f.alice, Username: "alice"}, f.app)
94 if len(ids) != 1 || ids[0] != f.app.ID {
95 t.Fatalf("user repo scope %v; want itself", ids)
96 }
97}
98
99func TestRepoLabelCommandsRefuseOrgNames(t *testing.T) {
100 f := newOrgFixture(t)
101 if _, err := f.st.SetOrgLabel(f.org, "bug", ""); err != nil {
102 t.Fatal(err)
103 }
104 c, out := f.ctx(f.alice)
105 if code := runLabelSet(c, []string{"acme/core", "bug", "--color", "ff0000"}); code != protocol.ExitFailure ||
106 !strings.Contains(out.String(), "org label set acme bug") {
107 t.Fatalf("label set over org name: exit %d %s", code, out.String())
108 }
109 out.Reset()
110 if code := runLabelRemove(c, []string{"acme/core", "bug"}); code != protocol.ExitFailure ||
111 !strings.Contains(out.String(), "org label remove acme bug") {
112 t.Fatalf("label remove of org row: exit %d %s", code, out.String())
113 }
114 out.Reset()
115 // issue label --add resolves to the org row, and label list marks it.
116 iid, _ := f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
117 _ = iid
118 if code := runIssueLabel(c, []string{"acme/core", "1", "--add", "bug"}); code != protocol.ExitOK {
119 t.Fatalf("issue label: exit %d %s", code, out.String())
120 }
121 out.Reset()
122 if code := runLabelList(c, []string{"acme/core"}); code != protocol.ExitOK ||
123 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"issues":1`) {
124 t.Fatalf("label list: exit %d %s", code, out.String())
125 }
126}
127
128func TestRepoMilestoneCommandsRefuseOrgTitles(t *testing.T) {
129 f := newOrgFixture(t)
130 if _, _, err := f.st.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
131 t.Fatal(err)
132 }
133 c, out := f.ctx(f.alice)
134 if code := runMilestoneCreate(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
135 !strings.Contains(out.String(), "org milestone create acme v1") {
136 t.Fatalf("milestone create over org title: exit %d %s", code, out.String())
137 }
138 out.Reset()
139 if code := runMilestoneClose(c, []string{"acme/core", "v1"}); code != protocol.ExitFailure ||
140 !strings.Contains(out.String(), "org milestone close acme v1") {
141 t.Fatalf("milestone close of org row: exit %d %s", code, out.String())
142 }
143 out.Reset()
144 // Attaching by title from a repo resolves the org milestone.
145 f.st.CreateIssue(f.core.ID, f.alice, "c1", "", "md")
146 if code := runIssueMilestone(c, []string{"acme/core", "1", "v1"}); code != protocol.ExitOK {
147 t.Fatalf("issue milestone: exit %d %s", code, out.String())
148 }
149 out.Reset()
150 if code := runMilestoneList(c, []string{"acme/core"}); code != protocol.ExitOK ||
151 !strings.Contains(out.String(), `"org":true`) || !strings.Contains(out.String(), `"open":1`) {
152 t.Fatalf("milestone list: exit %d %s", code, out.String())
153 }
154}
155
156// A duplicate title fails; it is not a usage error, and the repo-level
157// and org-level commands answer with the same code.
158func TestDuplicateMilestoneTitleFails(t *testing.T) {
159 f := newOrgFixture(t)
160 c, out := f.ctx(f.alice)
161 if code := runMilestoneCreate(c, []string{"alice/app", "v1"}); code != protocol.ExitOK {
162 t.Fatalf("first create: exit %d %s", code, out.String())
163 }
164 out.Reset()
165 if code := runMilestoneCreate(c, []string{"alice/app", "v1"}); code != protocol.ExitFailure {
166 t.Fatalf("duplicate create: exit %d %s", code, out.String())
167 }
168 out.Reset()
169 if code := runOrgMilestoneCreate(c, []string{"acme", "v1"}); code != protocol.ExitOK {
170 t.Fatalf("first org create: exit %d %s", code, out.String())
171 }
172 out.Reset()
173 if code := runOrgMilestoneCreate(c, []string{"acme", "v1"}); code != protocol.ExitFailure {
174 t.Fatalf("duplicate org create: exit %d %s", code, out.String())
175 }
176}
internal/control/scope.go added +48
@@ -0,0 +1,48 @@
1package control
2
3import (
4 "fmt"
5
6 "gitbay.org/gitbay/internal/policy"
7 "gitbay.org/gitbay/internal/store"
8)
9
10// ReadableOrgRepoIDs is the org's repositories user may read. Counts on
11// org labels and milestones are taken over these, so a private
12// repository's issues never show in a number someone outside it sees. A
13// zero user is anonymous.
14func ReadableOrgRepoIDs(st *store.Store, user store.User, orgID int64) ([]int64, error) {
15 repos, err := st.ListReposForOwner("org", orgID)
16 if err != nil {
17 return nil, err
18 }
19 var ids []int64
20 for _, r := range repos {
21 grant := ""
22 if user.ID != 0 {
23 if grant, err = st.AccessRole(r.ID, user.ID); err != nil {
24 return nil, err
25 }
26 }
27 if policy.CanRead(user, r, grant) {
28 ids = append(ids, r.ID)
29 }
30 }
31 return ids, nil
32}
33
34// ReadableScope is the set a repository's label and milestone counts
35// span: its org's readable repositories, or just itself when a user owns
36// it. The caller has already been allowed to read repo.
37func ReadableScope(st *store.Store, user store.User, repo store.Repo) ([]int64, error) {
38 if repo.OwnerKind == "org" {
39 return ReadableOrgRepoIDs(st, user, repo.OwnerID)
40 }
41 return []int64{repo.ID}, nil
42}
43
44// orgScopedMsg names the org command that manages a row a repository
45// command was asked to change.
46func orgScopedMsg(repo store.Repo, noun, name, verb string) string {
47 return fmt.Sprintf("%s is an org %s of %s; manage it with org %s %s %s %s", name, noun, repo.OwnerName, noun, verb, repo.OwnerName, name)
48}
internal/hookd/hookd.go +9 −4
@@ -35,12 +35,17 @@ const (
35 EnvSocket = "GITBAY_HOOK_SOCKET" 35 EnvSocket = "GITBAY_HOOK_SOCKET"
36 EnvRepoID = "GITBAY_REPO_ID" 36 EnvRepoID = "GITBAY_REPO_ID"
37 EnvUserID = "GITBAY_USER_ID" 37 EnvUserID = "GITBAY_USER_ID"
38 EnvScope = "GITBAY_KEY_SCOPE"
38) 39)
39 40
40type Request struct { 41type Request struct {
41 Hook string `json:"hook"` // pre-receive | post-receive 42 Hook string `json:"hook"` // pre-receive | post-receive
42 RepoID int64 `json:"repo_id"` 43 RepoID int64 `json:"repo_id"`
43 UserID int64 `json:"user_id"` 44 UserID int64 `json:"user_id"`
45 // Scope is the pushing key's scope. The user id alone is the account
46 // the key belongs to, and a deploy key grants nothing outside its
47 // binding, so anything acting on another repository needs this too.
48 Scope string `json:"scope"`
44 Updates []policy.RefUpdate `json:"updates"` 49 Updates []policy.RefUpdate `json:"updates"`
45} 50}
46 51
@@ -237,7 +242,7 @@ func (s *Server) postReceive(req Request) {
237 // in their messages (closes #N, plain #N). 242 // in their messages (closes #N, plain #N).
238 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete { 243 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
239 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name) 244 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
240 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New) 245 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
241 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New) 246 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
242 } 247 }
243 // A branch push with a .gitbay/ci.yml queues one build per job. 248 // A branch push with a .gitbay/ci.yml queues one build per job.
internal/httpd/labels.go +8 −2
@@ -5,6 +5,7 @@ import (
5 "net/http" 5 "net/http"
6 "strings" 6 "strings"
7 7
8 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/store" 9 "gitbay.org/gitbay/internal/store"
9) 10)
10 11
@@ -17,7 +18,12 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
17 return 18 return
18 } 19 }
19 p.Tab = "issues" 20 p.Tab = "issues"
20 labels, err := s.st.ListLabels(p.Repo.ID) 21 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
22 if err != nil {
23 http.Error(w, "internal error", http.StatusInternalServerError)
24 return
25 }
26 labels, err := s.st.ListLabels(p.Repo, readable)
21 if err != nil { 27 if err != nil {
22 http.Error(w, "internal error", http.StatusInternalServerError) 28 http.Error(w, "internal error", http.StatusInternalServerError)
23 return 29 return
@@ -28,7 +34,7 @@ func (s *Server) labels(w http.ResponseWriter, r *http.Request) {
28 LabelColors map[string]template.CSS 34 LabelColors map[string]template.CSS
29 CanWrite bool 35 CanWrite bool
30 Notice string 36 Notice string
31 }{p, labels, s.labelColors(p.Repo.ID), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) 37 }{p, labels, s.labelColors(p.Repo), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
32} 38}
33 39
34// labelSubmit creates a label, sets its colour, or removes it, through 40// labelSubmit creates a label, sets its colour, or removes it, through
internal/httpd/orglabels.go added +92
@@ -0,0 +1,92 @@
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6
7 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/store"
9)
10
11// orgScope resolves the org for its labels or milestones page. Members
12// see it; anyone else only when some repository under the org is
13// readable. Everything else is not found, the same answer as for a
14// user owner or an unknown name.
15func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool) {
16 viewer := s.viewer(r)
17 org, err := s.st.OrgByName(r.PathValue("owner"))
18 if err != nil {
19 s.notFound(w, r)
20 return org, viewer, nil, false
21 }
22 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID)
23 if err != nil {
24 http.Error(w, "internal error", http.StatusInternalServerError)
25 return org, viewer, nil, false
26 }
27 role := ""
28 if viewer.ID != 0 {
29 role, _ = s.st.OrgRole(org.ID, viewer.ID)
30 }
31 if role == "" && len(readable) == 0 {
32 s.notFound(w, r)
33 return org, viewer, nil, false
34 }
35 return org, viewer, readable, true
36}
37
38func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
39 org, viewer, readable, ok := s.orgScope(w, r)
40 if !ok {
41 return
42 }
43 labels, err := s.st.ListOrgLabels(org.ID, readable)
44 if err != nil {
45 http.Error(w, "internal error", http.StatusInternalServerError)
46 return
47 }
48 stored := make(map[string]string, len(labels))
49 for _, l := range labels {
50 stored[l.Name] = l.Color
51 }
52 s.render(w, "orglabels.html", struct {
53 basePage
54 Org string
55 Labels []store.Label
56 LabelColors map[string]template.CSS
57 }{s.baseFor(viewer), org.Name, labels, colorStyles(stored)})
58}
59
60func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
61 org, viewer, readable, ok := s.orgScope(w, r)
62 if !ok {
63 return
64 }
65 state := r.URL.Query().Get("state")
66 if state != "closed" && state != "all" {
67 state = "open"
68 }
69 ms, err := s.st.ListOrgMilestones(org.ID, state, readable)
70 if err != nil {
71 http.Error(w, "internal error", http.StatusInternalServerError)
72 return
73 }
74 type msView struct {
75 store.Milestone
76 Percent int
77 }
78 var views []msView
79 for _, m := range ms {
80 v := msView{Milestone: m}
81 if total := m.OpenItems + m.ClosedItems; total > 0 {
82 v.Percent = m.ClosedItems * 100 / total
83 }
84 views = append(views, v)
85 }
86 s.render(w, "orgmilestones.html", struct {
87 basePage
88 Org string
89 State string
90 Milestones []msView
91 }{s.baseFor(viewer), org.Name, state, views})
92}
internal/httpd/routes.go +2
@@ -71,6 +71,8 @@ func (s *Server) Routes() []Route {
71 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, 71 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
72 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, 72 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
73 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, 73 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
74 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
75 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
74 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 76 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
75 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, 77 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
76 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, 78 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
internal/httpd/web.go +20 −7
@@ -702,7 +702,12 @@ func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
702 if state != "closed" && state != "all" { 702 if state != "closed" && state != "all" {
703 state = "open" 703 state = "open"
704 } 704 }
705 ms, err := s.st.ListMilestones(p.Repo.ID, state) 705 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
706 if err != nil {
707 http.Error(w, "internal error", http.StatusInternalServerError)
708 return
709 }
710 ms, err := s.st.ListMilestones(p.Repo, state, readable)
706 if err != nil { 711 if err != nil {
707 http.Error(w, "internal error", http.StatusInternalServerError) 712 http.Error(w, "internal error", http.StatusInternalServerError)
708 return 713 return
@@ -1603,8 +1608,15 @@ func hexByte(s string) int64 {
1603// labelColors returns a complete label-name -> chip color map for a repo: 1608// labelColors returns a complete label-name -> chip color map for a repo:
1604// the stored labels.color when it is a valid hex color, otherwise a 1609// the stored labels.color when it is a valid hex color, otherwise a
1605// stable default picked from the palette by name hash. 1610// stable default picked from the palette by name hash.
1606func (s *Server) labelColors(repoID int64) map[string]template.CSS { 1611func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1607 stored, _ := s.st.LabelColors(repoID) 1612 stored, _ := s.st.LabelColors(repo)
1613 return colorStyles(stored)
1614}
1615
1616// colorStyles turns a label-name -> stored color map into chip styles: the
1617// stored color when it is a valid hex color, otherwise a stable default
1618// picked from the palette by name hash.
1619func colorStyles(stored map[string]string) map[string]template.CSS {
1608 out := make(map[string]template.CSS, len(stored)) 1620 out := make(map[string]template.CSS, len(stored))
1609 for name, color := range stored { 1621 for name, color := range stored {
1610 if !hexColorPat.MatchString(color) { 1622 if !hexColorPat.MatchString(color) {
@@ -1656,7 +1668,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1656 issues = issues[:listPage] 1668 issues = issues[:listPage]
1657 older = olderLink(r, issues[len(issues)-1].Number) 1669 older = olderLink(r, issues[len(issues)-1].Number)
1658 } 1670 }
1659 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil { 1671 if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
1660 for i := range issues { 1672 for i := range issues {
1661 issues[i].Labels = labels[issues[i].ID] 1673 issues[i].Labels = labels[issues[i].ID]
1662 } 1674 }
@@ -1672,7 +1684,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1672 Older string 1684 Older string
1673 }{p, state, f.Label, f.Search, 1685 }{p, state, f.Label, f.Search,
1674 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}), 1686 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1675 issues, s.labelColors(p.Repo.ID), older}) 1687 issues, s.labelColors(p.Repo), older})
1676} 1688}
1677 1689
1678func (s *Server) issue(w http.ResponseWriter, r *http.Request) { 1690func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -1697,7 +1709,8 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1697 return 1709 return
1698 } 1710 }
1699 md := s.ugcFor(r, p.Repo) 1711 md := s.ugcFor(r, p.Repo)
1700 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open") 1712 // nil readable: the picker lists titles, never the progress counts.
1713 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
1701 s.render(w, "issue.html", struct { 1714 s.render(w, "issue.html", struct {
1702 repoPage 1715 repoPage
1703 Issue store.Issue 1716 Issue store.Issue
@@ -1710,7 +1723,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1710 LabelColors map[string]template.CSS 1723 LabelColors map[string]template.CSS
1711 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), 1724 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1712 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), 1725 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1713 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)}) 1726 milestones, s.takeFlash(w, r), s.labelColors(p.Repo)})
1714} 1727}
1715 1728
1716// canEditItem: the author or anyone with write access may edit. 1729// canEditItem: the author or anyone with write access may edit.
internal/sshd/sshd.go +1
@@ -404,6 +404,7 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
404 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root), 404 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
405 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), 405 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
406 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), 406 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
407 hookd.EnvScope + "=" + scope,
407 } 408 }
408 // A storage quota on the owner rides the same mechanism as the pack 409 // A storage quota on the owner rides the same mechanism as the pack
409 // cap: the pack may be no larger than what the owner has left. 410 // cap: the pack may be no larger than what the owner has left.
internal/store/issues.go +30 −21
@@ -271,13 +271,16 @@ func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error
271} 271}
272 272
273// ListIssueLabels returns the label names attached to each issue of a 273// ListIssueLabels returns the label names attached to each issue of a
274// repo, keyed by issue id. Used by the web issue listing; ListIssues 274// repo, keyed by issue id, its org's labels included. Used by the web
275// itself stays label-free for the CLI's lean list output. 275// issue listing; ListIssues itself stays label-free for the CLI's lean
276func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) { 276// list output.
277func (s *Store) ListIssueLabels(repo Repo) (map[int64][]string, error) {
278 where, args := scopeClause("l", repo)
277 rows, err := s.DB.Query(` 279 rows, err := s.DB.Query(`
278 SELECT il.issue_id, l.name FROM issue_labels il 280 SELECT il.issue_id, l.name FROM issue_labels il
279 JOIN labels l ON l.id = il.label_id 281 JOIN labels l ON l.id = il.label_id
280 WHERE l.repo_id = ? ORDER BY l.name`, repoID) 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...)...)
281 if err != nil { 284 if err != nil {
282 return nil, err 285 return nil, err
283 } 286 }
@@ -294,10 +297,11 @@ func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
294 return out, rows.Err() 297 return out, rows.Err()
295} 298}
296 299
297// LabelColors returns the repo's label colors keyed by label name. Labels 300// LabelColors returns the colours of the labels a repository sees, keyed
298// with no stored color map to "". 301// by name. Labels with no stored colour map to "".
299func (s *Store) LabelColors(repoID int64) (map[string]string, error) { 302func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
300 rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID) 303 where, args := scopeClause("l", repo)
304 rows, err := s.DB.Query("SELECT l.name, l.color FROM labels l WHERE "+where, args...)
301 if err != nil { 305 if err != nil {
302 return nil, err 306 return nil, err
303 } 307 }
@@ -313,30 +317,35 @@ func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
313 return out, rows.Err() 317 return out, rows.Err()
314} 318}
315 319
316// SetIssueLabel attaches (add) or detaches a label, creating the repo label 320// SetIssueLabel attaches (add) or detaches a label by name. Adding
317// on first use. 321// resolves the org's row when the org has the name, else the repository's,
318func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { 322// creating that on first use.
323func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
319 tx, err := s.DB.Begin() 324 tx, err := s.DB.Begin()
320 if err != nil { 325 if err != nil {
321 return err 326 return err
322 } 327 }
323 defer tx.Rollback() 328 defer tx.Rollback()
329 where, args := scopeClause("l", repo)
324 if add { 330 if add {
325 if _, err := tx.Exec( 331 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
326 "INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING",
327 repoID, name); err != nil {
328 return err 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 }
329 } 338 }
330 if _, err := tx.Exec(` 339 if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
331 INSERT INTO issue_labels (issue_id, label_id) 340 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
332 SELECT ?, id FROM labels WHERE repo_id = ? AND name = ? 341 ORDER BY l.org_id IS NULL LIMIT 1
333 ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil { 342 ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
334 return err 343 return err
335 } 344 }
336 } else { 345 } else {
337 res, err := tx.Exec(` 346 res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
338 DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN 347 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
339 (SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name) 348 append(append([]any{issueID}, args...), name)...)
340 if err != nil { 349 if err != nil {
341 return err 350 return err
342 } 351 }
internal/store/labels.go +154 −16
@@ -1,19 +1,30 @@
1package store 1package store
2 2
3// Label is one of a repository's issue labels with its colour, "" when 3import (
4// none was set (the web then derives one from the name), and how many 4 "database/sql"
5// issues carry it. 5 "errors"
6)
7
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.
6type Label struct { 11type Label struct {
7 Name string `json:"name"` 12 Name string `json:"name"`
8 Color string `json:"color,omitempty"` 13 Color string `json:"color,omitempty"`
14 Org bool `json:"org,omitempty"`
9 Issues int64 `json:"issues"` 15 Issues int64 `json:"issues"`
10} 16}
11 17
12// ListLabels lists a repository's labels by name. 18// labelRows lists labels under where, with use counted over the issues of
13func (s *Store) ListLabels(repoID int64) ([]Label, error) { 19// the readable repositories only, so a private repository's issues do not
14 rows, err := s.DB.Query(`SELECT l.name, l.color, COUNT(il.issue_id) 20// show in a count someone outside it can see.
15 FROM labels l LEFT JOIN issue_labels il ON il.label_id = l.id 21func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
16 WHERE l.repo_id = ? GROUP BY l.id ORDER BY l.name`, repoID) 22 in, inArgs := inClause(readable)
23 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
24 (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 + `)
26 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name`
27 rows, err := s.DB.Query(q, append(inArgs, args...)...)
17 if err != nil { 28 if err != nil {
18 return nil, err 29 return nil, err
19 } 30 }
@@ -21,7 +32,7 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) {
21 var out []Label 32 var out []Label
22 for rows.Next() { 33 for rows.Next() {
23 var l Label 34 var l Label
24 if err := rows.Scan(&l.Name, &l.Color, &l.Issues); err != nil { 35 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil {
25 return nil, err 36 return nil, err
26 } 37 }
27 out = append(out, l) 38 out = append(out, l)
@@ -29,16 +40,143 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) {
29 return out, rows.Err() 40 return out, rows.Err()
30} 41}
31 42
32// SetLabel creates the label or sets its colour. 43// ListLabels lists the labels a repository sees: its org's first, then its
33func (s *Store) SetLabel(repoID int64, name, color string) error { 44// own, each by name.
34 _, err := s.DB.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?) 45func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
35 ON CONFLICT (repo_id, name) DO UPDATE SET color = excluded.color`, repoID, name, color) 46 where, args := scopeClause("l", repo)
47 return s.labelRows(where, args, readable)
48}
49
50// ListOrgLabels lists an org's labels.
51func (s *Store) ListOrgLabels(orgID int64, readable []int64) ([]Label, error) {
52 return s.labelRows("l.org_id = ?", []any{orgID}, readable)
53}
54
55// LabelByName resolves a name the way attaching does: the org's row when
56// the org has it, else the repository's.
57func (s *Store) LabelByName(repo Repo, name string) (Label, error) {
58 where, args := scopeClause("l", repo)
59 var l Label
60 err := s.DB.QueryRow(`SELECT l.name, l.color, l.org_id IS NOT NULL FROM labels l
61 WHERE `+where+` AND l.name = ? ORDER BY l.org_id IS NULL LIMIT 1`,
62 append(args, name)...).Scan(&l.Name, &l.Color, &l.Org)
63 if errors.Is(err, sql.ErrNoRows) {
64 return l, ErrNotFound
65 }
66 return l, err
67}
68
69// orgHoldsLabel reports whether the repository's org has a label of that
70// name; always false for a user-owned repository.
71func orgHoldsLabel(q interface {
72 QueryRow(string, ...any) *sql.Row
73}, repo Repo, name string) (bool, error) {
74 if repo.OwnerKind != "org" {
75 return false, nil
76 }
77 var n int
78 err := q.QueryRow("SELECT COUNT(*) FROM labels WHERE org_id = ? AND name = ?", repo.OwnerID, name).Scan(&n)
79 return n > 0, err
80}
81
82// SetLabel creates the repository's label or sets its colour. A name the
83// org holds is refused with ErrOrgScoped.
84func (s *Store) SetLabel(repo Repo, name, color string) error {
85 tx, err := s.DB.Begin()
86 if err != nil {
87 return err
88 }
89 defer tx.Rollback()
90 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
91 if err != nil {
92 return err
93 }
94 return ErrOrgScoped
95 }
96 _, err = tx.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?)
97 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO UPDATE SET color = excluded.color`,
98 repo.ID, name, color)
99 if err != nil {
100 return err
101 }
102 return tx.Commit()
103}
104
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.
107func (s *Store) DeleteLabel(repo Repo, name string) error {
108 tx, err := s.DB.Begin()
109 if err != nil {
110 return err
111 }
112 defer tx.Rollback()
113 res, err := tx.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repo.ID, name)
114 if err != nil {
115 return err
116 }
117 if n, _ := res.RowsAffected(); n > 0 {
118 return tx.Commit()
119 }
120 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
121 if err != nil {
122 return err
123 }
124 return ErrOrgScoped
125 }
126 return ErrNotFound
127}
128
129// 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.
132func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
133 tx, err := s.DB.Begin()
134 if err != nil {
135 return 0, err
136 }
137 defer tx.Rollback()
138 if _, err := tx.Exec(`INSERT INTO labels (org_id, name, color) VALUES (?, ?, ?)
139 ON CONFLICT (org_id, name) WHERE org_id IS NOT NULL DO UPDATE SET color = excluded.color`,
140 orgID, name, color); err != nil {
141 return 0, err
142 }
143 var orgRow int64
144 if err := tx.QueryRow("SELECT id FROM labels WHERE org_id = ? AND name = ?", orgID, name).Scan(&orgRow); err != nil {
145 return 0, err
146 }
147 rows, err := tx.Query(`SELECT l.id FROM labels l JOIN repos r ON r.id = l.repo_id
148 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND l.name = ?`, orgID, name)
149 if err != nil {
150 return 0, err
151 }
152 repoRows, err := scanIDs(rows)
153 if err != nil {
154 return 0, err
155 }
156 for _, id := range repoRows {
157 if err := foldLabelRow(tx, orgRow, id); err != nil {
158 return 0, err
159 }
160 }
161 return len(repoRows), tx.Commit()
162}
163
164// 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.
166func 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
171 }
172 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
36 return err 173 return err
37} 174}
38 175
39// DeleteLabel removes a label and takes it off every issue. 176// DeleteOrgLabel removes an org's label from the org and from every issue
40func (s *Store) DeleteLabel(repoID int64, name string) error { 177// under it.
41 res, err := s.DB.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repoID, name) 178func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
179 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
42 if err != nil { 180 if err != nil {
43 return err 181 return err
44 } 182 }
internal/store/labels_test.go added +297
@@ -0,0 +1,297 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8// acmeFixture: org acme owned by alice with repos acme/core and
9// acme/site, an issue in each, and alice's own alice/app.
10type acmeFixture struct {
11 s *Store
12 alice int64
13 org int64
14 core, site Repo
15 app Repo
16 coreIssue int64
17 siteIssue int64
18}
19
20func newAcme(t *testing.T) acmeFixture {
21 t.Helper()
22 s := open(t)
23 if err := s.MigrateUp(); err != nil {
24 t.Fatal(err)
25 }
26 var f acmeFixture
27 f.s = s
28 var err error
29 if f.alice, err = s.CreateUser("alice", false); err != nil {
30 t.Fatal(err)
31 }
32 if f.org, err = s.CreateOrg("acme", f.alice); err != nil {
33 t.Fatal(err)
34 }
35 mk := func(kind string, owner int64, name string) Repo {
36 id, err := s.CreateRepo(kind, owner, name, "public")
37 if err != nil {
38 t.Fatal(err)
39 }
40 r, err := s.RepoByID(id)
41 if err != nil {
42 t.Fatal(err)
43 }
44 return r
45 }
46 f.core = mk("org", f.org, "core")
47 f.site = mk("org", f.org, "site")
48 f.app = mk("user", f.alice, "app")
49 // CreateIssue returns the per-repo issue number, not the issues.id row
50 // that issue_labels.issue_id references (and that every production
51 // caller of SetIssueLabel passes); resolve it the same way they do, or
52 // core's and site's both-numbered-1 first issues collide.
53 mkIssue := func(repo Repo, title string) int64 {
54 n, err := s.CreateIssue(repo.ID, f.alice, title, "", "md")
55 if err != nil {
56 t.Fatal(err)
57 }
58 iss, err := s.IssueByNumber(repo.ID, n)
59 if err != nil {
60 t.Fatal(err)
61 }
62 return iss.ID
63 }
64 f.coreIssue = mkIssue(f.core, "c1")
65 f.siteIssue = mkIssue(f.site, "s1")
66 return f
67}
68
69func (f acmeFixture) orgRepos() []int64 { return []int64{f.core.ID, f.site.ID} }
70
71func TestOrgLabelSeenByEveryOrgRepo(t *testing.T) {
72 f := newAcme(t)
73 if _, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000"); err != nil {
74 t.Fatal(err)
75 }
76 if err := f.s.SetLabel(f.site, "docs", ""); err != nil {
77 t.Fatal(err)
78 }
79 // site sees the org's bug first, then its own docs; core sees only bug;
80 // alice/app, user-owned, sees nothing.
81 got, err := f.s.ListLabels(f.site, f.orgRepos())
82 if err != nil || len(got) != 2 || got[0].Name != "bug" || !got[0].Org || got[1].Name != "docs" || got[1].Org {
83 t.Fatalf("site labels = %+v, %v", got, err)
84 }
85 if got, _ := f.s.ListLabels(f.core, f.orgRepos()); len(got) != 1 || got[0].Name != "bug" {
86 t.Fatalf("core labels = %+v", got)
87 }
88 if got, _ := f.s.ListLabels(f.app, []int64{f.app.ID}); len(got) != 0 {
89 t.Fatalf("app labels = %+v", got)
90 }
91 colors, _ := f.s.LabelColors(f.core)
92 if colors["bug"] != "#ff0000" {
93 t.Fatalf("core colours = %v", colors)
94 }
95}
96
97func TestIssueLabelResolvesOrgRowFirst(t *testing.T) {
98 f := newAcme(t)
99 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
100 t.Fatal(err)
101 }
102 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil {
103 t.Fatal(err)
104 }
105 if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil {
106 t.Fatal(err)
107 }
108 // One org row, no repo rows were created on the fly.
109 var n int
110 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n)
111 if n != 1 {
112 t.Fatalf("labels named bug: %d, want 1", n)
113 }
114 // The count spans the org's readable repos.
115 got, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
116 if len(got) != 1 || got[0].Issues != 2 {
117 t.Fatalf("org labels = %+v", got)
118 }
119 got, _ = f.s.ListOrgLabels(f.org, []int64{f.core.ID})
120 if got[0].Issues != 1 {
121 t.Fatalf("org labels over core only = %+v", got)
122 }
123 // A label neither scope has is still created on the fly in the repo.
124 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "adhoc", true); err != nil {
125 t.Fatal(err)
126 }
127 if l, err := f.s.LabelByName(f.core, "adhoc"); err != nil || l.Org {
128 t.Fatalf("adhoc = %+v, %v", l, err)
129 }
130 // Removing by name works for the org row too.
131 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", false); err != nil {
132 t.Fatal(err)
133 }
134 got, _ = f.s.ListOrgLabels(f.org, f.orgRepos())
135 if got[0].Issues != 1 {
136 t.Fatalf("after detach: %+v", got)
137 }
138}
139
140// The web issue list reads labels per repository; an org label attached
141// to an issue has to come back from there like the repository's own.
142func TestListIssueLabelsIncludesOrgRows(t *testing.T) {
143 f := newAcme(t)
144 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
145 t.Fatal(err)
146 }
147 if err := f.s.SetLabel(f.core, "docs", ""); err != nil {
148 t.Fatal(err)
149 }
150 for _, name := range []string{"bug", "docs"} {
151 if err := f.s.SetIssueLabel(f.core, f.coreIssue, name, true); err != nil {
152 t.Fatal(err)
153 }
154 }
155 got, err := f.s.ListIssueLabels(f.core)
156 if err != nil || len(got[f.coreIssue]) != 2 || got[f.coreIssue][0] != "bug" || got[f.coreIssue][1] != "docs" {
157 t.Fatalf("core issue labels = %v, %v", got, err)
158 }
159 // Another repository under the org does not pick up core's attachment.
160 if got, _ := f.s.ListIssueLabels(f.site); len(got) != 0 {
161 t.Fatalf("site issue labels = %v", got)
162 }
163}
164
165func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) {
166 f := newAcme(t)
167 if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil {
168 t.Fatal(err)
169 }
170 if err := f.s.SetLabel(f.core, "bug", "#00ff00"); !errors.Is(err, ErrOrgScoped) {
171 t.Fatalf("SetLabel over org name: %v, want ErrOrgScoped", err)
172 }
173 if err := f.s.DeleteLabel(f.core, "bug"); !errors.Is(err, ErrOrgScoped) {
174 t.Fatalf("DeleteLabel of org row: %v, want ErrOrgScoped", err)
175 }
176 if err := f.s.DeleteLabel(f.core, "nope"); !errors.Is(err, ErrNotFound) {
177 t.Fatalf("DeleteLabel of nothing: %v, want ErrNotFound", err)
178 }
179 // A user-owned repo is unaffected by any org.
180 if err := f.s.SetLabel(f.app, "bug", ""); err != nil {
181 t.Fatal(err)
182 }
183}
184
185func TestSetOrgLabelPromotesRepoLabels(t *testing.T) {
186 f := newAcme(t)
187 if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil {
188 t.Fatal(err)
189 }
190 if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil {
191 t.Fatal(err)
192 }
193 if err := f.s.SetLabel(f.app, "bug", "#123456"); err != nil {
194 t.Fatal(err)
195 }
196 folded, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000")
197 if err != nil || folded != 2 {
198 t.Fatalf("SetOrgLabel folded %d, %v; want 2", folded, err)
199 }
200 var n int
201 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND org_id = ?", f.org).Scan(&n)
202 if n != 1 {
203 t.Fatalf("org rows named bug: %d", n)
204 }
205 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND repo_id IN (?, ?)", f.core.ID, f.site.ID).Scan(&n)
206 if n != 0 {
207 t.Fatalf("repo rows named bug left under the org: %d", n)
208 }
209 got, _ := f.s.ListOrgLabels(f.org, f.orgRepos())
210 if len(got) != 1 || got[0].Issues != 2 || got[0].Color != "#ff0000" {
211 t.Fatalf("after promote: %+v", got)
212 }
213 // alice/app's own bug is another owner's and stays.
214 if l, err := f.s.LabelByName(f.app, "bug"); err != nil || l.Color != "#123456" {
215 t.Fatalf("app bug = %+v, %v", l, err)
216 }
217 // A second set only recolours.
218 if folded, err := f.s.SetOrgLabel(f.org, "bug", "#0000ff"); err != nil || folded != 0 {
219 t.Fatalf("second set folded %d, %v", folded, err)
220 }
221 if err := f.s.DeleteOrgLabel(f.org, "bug"); err != nil {
222 t.Fatal(err)
223 }
224 if err := f.s.DeleteOrgLabel(f.org, "bug"); !errors.Is(err, ErrNotFound) {
225 t.Fatalf("second delete: %v", err)
226 }
227 f.s.DB.QueryRow("SELECT COUNT(*) FROM issue_labels").Scan(&n)
228 if n != 0 {
229 t.Fatalf("memberships after org delete: %d", n)
230 }
231}
232
233// A repository moving into an org brings its own labels and milestones;
234// the names the org already holds fold into the org's rows rather than
235// leaving the repository seeing two of each.
236func TestTransferIntoOrgFoldsDuplicateNames(t *testing.T) {
237 f := newAcme(t)
238 n, err := f.s.CreateIssue(f.app.ID, f.alice, "a1", "", "md")
239 if err != nil {
240 t.Fatal(err)
241 }
242 issue, err := f.s.IssueByNumber(f.app.ID, n)
243 if err != nil {
244 t.Fatal(err)
245 }
246 if err := f.s.SetLabel(f.app, "bug", "#123456"); err != nil {
247 t.Fatal(err)
248 }
249 if err := f.s.SetIssueLabel(f.app, issue.ID, "bug", true); err != nil {
250 t.Fatal(err)
251 }
252 repoMS, err := f.s.CreateMilestone(f.app, "v1", "", "")
253 if err != nil {
254 t.Fatal(err)
255 }
256 if err := f.s.SetIssueMilestone(issue.ID, repoMS); err != nil {
257 t.Fatal(err)
258 }
259 if _, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000"); err != nil {
260 t.Fatal(err)
261 }
262 orgMS, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", "")
263 if err != nil {
264 t.Fatal(err)
265 }
266 if err := f.s.TransferRepo(f.app.ID, "org", f.org); err != nil {
267 t.Fatal(err)
268 }
269 app, err := f.s.RepoByID(f.app.ID)
270 if err != nil {
271 t.Fatal(err)
272 }
273 labels, err := f.s.ListLabels(app, []int64{app.ID})
274 if err != nil || len(labels) != 1 || !labels[0].Org || labels[0].Color != "#ff0000" || labels[0].Issues != 1 {
275 t.Fatalf("labels after transfer = %+v, %v", labels, err)
276 }
277 ms, err := f.s.ListMilestones(app, "all", []int64{app.ID})
278 if err != nil || len(ms) != 1 || ms[0].ID != orgMS || ms[0].OrgID != f.org || ms[0].OpenItems != 1 {
279 t.Fatalf("milestones after transfer = %+v, %v", ms, err)
280 }
281 // The issue keeps both, pointing at the org's rows; the repository's
282 // rows are gone.
283 var count int
284 f.s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id
285 WHERE il.issue_id = ? AND l.org_id = ?`, issue.ID, f.org).Scan(&count)
286 if count != 1 {
287 t.Fatalf("label membership after transfer: %d", count)
288 }
289 f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE repo_id = ?", app.ID).Scan(&count)
290 if count != 0 {
291 t.Fatalf("repo label rows left: %d", count)
292 }
293 f.s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE id = ?", repoMS).Scan(&count)
294 if count != 0 {
295 t.Fatalf("repo milestone row left: %d", count)
296 }
297}
internal/store/migrations/0052_org_scope.down.sql added +33
@@ -0,0 +1,33 @@
1-- foreign_keys: off
2-- Back to per-repository rows. An org-scoped row has no repository to go
3-- to; the NOT NULL on repo_id refuses the copy, which fails the migration.
4PRAGMA legacy_alter_table = ON;
5
6ALTER TABLE labels RENAME TO labels_old;
7CREATE TABLE labels (
8 id INTEGER PRIMARY KEY,
9 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
10 name TEXT NOT NULL,
11 color TEXT NOT NULL DEFAULT '',
12 UNIQUE (repo_id, name)
13);
14INSERT INTO labels (id, repo_id, name, color)
15 SELECT id, repo_id, name, color FROM labels_old;
16DROP TABLE labels_old;
17
18ALTER TABLE milestones RENAME TO milestones_old;
19CREATE TABLE milestones (
20 id INTEGER PRIMARY KEY,
21 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
22 title TEXT NOT NULL,
23 description TEXT NOT NULL DEFAULT '',
24 due_date TEXT NOT NULL DEFAULT '',
25 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
26 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
27 UNIQUE (repo_id, title)
28);
29INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
30 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
31DROP TABLE milestones_old;
32
33PRAGMA legacy_alter_table = OFF;
internal/store/migrations/0052_org_scope.up.sql added +48
@@ -0,0 +1,48 @@
1-- foreign_keys: off
2-- Labels and milestones scoped to a repository or to an org (#203).
3-- Exactly one of repo_id and org_id is set. Uniqueness is per scope, as
4-- two partial indexes; the app refuses a repo name the org already holds.
5--
6-- Both tables have children (issue_labels, issues.milestone_id,
7-- merge_requests.milestone_id). Foreign keys are off for this migration:
8-- rebuilding a parent table that children reference loses the children's
9-- rows with foreign keys on. legacy_alter_table keeps the children naming
10-- labels and milestones through the rename, so they bind to the new
11-- tables rather than to labels_old/milestones_old. foreign_key_check
12-- afterwards proves the ids line up.
13PRAGMA legacy_alter_table = ON;
14
15ALTER TABLE labels RENAME TO labels_old;
16CREATE TABLE labels (
17 id INTEGER PRIMARY KEY,
18 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
19 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
20 name TEXT NOT NULL,
21 color TEXT NOT NULL DEFAULT '',
22 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
23);
24INSERT INTO labels (id, repo_id, name, color)
25 SELECT id, repo_id, name, color FROM labels_old;
26DROP TABLE labels_old;
27CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL;
28CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL;
29
30ALTER TABLE milestones RENAME TO milestones_old;
31CREATE TABLE milestones (
32 id INTEGER PRIMARY KEY,
33 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
34 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
35 title TEXT NOT NULL,
36 description TEXT NOT NULL DEFAULT '',
37 due_date TEXT NOT NULL DEFAULT '',
38 state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
39 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
40 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
41);
42INSERT INTO milestones (id, repo_id, title, description, due_date, state, created_at)
43 SELECT id, repo_id, title, description, due_date, state, created_at FROM milestones_old;
44DROP TABLE milestones_old;
45CREATE UNIQUE INDEX milestones_repo_title ON milestones(repo_id, title) WHERE repo_id IS NOT NULL;
46CREATE UNIQUE INDEX milestones_org_title ON milestones(org_id, title) WHERE org_id IS NOT NULL;
47
48PRAGMA legacy_alter_table = OFF;
internal/store/milestones.go +142 −20
@@ -9,6 +9,7 @@ import (
9type Milestone struct { 9type Milestone struct {
10 ID int64 10 ID int64
11 RepoID int64 11 RepoID int64
12 OrgID int64 // set instead of RepoID for an org milestone
12 Title string 13 Title string
13 Description string 14 Description string
14 DueDate string 15 DueDate string
@@ -18,52 +19,160 @@ type Milestone struct {
18 ClosedItems int // closed issues + merged/closed MRs attached 19 ClosedItems int // closed issues + merged/closed MRs attached
19} 20}
20 21
21func (s *Store) CreateMilestone(repoID int64, title, description, due string) (int64, error) { 22// orgHoldsMilestone reports whether the repository's org has a milestone
22 res, err := s.DB.Exec( 23// of that title; always false for a user-owned repository.
24func orgHoldsMilestone(q interface {
25 QueryRow(string, ...any) *sql.Row
26}, repo Repo, title string) (bool, error) {
27 if repo.OwnerKind != "org" {
28 return false, nil
29 }
30 var n int
31 err := q.QueryRow("SELECT COUNT(*) FROM milestones WHERE org_id = ? AND title = ?", repo.OwnerID, title).Scan(&n)
32 return n > 0, err
33}
34
35// CreateMilestone creates the repository's milestone. A title the org
36// holds is refused with ErrOrgScoped.
37func (s *Store) CreateMilestone(repo Repo, title, description, due string) (int64, error) {
38 tx, err := s.DB.Begin()
39 if err != nil {
40 return 0, err
41 }
42 defer tx.Rollback()
43 if held, err := orgHoldsMilestone(tx, repo, title); err != nil || held {
44 if err != nil {
45 return 0, err
46 }
47 return 0, ErrOrgScoped
48 }
49 res, err := tx.Exec(
23 "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)", 50 "INSERT INTO milestones (repo_id, title, description, due_date) VALUES (?, ?, ?, ?)",
24 repoID, title, description, due) 51 repo.ID, title, description, due)
25 if err != nil { 52 if err != nil {
26 if isUniqueErr(err) { 53 if isUniqueErr(err) {
27 return 0, fmt.Errorf("milestone %q already exists", title) 54 return 0, fmt.Errorf("milestone %q already exists", title)
28 } 55 }
29 return 0, err 56 return 0, err
30 } 57 }
31 return res.LastInsertId() 58 id, err := res.LastInsertId()
59 if err != nil {
60 return 0, err
61 }
62 return id, tx.Commit()
63}
64
65// CreateOrgMilestone creates the org's milestone. Repositories under the
66// org that hold the title are folded in: their issues and merge requests
67// move to the org's row and their rows go. folded is how many were.
68func (s *Store) CreateOrgMilestone(orgID int64, title, description, due string) (int64, int, error) {
69 tx, err := s.DB.Begin()
70 if err != nil {
71 return 0, 0, err
72 }
73 defer tx.Rollback()
74 res, err := tx.Exec(
75 "INSERT INTO milestones (org_id, title, description, due_date) VALUES (?, ?, ?, ?)",
76 orgID, title, description, due)
77 if err != nil {
78 if isUniqueErr(err) {
79 return 0, 0, fmt.Errorf("milestone %q already exists", title)
80 }
81 return 0, 0, err
82 }
83 id, err := res.LastInsertId()
84 if err != nil {
85 return 0, 0, err
86 }
87 rows, err := tx.Query(`SELECT m.id FROM milestones m JOIN repos r ON r.id = m.repo_id
88 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND m.title = ?`, orgID, title)
89 if err != nil {
90 return 0, 0, err
91 }
92 repoMilestones, err := scanIDs(rows)
93 if err != nil {
94 return 0, 0, err
95 }
96 for _, mid := range repoMilestones {
97 if err := foldMilestoneRow(tx, id, mid); err != nil {
98 return 0, 0, err
99 }
100 }
101 return id, len(repoMilestones), tx.Commit()
102}
103
104// foldMilestoneRow moves a repository's milestone onto the org's row:
105// every issue and merge request attached to it gets the org row, then the
106// repository row goes.
107func foldMilestoneRow(tx *sql.Tx, orgRow, repoRow int64) error {
108 for _, table := range []string{"issues", "merge_requests"} {
109 if _, err := tx.Exec("UPDATE "+table+" SET milestone_id = ? WHERE milestone_id = ?", orgRow, repoRow); err != nil {
110 return err
111 }
112 }
113 _, err := tx.Exec("DELETE FROM milestones WHERE id = ?", repoRow)
114 return err
32} 115}
33 116
34const milestoneSelect = ` 117// milestoneQuery selects milestones with their progress, counting only
35 SELECT m.id, m.repo_id, m.title, m.description, m.due_date, m.state, m.created_at, 118// items in the readable repositories. Its args come first in any query
36 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open') 119// built on it.
37 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone')), 120func milestoneQuery(readable []int64) (string, []any) {
38 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed') 121 in, args := inClause(readable)
39 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed')) 122 q := `
123 SELECT m.id, COALESCE(m.repo_id, 0), COALESCE(m.org_id, 0), m.title, m.description, m.due_date, m.state, m.created_at,
124 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open' AND i.repo_id IN ` + in + `)
125 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('open','source_gone') AND r.repo_id IN ` + in + `),
126 (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed' AND i.repo_id IN ` + in + `)
127 + (SELECT COUNT(*) FROM merge_requests r WHERE r.milestone_id = m.id AND r.state IN ('merged','closed') AND r.repo_id IN ` + in + `)
40 FROM milestones m` 128 FROM milestones m`
129 all := make([]any, 0, 4*len(args))
130 for i := 0; i < 4; i++ {
131 all = append(all, args...)
132 }
133 return q, all
134}
41 135
42func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) { 136func scanMilestone(row interface{ Scan(...any) error }) (Milestone, error) {
43 var m Milestone 137 var m Milestone
44 err := row.Scan(&m.ID, &m.RepoID, &m.Title, &m.Description, &m.DueDate, &m.State, 138 err := row.Scan(&m.ID, &m.RepoID, &m.OrgID, &m.Title, &m.Description, &m.DueDate, &m.State,
45 &m.CreatedAt, &m.OpenItems, &m.ClosedItems) 139 &m.CreatedAt, &m.OpenItems, &m.ClosedItems)
46 return m, err 140 return m, err
47} 141}
48 142
49func (s *Store) MilestoneByTitle(repoID int64, title string) (Milestone, error) { 143// milestoneByTitle resolves a title under where. The org's row comes
50 m, err := scanMilestone(s.DB.QueryRow( 144// first when both scopes are in play; creation keeps that from happening.
51 milestoneSelect+" WHERE m.repo_id = ? AND m.title = ?", repoID, title)) 145func (s *Store) milestoneByTitle(where string, args []any) (Milestone, error) {
146 q, qargs := milestoneQuery(nil)
147 m, err := scanMilestone(s.DB.QueryRow(q+" WHERE "+where+" ORDER BY m.org_id IS NULL LIMIT 1", append(qargs, args...)...))
52 if errors.Is(err, sql.ErrNoRows) { 148 if errors.Is(err, sql.ErrNoRows) {
53 return m, ErrNotFound 149 return m, ErrNotFound
54 } 150 }
55 return m, err 151 return m, err
56} 152}
57 153
58func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error) { 154// MilestoneByTitle resolves a title the way attaching does: the org's
59 q := milestoneSelect + " WHERE m.repo_id = ?" 155// milestone when the org has it, else the repository's. Progress counts
60 args := []any{repoID} 156// are not populated here; list for those.
157func (s *Store) MilestoneByTitle(repo Repo, title string) (Milestone, error) {
158 where, args := scopeClause("m", repo)
159 return s.milestoneByTitle(where+" AND m.title = ?", append(args, title))
160}
161
162func (s *Store) OrgMilestoneByTitle(orgID int64, title string) (Milestone, error) {
163 return s.milestoneByTitle("m.org_id = ? AND m.title = ?", []any{orgID, title})
164}
165
166func (s *Store) listMilestones(where string, args []any, state string, readable []int64) ([]Milestone, error) {
167 q, qargs := milestoneQuery(readable)
168 q += " WHERE " + where
169 qargs = append(qargs, args...)
61 if state != "all" { 170 if state != "all" {
62 q += " AND m.state = ?" 171 q += " AND m.state = ?"
63 args = append(args, state) 172 qargs = append(qargs, state)
64 } 173 }
65 q += " ORDER BY m.due_date = '', m.due_date, m.title" 174 q += " ORDER BY m.org_id IS NULL, m.due_date = '', m.due_date, m.title"
66 rows, err := s.DB.Query(q, args...) 175 rows, err := s.DB.Query(q, qargs...)
67 if err != nil { 176 if err != nil {
68 return nil, err 177 return nil, err
69 } 178 }
@@ -79,6 +188,19 @@ func (s *Store) ListMilestones(repoID int64, state string) ([]Milestone, error)
79 return out, rows.Err() 188 return out, rows.Err()
80} 189}
81 190
191// ListMilestones lists the milestones a repository sees, the org's first,
192// with progress counted over the readable repositories.
193func (s *Store) ListMilestones(repo Repo, state string, readable []int64) ([]Milestone, error) {
194 where, args := scopeClause("m", repo)
195 return s.listMilestones(where, args, state, readable)
196}
197
198// ListOrgMilestones lists an org's milestones with progress across the
199// readable repositories under it.
200func (s *Store) ListOrgMilestones(orgID int64, state string, readable []int64) ([]Milestone, error) {
201 return s.listMilestones("m.org_id = ?", []any{orgID}, state, readable)
202}
203
82func (s *Store) SetMilestoneState(id int64, state string) error { 204func (s *Store) SetMilestoneState(id int64, state string) error {
83 res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id) 205 res, err := s.DB.Exec("UPDATE milestones SET state = ? WHERE id = ?", state, id)
84 if err != nil { 206 if err != nil {
internal/store/milestones_test.go added +117
@@ -0,0 +1,117 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestOrgMilestoneSpansRepos(t *testing.T) {
9 f := newAcme(t)
10 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "first", "2027-01-01")
11 if err != nil || folded != 0 || id == 0 {
12 t.Fatalf("CreateOrgMilestone: %d, %d, %v", id, folded, err)
13 }
14 // Resolves from either repo, not from alice/app.
15 m, err := f.s.MilestoneByTitle(f.core, "v1")
16 if err != nil || m.OrgID != f.org || m.RepoID != 0 {
17 t.Fatalf("core resolves v1 = %+v, %v", m, err)
18 }
19 if _, err := f.s.MilestoneByTitle(f.app, "v1"); !errors.Is(err, ErrNotFound) {
20 t.Fatalf("app resolves v1: %v", err)
21 }
22 if err := f.s.SetIssueMilestone(f.coreIssue, id); err != nil {
23 t.Fatal(err)
24 }
25 if err := f.s.SetIssueMilestone(f.siteIssue, id); err != nil {
26 t.Fatal(err)
27 }
28 if err := f.s.SetIssueState(f.siteIssue, "closed"); err != nil {
29 t.Fatal(err)
30 }
31 ms, err := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
32 if err != nil || len(ms) != 1 || ms[0].OpenItems != 1 || ms[0].ClosedItems != 1 {
33 t.Fatalf("org list = %+v, %v", ms, err)
34 }
35 // Counts stop at what the caller can read.
36 ms, _ = f.s.ListOrgMilestones(f.org, "open", []int64{f.core.ID})
37 if ms[0].OpenItems != 1 || ms[0].ClosedItems != 0 {
38 t.Fatalf("org list over core = %+v", ms)
39 }
40 // A repo's list shows the org milestone first, then its own.
41 if _, err := f.s.CreateMilestone(f.core, "core-only", "", ""); err != nil {
42 t.Fatal(err)
43 }
44 ms, _ = f.s.ListMilestones(f.core, "open", f.orgRepos())
45 if len(ms) != 2 || ms[0].Title != "v1" || ms[0].OrgID != f.org || ms[1].Title != "core-only" || ms[1].RepoID != f.core.ID {
46 t.Fatalf("core list = %+v", ms)
47 }
48 if _, err := f.s.OrgMilestoneByTitle(f.org, "core-only"); !errors.Is(err, ErrNotFound) {
49 t.Fatalf("org resolves a repo milestone: %v", err)
50 }
51}
52
53func TestRepoMilestoneRefusedWhenOrgHoldsTitle(t *testing.T) {
54 f := newAcme(t)
55 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err != nil {
56 t.Fatal(err)
57 }
58 if _, err := f.s.CreateMilestone(f.core, "v1", "", ""); !errors.Is(err, ErrOrgScoped) {
59 t.Fatalf("CreateMilestone over org title: %v", err)
60 }
61 if _, err := f.s.CreateMilestone(f.app, "v1", "", ""); err != nil {
62 t.Fatalf("user repo unaffected: %v", err)
63 }
64 if _, _, err := f.s.CreateOrgMilestone(f.org, "v1", "", ""); err == nil {
65 t.Fatal("duplicate org milestone accepted")
66 }
67}
68
69func TestCreateOrgMilestonePromotes(t *testing.T) {
70 f := newAcme(t)
71 cid, err := f.s.CreateMilestone(f.core, "v1", "", "")
72 if err != nil {
73 t.Fatal(err)
74 }
75 sid, err := f.s.CreateMilestone(f.site, "v1", "", "")
76 if err != nil {
77 t.Fatal(err)
78 }
79 if err := f.s.SetIssueMilestone(f.coreIssue, cid); err != nil {
80 t.Fatal(err)
81 }
82 n, err := f.s.CreateMR(f.site.ID, f.alice, f.site.ID, "feat", "main", "t", "", "abc", "md", false)
83 if err != nil {
84 t.Fatal(err)
85 }
86 // CreateMR returns the per-repo MR number, not the merge_requests.id
87 // row that milestone_id references; resolve it the way SetMRMilestone
88 // callers must.
89 mr, err := f.s.MRByNumber(f.site.ID, n)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if err := f.s.SetMRMilestone(mr.ID, sid); err != nil {
94 t.Fatal(err)
95 }
96 id, folded, err := f.s.CreateOrgMilestone(f.org, "v1", "org wide", "2027-06-01")
97 if err != nil || folded != 2 {
98 t.Fatalf("promote: folded %d, %v", folded, err)
99 }
100 var count int
101 f.s.DB.QueryRow("SELECT COUNT(*) FROM milestones WHERE title = 'v1'").Scan(&count)
102 if count != 1 {
103 t.Fatalf("milestones named v1: %d", count)
104 }
105 f.s.DB.QueryRow("SELECT COUNT(*) FROM issues WHERE milestone_id = ?", id).Scan(&count)
106 if count != 1 {
107 t.Fatalf("issues on org milestone: %d", count)
108 }
109 f.s.DB.QueryRow("SELECT COUNT(*) FROM merge_requests WHERE milestone_id = ?", id).Scan(&count)
110 if count != 1 {
111 t.Fatalf("mrs on org milestone: %d", count)
112 }
113 ms, _ := f.s.ListOrgMilestones(f.org, "open", f.orgRepos())
114 if len(ms) != 1 || ms[0].OpenItems != 2 || ms[0].Description != "org wide" || ms[0].DueDate != "2027-06-01" {
115 t.Fatalf("after promote: %+v", ms)
116 }
117}
internal/store/repos.go +70 −5
@@ -476,11 +476,76 @@ func (s *Store) RenameRepo(repoID int64, newName string) error {
476 476
477// TransferRepo moves a repository to a new owner. The unique index on 477// TransferRepo moves a repository to a new owner. The unique index on
478// (owner_kind, owner_id, name) refuses collisions in the target namespace. 478// (owner_kind, owner_id, name) refuses collisions in the target namespace.
479// Moving into an org folds the repository's labels and milestones whose
480// names the org already holds into the org's rows, in the same
481// transaction, so the repository does not come out seeing two of each.
482// Moving out of an org needs no counterpart: the repository keeps what it
483// owns and stops seeing the org's rows.
479func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { 484func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
480 _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?", 485 tx, err := s.DB.Begin()
481 newKind, newOwnerID, repoID) 486 if err != nil {
482 if isUniqueErr(err) { 487 return err
483 return fmt.Errorf("the target owner already has a repository by that name")
484 } 488 }
485 return err 489 defer tx.Rollback()
490 if _, err := tx.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
491 newKind, newOwnerID, repoID); err != nil {
492 if isUniqueErr(err) {
493 return fmt.Errorf("the target owner already has a repository by that name")
494 }
495 return err
496 }
497 if newKind == "org" {
498 if err := foldIntoOrg(tx, repoID, newOwnerID); err != nil {
499 return err
500 }
501 }
502 return tx.Commit()
503}
504
505// foldIntoOrg folds a repository's labels and milestones into the org's
506// rows of the same name, the way org label set and org milestone create
507// fold the repositories already under the org.
508func foldIntoOrg(tx *sql.Tx, repoID, orgID int64) error {
509 labels, err := sharedNameRows(tx, "labels", "name", repoID, orgID)
510 if err != nil {
511 return err
512 }
513 for _, p := range labels {
514 if err := foldLabelRow(tx, p.org, p.repo); err != nil {
515 return err
516 }
517 }
518 milestones, err := sharedNameRows(tx, "milestones", "title", repoID, orgID)
519 if err != nil {
520 return err
521 }
522 for _, p := range milestones {
523 if err := foldMilestoneRow(tx, p.org, p.repo); err != nil {
524 return err
525 }
526 }
527 return nil
528}
529
530// rowPair is one repository row and the org row it folds into.
531type rowPair struct{ repo, org int64 }
532
533// sharedNameRows pairs a repository's label or milestone rows with the
534// org's rows carrying the same name.
535func sharedNameRows(tx *sql.Tx, table, nameCol string, repoID, orgID int64) ([]rowPair, error) {
536 rows, err := tx.Query("SELECT t.id, o.id FROM "+table+" t JOIN "+table+" o"+
537 " ON o.org_id = ? AND o."+nameCol+" = t."+nameCol+" WHERE t.repo_id = ?", orgID, repoID)
538 if err != nil {
539 return nil, err
540 }
541 defer rows.Close()
542 var out []rowPair
543 for rows.Next() {
544 var p rowPair
545 if err := rows.Scan(&p.repo, &p.org); err != nil {
546 return nil, err
547 }
548 out = append(out, p)
549 }
550 return out, rows.Err()
486} 551}
internal/store/scope.go added +48
@@ -0,0 +1,48 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "strings"
7)
8
9// ErrOrgScoped is returned when a repository-level write names a label or
10// milestone its org holds; the org commands manage those.
11var ErrOrgScoped = errors.New("held by the org")
12
13// scopeClause selects the label or milestone rows a repository sees: its
14// own, and its org's when an org owns it. alias is the table alias in the
15// query.
16func scopeClause(alias string, repo Repo) (string, []any) {
17 if repo.OwnerKind == "org" {
18 return "(" + alias + ".repo_id = ? OR " + alias + ".org_id = ?)", []any{repo.ID, repo.OwnerID}
19 }
20 return alias + ".repo_id = ?", []any{repo.ID}
21}
22
23// inClause renders ids as a parenthesised placeholder list. An empty set
24// yields (NULL), which matches nothing.
25func inClause(ids []int64) (string, []any) {
26 if len(ids) == 0 {
27 return "(NULL)", nil
28 }
29 args := make([]any, len(ids))
30 for i, id := range ids {
31 args[i] = id
32 }
33 return "(" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")", args
34}
35
36// scanIDs collects a single-column id result and closes the rows.
37func scanIDs(rows *sql.Rows) ([]int64, error) {
38 defer rows.Close()
39 var out []int64
40 for rows.Next() {
41 var id int64
42 if err := rows.Scan(&id); err != nil {
43 return nil, err
44 }
45 out = append(out, id)
46 }
47 return out, rows.Err()
48}
internal/store/store.go +84 −7
@@ -70,8 +70,16 @@ type migration struct {
70 name string 70 name string
71 up string 71 up string
72 down string 72 down string
73 // upFKOff and downFKOff are true when the up/down script's first line
74 // is the directive "-- foreign_keys: off".
75 upFKOff bool
76 downFKOff bool
73} 77}
74 78
79// fkOffDirective, as the first line of a migration script, opts that
80// direction out of foreign-key enforcement for its step.
81const fkOffDirective = "-- foreign_keys: off"
82
75func loadMigrations() ([]migration, error) { 83func loadMigrations() ([]migration, error) {
76 entries, err := fs.ReadDir(migrationFS, "migrations") 84 entries, err := fs.ReadDir(migrationFS, "migrations")
77 if err != nil { 85 if err != nil {
@@ -110,10 +118,15 @@ func loadMigrations() ([]migration, error) {
110 if err != nil { 118 if err != nil {
111 return nil, err 119 return nil, err
112 } 120 }
121 text := string(sqlBytes)
122 firstLine, _, _ := strings.Cut(text, "\n")
123 fkOff := strings.TrimSpace(firstLine) == fkOffDirective
113 if dir == "up" { 124 if dir == "up" {
114 m.up = string(sqlBytes) 125 m.up = text
126 m.upFKOff = fkOff
115 } else { 127 } else {
116 m.down = string(sqlBytes) 128 m.down = text
129 m.downFKOff = fkOff
117 } 130 }
118 } 131 }
119 var ms []migration 132 var ms []migration
@@ -160,8 +173,51 @@ func (s *Store) migrateTo(target int) error {
160 if err != nil { 173 if err != nil {
161 return err 174 return err
162 } 175 }
163 step := func(sqlText string, newVersion int) error { 176 step := func(sqlText string, newVersion int, fkOff bool) (retErr error) {
164 tx, err := s.DB.Begin() 177 if !fkOff {
178 tx, err := s.DB.Begin()
179 if err != nil {
180 return err
181 }
182 defer tx.Rollback()
183 if _, err := tx.Exec(sqlText); err != nil {
184 return err
185 }
186 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
187 return err
188 }
189 return tx.Commit()
190 }
191
192 // A script whose first line is "-- foreign_keys: off" rebuilds a
193 // table that other tables reference (labels, milestones): with
194 // foreign keys on, the rebuild-by-rename loses the children's
195 // rows. PRAGMA foreign_keys is a no-op inside a transaction, and
196 // the pool gives no guarantee that a pragma set on one connection
197 // is seen by the connection Begin() draws next, so the whole step
198 // — pragma off, transaction, pragma on, foreign_key_check — runs
199 // on a single pinned connection.
200 ctx := context.Background()
201 conn, err := s.DB.Conn(ctx)
202 if err != nil {
203 return err
204 }
205 defer conn.Close()
206 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
207 return err
208 }
209 // The connection goes back to the pool when this returns, so every
210 // path out of here has to put foreign keys back on first.
211 restoreFK := func() error {
212 _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
213 return err
214 }
215 defer func() {
216 if err := restoreFK(); err != nil && retErr == nil {
217 retErr = err
218 }
219 }()
220 tx, err := conn.BeginTx(ctx, nil)
165 if err != nil { 221 if err != nil {
166 return err 222 return err
167 } 223 }
@@ -172,18 +228,39 @@ func (s *Store) migrateTo(target int) error {
172 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil { 228 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
173 return err 229 return err
174 } 230 }
175 return tx.Commit() 231 if err := tx.Commit(); err != nil {
232 return err
233 }
234 if err := restoreFK(); err != nil {
235 return err
236 }
237 rows, err := conn.QueryContext(ctx, "PRAGMA foreign_key_check")
238 if err != nil {
239 return err
240 }
241 defer rows.Close()
242 if rows.Next() {
243 var table string
244 var rowid sql.NullInt64
245 var referredTable string
246 var fkid int
247 if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
248 return err
249 }
250 return fmt.Errorf("foreign_key_check failed after migration: %s", table)
251 }
252 return rows.Err()
176 } 253 }
177 for cur < target { 254 for cur < target {
178 m := ms[cur] 255 m := ms[cur]
179 if err := step(m.up, m.version); err != nil { 256 if err := step(m.up, m.version, m.upFKOff); err != nil {
180 return fmt.Errorf("migration %d up: %w", m.version, err) 257 return fmt.Errorf("migration %d up: %w", m.version, err)
181 } 258 }
182 cur = m.version 259 cur = m.version
183 } 260 }
184 for cur > target { 261 for cur > target {
185 m := ms[cur-1] 262 m := ms[cur-1]
186 if err := step(m.down, m.version-1); err != nil { 263 if err := step(m.down, m.version-1, m.downFKOff); err != nil {
187 return fmt.Errorf("migration %d down: %w", m.version, err) 264 return fmt.Errorf("migration %d down: %w", m.version, err)
188 } 265 }
189 cur = m.version - 1 266 cur = m.version - 1
internal/store/store_test.go +111
@@ -144,3 +144,114 @@ func TestSSHKeyLabel(t *testing.T) {
144 t.Fatalf("relabel by another user: %v, want ErrNotFound", err) 144 t.Fatalf("relabel by another user: %v, want ErrNotFound", err)
145 } 145 }
146} 146}
147
148// Migration 0052 rebuilds labels and milestones with an org scope. Foreign
149// keys are off for the migration: rebuilding a parent table with children
150// (issue_labels, issues.milestone_id) otherwise loses the children's rows.
151// legacy_alter_table keeps the children naming labels and milestones
152// through the rename, so they bind to the new tables rather than to
153// labels_old/milestones_old. foreign_key_check afterwards proves the ids
154// line up. This checks the ids, the memberships and the foreign keys all
155// survive.
156func TestMigration0052KeepsMembershipsAndForeignKeys(t *testing.T) {
157 s := open(t)
158 if err := s.MigrateTo(51); err != nil {
159 t.Fatal(err)
160 }
161 uid, err := s.CreateUser("alice", false)
162 if err != nil {
163 t.Fatal(err)
164 }
165 rid, err := s.CreateRepo("user", uid, "app", "public")
166 if err != nil {
167 t.Fatal(err)
168 }
169 number, err := s.CreateIssue(rid, uid, "one", "", "md")
170 if err != nil {
171 t.Fatal(err)
172 }
173 // CreateIssue returns the per-repo number; the rows below reference
174 // the issues.id row.
175 issue, err := s.IssueByNumber(rid, number)
176 if err != nil {
177 t.Fatal(err)
178 }
179 iid := issue.ID
180 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, name, color) VALUES (?, 'bug', '#ff0000')", rid); err != nil {
181 t.Fatal(err)
182 }
183 if _, err := s.DB.Exec("INSERT INTO issue_labels (issue_id, label_id) SELECT ?, id FROM labels WHERE name = 'bug'", iid); err != nil {
184 t.Fatal(err)
185 }
186 if _, err := s.DB.Exec("INSERT INTO milestones (repo_id, title) VALUES (?, 'v1')", rid); err != nil {
187 t.Fatal(err)
188 }
189 if _, err := s.DB.Exec("UPDATE issues SET milestone_id = (SELECT id FROM milestones WHERE title = 'v1') WHERE id = ?", iid); err != nil {
190 t.Fatal(err)
191 }
192 if err := s.MigrateTo(52); err != nil {
193 t.Fatal(err)
194 }
195 var n int
196 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id
197 WHERE il.issue_id = ? AND l.name = 'bug' AND l.repo_id = ? AND l.org_id IS NULL`, iid, rid).Scan(&n); err != nil || n != 1 {
198 t.Fatalf("label membership after 0052: %d, %v", n, err)
199 }
200 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issues i JOIN milestones m ON m.id = i.milestone_id
201 WHERE i.id = ? AND m.title = 'v1' AND m.repo_id = ?`, iid, rid).Scan(&n); err != nil || n != 1 {
202 t.Fatalf("milestone attachment after 0052: %d, %v", n, err)
203 }
204 rows, err := s.DB.Query("PRAGMA foreign_key_check")
205 if err != nil {
206 t.Fatal(err)
207 }
208 defer rows.Close()
209 if rows.Next() {
210 t.Fatal("foreign_key_check reported a violation after 0052")
211 }
212 // The scope CHECK holds: a row with neither or both scopes is refused.
213 if _, err := s.DB.Exec("INSERT INTO labels (name) VALUES ('neither')"); err == nil {
214 t.Fatal("label with no scope was accepted")
215 }
216 if _, err := s.DB.Exec("INSERT INTO labels (repo_id, org_id, name) VALUES (?, 1, 'both')", rid); err == nil {
217 t.Fatal("label with both scopes was accepted")
218 }
219 // Down refuses while an org-scoped row exists, and works once it is gone.
220 if _, err := s.DB.Exec("INSERT INTO orgs (name) VALUES ('acme')"); err != nil {
221 t.Fatal(err)
222 }
223 if _, err := s.DB.Exec("INSERT INTO labels (org_id, name) VALUES ((SELECT id FROM orgs WHERE name = 'acme'), 'org-only')"); err != nil {
224 t.Fatal(err)
225 }
226 if err := s.MigrateTo(51); err == nil {
227 t.Fatal("down migration accepted an org-scoped label")
228 }
229 if _, err := s.DB.Exec("DELETE FROM labels WHERE org_id IS NOT NULL"); err != nil {
230 t.Fatal(err)
231 }
232 if err := s.MigrateTo(51); err != nil {
233 t.Fatalf("down migration: %v", err)
234 }
235 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?`, iid).Scan(&n); err != nil || n != 1 {
236 t.Fatalf("label membership after down: %d, %v", n, err)
237 }
238}
239
240// Migrating all the way up runs 0052's "-- foreign_keys: off" step on its
241// own pinned connection and every other migration's script, which has no
242// such directive, on the pool as usual. A fresh query afterwards still
243// sees foreign keys on: the pinned connection re-enabled them before
244// returning to the pool, and no other connection was ever touched.
245func TestMigrationForeignKeysDirective(t *testing.T) {
246 s := open(t)
247 if err := s.MigrateUp(); err != nil {
248 t.Fatal(err)
249 }
250 var fk int
251 if err := s.DB.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
252 t.Fatal(err)
253 }
254 if fk != 1 {
255 t.Fatalf("foreign_keys after MigrateUp: %d, want 1", fk)
256 }
257}
internal/web/templates/labels.html +4 −4
@@ -5,18 +5,18 @@
5{{if .Labels}}<div class="tablewrap"><table class="keys"> 5{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col"></th></tr> 6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col"></th></tr>
7{{range .Labels}}<tr> 7{{range .Labels}}<tr>
8 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a></td> 8 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td>
9 <td>{{if $.CanWrite}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 9 <td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
10 <input type="hidden" name="name" value="{{.Name}}"> 10 <input type="hidden" name="name" value="{{.Name}}">
11 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8"> 11 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
12 <button type="submit" class="btn">Save</button> 12 <button type="submit" class="btn">Save</button>
13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td> 13 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>
14 <td>{{.Issues}}</td> 14 <td>{{.Issues}}</td>
15 <td class="act">{{if $.CanWrite}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 15 <td class="act">{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
16 <input type="hidden" name="action" value="remove"> 16 <input type="hidden" name="action" value="remove">
17 <input type="hidden" name="name" value="{{.Name}}"> 17 <input type="hidden" name="name" value="{{.Name}}">
18 <button type="submit" class="linklike">Remove</button> 18 <button type="submit" class="linklike">Remove</button>
19 </form>{{end}}</td> 19 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
20</tr> 20</tr>
21{{end}}</table></div> 21{{end}}</table></div>
22{{else}}<p class="none">No labels yet.</p>{{end}} 22{{else}}<p class="none">No labels yet.</p>{{end}}
internal/web/templates/milestones.html +1 −1
@@ -11,7 +11,7 @@
11<ul class="milestonelist"> 11<ul class="milestonelist">
12{{range .Milestones}}<li> 12{{range .Milestones}}<li>
13 <div class="msmain"> 13 <div class="msmain">
14 <p class="title">{{.Title}} <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span></p> 14 <p class="title">{{.Title}} <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>{{if .OrgID}} <span class="chip chip-neutral">org</span>{{end}}</p>
15 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}} 15 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
16 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p> 16 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
17 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> 17 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
internal/web/templates/orglabels.html added +14
@@ -0,0 +1,14 @@
1{{define "title"}}labels · {{.Org}}{{end}}
2{{define "content"}}
3<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1>
4<p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} &lt;label&gt;</code>; counts span the repositories you can read.</p>
5{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th></tr>
7{{range .Labels}}<tr>
8 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td>
9 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td>
10 <td>{{.Issues}}</td>
11</tr>
12{{end}}</table></div>
13{{else}}<p class="none">No org labels yet.</p>{{end}}
14{{end}}
internal/web/templates/orgmilestones.html added +23
@@ -0,0 +1,23 @@
1{{define "title"}}milestones · {{.Org}}{{end}}
2{{define "content"}}
3<div class="listhead">
4 <h1><a href="/{{.Org}}">{{.Org}}</a> milestones</h1>
5 <nav class="filters">
6 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
7 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
8 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
9 </nav>
10</div>
11<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p>
12<ul class="milestonelist">
13{{range .Milestones}}<li>
14 <div class="msmain">
15 <p class="title">{{.Title}} <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span></p>
16 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
17 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
18 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
19 </div>
20</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones — create one with <code>gitbay org milestone create {{.Org}} "v1.0"</code></li>{{end}}
22</ul>
23{{end}}
internal/web/templates/owner.html +1
@@ -7,6 +7,7 @@
7{{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}} 7{{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}}
8{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Name}}">{{.Name}}</a> {{end}}</p>{{end}} 8{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Name}}">{{.Name}}</a> {{end}}</p>{{end}}
9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} 9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
10{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
10</section> 11</section>
11{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} 12{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
12<section class="activity"> 13<section class="activity">