Commit c08edd0c19

c08edd0c19af6a3280cc505f5701d9969e083f30

parent: fd856464cc

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 06:12 UTC

docs: plan for org labels, milestones and cross-repository closes

Ref #203
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.