Commit fd856464cc

fd856464ccefd975f8d595399ff44047fe865684

parent: 3c0c20da78

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

cmc <hello@cleberg.net> · 2026-09-11 02:46 UTC

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

Ref #203
docs/specs/2026-09-11-org-labels-milestones-closes-design.md added +212
@@ -0,0 +1,212 @@
1# Org-level labels and milestones, cross-repository closes
2
3Closes #203 (ref #185). Labels and milestones an org defines once for every
4repository under it, and `Closes owner/name#N` acting on another repository
5the actor can write to.
6
7## Problem
8
9Labels and milestones are rows keyed on `repo_id`; `closes #N` acts in the
10repository the commit landed in (`internal/control/commitrefs.go`). An org
11with several repositories recreates its labels in each, keeps a milestone
12per repository for one release, and cannot close `ttorg/widget#1` from a
13commit to `ttorg/lib`. The web already links `owner/name#N` across
14repositories (`internal/autolink`, with a read check); only the action is
15missing.
16
17## Decision
18
19All three move beyond the repository:
20
21- An org holds labels and milestones. Every repository owned by the org
22 sees them beside its own. Org rows are managed by org admins through
23 `org label` and `org milestone`.
24- `Closes owner/name#N` in a commit on the default branch, or in a merged
25 merge request's title or body, closes that issue when the pusher or
26 merger holds write on the target. Otherwise the text stays a plain
27 autolink.
28
29Decisions taken on the way, with the alternatives rejected:
30
31- **Scope columns on the existing tables**, not separate `org_labels` and
32 `org_milestones` tables. `issue_labels` and the two `milestone_id` columns
33 keep pointing at the same ids, so attaching, filtering and counting do
34 not fork into two sources. The cost is a table rebuild in the migration.
35- **Inherited, not templated.** An org label is one row every repository
36 reads, not a copy made at repository creation. Copies drift, which is
37 what #203 complains about.
38- **Any writable target for closes**, not same-org only. Write on the
39 target is the permission `issue close` needs there; the org boundary
40 would be narrower than the model and one more rule to explain.
41- **Org admins manage org rows.** Repository write is enough for repo rows
42 today; the org's rows affect every repository, so the org's admin role
43 is the gate.
44- **Promote on org create.** `org label set bug` when repositories under
45 the org already hold `bug` folds them into the org row rather than
46 refusing. Refusing would make the migrant's first command fail against
47 exactly the duplication they came to remove.
48- **Org pages under `/{org}/-/`.** A hyphen cannot start a repository
49 name, so `/{org}/-/labels` shadows nothing and reserves nothing.
50- **Org writes are CLI and API only.** The repository label page's form
51 exists for colour alone; three org forms nobody asked for are not
52 worth their handlers. Recorded in Parity as deliberate.
53
54## Data
55
56Migration 0052 rebuilds `labels` and `milestones` the way 0041 rebuilt
57`commit_statuses`: rename, create, copy with ids, drop. Unlike
58`commit_statuses`, both tables have children (`issue_labels`,
59`issues.milestone_id`, `merge_requests.milestone_id`), and since SQLite
603.26 `ALTER TABLE RENAME` rewrites a child's foreign key to follow the
61renamed parent, which would leave the children pointing at `labels_old`.
62The script therefore brackets the renames with `PRAGMA legacy_alter_table
63= ON` and `= OFF`, which a transaction allows; the children keep naming
64`labels` and `milestones` and bind to the new tables. `foreign_keys` stays
65on, so the copy is checked and the drop of the old tables cascades
66nothing, since nothing references them. The migration test runs `PRAGMA
67foreign_key_check` afterwards and expects no rows.
68
69```sql
70CREATE TABLE labels (
71 id INTEGER PRIMARY KEY,
72 repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE,
73 org_id INTEGER REFERENCES orgs(id) ON DELETE CASCADE,
74 name TEXT NOT NULL,
75 color TEXT NOT NULL DEFAULT '',
76 CHECK ((repo_id IS NULL) <> (org_id IS NULL))
77);
78CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL;
79CREATE UNIQUE INDEX labels_org_name ON labels(org_id, name) WHERE org_id IS NOT NULL;
80```
81
82`milestones` keeps `title`, `description`, `due_date`, `state`, `created_at`
83and gets the same `repo_id`/`org_id` pair, CHECK and two partial unique
84indexes in place of `UNIQUE (repo_id, title)`.
85
86The down migration recreates the old shape and fails if any org-scoped row
87exists; there is no repository to give such a row to.
88
89`store.Label` and `store.Milestone` gain `OrgID int64` beside `RepoID`.
90
91## Resolution
92
93Store lookups that today take a `repoID` take the `store.Repo` and derive
94the scope: `repo_id = ?` for a user-owned repository, `repo_id = ? OR
95org_id = ?` with `repo.OwnerID` when `repo.OwnerKind == "org"`.
96
97- **Listing** for a repository returns org rows then repo rows, each by
98 name. `label list`, `milestone list` and the web pages mark org rows.
99- **Attaching** by name (`issue label --add`, `issue milestone`, `mr
100 milestone`) resolves the org row when one exists, else the repo row.
101 `issue label --add` still creates a repo label on the fly when neither
102 exists.
103- **Repo-level create** (`label set`, `milestone create`) is refused when
104 the org holds the name: `bug is an org label; set it with org label set
105 <org> bug`. Exit 1. `label remove`, `milestone close` and `milestone
106 reopen` refuse an org row the same way.
107- **Org-level create** when repositories under the org hold the name
108 promotes, in one transaction: insert the org row, repoint
109 `issue_labels.label_id` (or `issues.milestone_id` and
110 `merge_requests.milestone_id`) from each repo row to it, delete the repo
111 rows. The colour, description and due date are the ones on the command.
112 The reply names how many repositories were folded in.
113- **Filtering** (`--label`, `--milestone`, the web filters) resolves the
114 name the same way, so an org milestone filters a repository's list like
115 a repo one.
116- **Counting.** An org label's use count and an org milestone's open and
117 closed totals span the org's repositories the caller can read. The store
118 takes the readable repository ids the caller already gets for `repo
119 list` and restricts the count subqueries to `repo_id IN (...)`. An
120 anonymous web viewer counts public repositories only.
121
122## Commands
123
124One file, `internal/control/orglabel.go`.
125
126```
127org label set <org> <label> [--color rrggbb|'']
128org label list <org> ReadOnly
129org label remove <org> <label>
130org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD]
131org milestone list <org> [--state open|closed|all] ReadOnly
132org milestone close <org> <title>
133org milestone reopen <org> <title>
134```
135
136Writes require org admin, via `OrgRole`, the gate `org members add` uses.
137Reads require membership or a public repository under the org; an outsider
138gets "denied", not "no organization", as `org show` answers today. `org
139label set` on an existing org label sets the colour. `org label remove`
140takes the label off every issue in the org through the existing cascade.
141
142JSON: `org label list` returns `[{name, color, uses}]`; `org milestone
143list` returns the milestone rows with `open` and `closed` counts, as
144`milestone list` does. Each command gets a `pass()` in `cmd/gitbay/main.go`.
145
146## Closes
147
148`closePat` gains an optional path prefix:
149
150```
151(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[ :]+(?:([a-z0-9][a-z0-9._-]*)/([a-z0-9][a-z0-9._-]*))?#(\d+)\b
152```
153
154`ProcessCommitMessages` and `ProcessMRDescription` resolve a prefixed match
155with `RepoByPath`, look up the actor's grant on the target with
156`AccessRole`, and act only if `policy.CanWrite`. An unknown path or a
157refused target does nothing and logs nothing above debug; the text remains
158an autolink only readers see. On success `actOnIssue` runs against the
159target: the issue closes, the system comment links the source commit by
160full path, the `issue.closed` event lands on the target's feed, and the
161once-per-(issue, sha) record applies. Bare `owner/name#N` without a
162keyword stays display-only.
163
164## Web
165
166- `GET /{owner}/-/labels` and `GET /{owner}/-/milestones` for an org,
167 rendered from `labels.html` and `milestones.html` with the org as scope
168 and no edit form, linked from the org page. 404 for a user owner, and
169 for an org the viewer cannot see any repository of.
170- Repository label and milestone pages show org rows with an "org" mark
171 and no edit control.
172- Issue and merge request lists, filters and the milestone picker need
173 only the store change; templates gain the mark.
174- No new event kinds; label and milestone changes are configuration.
175
176## Docs
177
178- Users: an "Org labels and milestones" paragraph after the milestones
179 one, and `Closes owner/name#N` in the commit-references paragraph.
180- Parity: rows for `org label set/list/remove`, `org milestone
181 create/list/close/reopen` (CLI yes, web read-only, API yes) and
182 cross-repo closes; org writes recorded as deliberately CLI-only.
183- FAQ: nothing, the question no longer needs a "not planned" answer.
184- CHANGELOG entry under the next version.
185
186## Tests
187
188- Store: migration 0052 over seeded repo labels and milestones attached
189 to issues and MRs, ids and memberships intact and `PRAGMA
190 foreign_key_check` empty; promote folding two
191 repositories' `bug` into one org row; repo-level create refused against
192 an org name; counts restricted to a readable set.
193- Control: per command, org admin versus member on writes, outsider
194 wording on reads; `issue label --add` resolving to the org row;
195 `--milestone` filtering by an org milestone in `issue list` and `mr
196 list`.
197- Closes, in `commitrefs_test.go`: prefixed close with write on the
198 target closes; without write leaves it open; unknown path ignored; plain
199 `#N` unchanged; once per issue and sha; the MR description path.
200- e2e, `e2e/orglabels_test.go`: an org with two repositories, `org label
201 set` then an issue in each carrying it, an org milestone with progress
202 across both, a push to one repository closing an issue in the other, the
203 two org pages for a member, and a private org's pages for an outsider.
204- `TestReadOnlyCommandsWriteNothing` and the CLI coverage test cover the
205 new commands without additions.
206
207## Rollout
208
209One MR. Migration 0052 runs on daemon start; the rebuild copies every row
210once and is fast at this scale. No config, no runner change, no client
211change. Ships in the next minor version, since it adds commands and a
212migration.