docs/plans/2026-09-11-org-labels-milestones-closes.md

v1.42.0
gitbay/docs/plans/2026-09-11-org-labels-milestones-closes.md rendered · source · history · blame · raw

2691 lines · 100281 bytes

   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.