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

b347d6c8c464e3c965455795f4e22e0aaeed0652
gitbay/docs/specs/2026-09-11-org-labels-milestones-closes-design.md rendered · source · history · blame · raw

215 lines · 10453 bytes

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