Commit fd856464cc
Verified · cmc ci/build: success ci/test: success
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 | ||
| 3 | Closes #203 (ref #185). Labels and milestones an org defines once for every | |
| 4 | repository under it, and `Closes owner/name#N` acting on another repository | |
| 5 | the actor can write to. | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | Labels and milestones are rows keyed on `repo_id`; `closes #N` acts in the | |
| 10 | repository the commit landed in (`internal/control/commitrefs.go`). An org | |
| 11 | with several repositories recreates its labels in each, keeps a milestone | |
| 12 | per repository for one release, and cannot close `ttorg/widget#1` from a | |
| 13 | commit to `ttorg/lib`. The web already links `owner/name#N` across | |
| 14 | repositories (`internal/autolink`, with a read check); only the action is | |
| 15 | missing. | |
| 16 | ||
| 17 | ## Decision | |
| 18 | ||
| 19 | All 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 | ||
| 29 | Decisions 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 | ||
| 56 | Migration 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 | |
| 60 | 3.26 `ALTER TABLE RENAME` rewrites a child's foreign key to follow the | |
| 61 | renamed parent, which would leave the children pointing at `labels_old`. | |
| 62 | The 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 | |
| 65 | on, so the copy is checked and the drop of the old tables cascades | |
| 66 | nothing, since nothing references them. The migration test runs `PRAGMA | |
| 67 | foreign_key_check` afterwards and expects no rows. | |
| 68 | ||
| 69 | ```sql | |
| 70 | CREATE 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 | ); | |
| 78 | CREATE UNIQUE INDEX labels_repo_name ON labels(repo_id, name) WHERE repo_id IS NOT NULL; | |
| 79 | CREATE 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` | |
| 83 | and gets the same `repo_id`/`org_id` pair, CHECK and two partial unique | |
| 84 | indexes in place of `UNIQUE (repo_id, title)`. | |
| 85 | ||
| 86 | The down migration recreates the old shape and fails if any org-scoped row | |
| 87 | exists; 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 | ||
| 93 | Store lookups that today take a `repoID` take the `store.Repo` and derive | |
| 94 | the scope: `repo_id = ?` for a user-owned repository, `repo_id = ? OR | |
| 95 | org_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 | ||
| 124 | One file, `internal/control/orglabel.go`. | |
| 125 | ||
| 126 | ``` | |
| 127 | org label set <org> <label> [--color rrggbb|''] | |
| 128 | org label list <org> ReadOnly | |
| 129 | org label remove <org> <label> | |
| 130 | org milestone create <org> <title> [--description <d>] [--due YYYY-MM-DD] | |
| 131 | org milestone list <org> [--state open|closed|all] ReadOnly | |
| 132 | org milestone close <org> <title> | |
| 133 | org milestone reopen <org> <title> | |
| 134 | ``` | |
| 135 | ||
| 136 | Writes require org admin, via `OrgRole`, the gate `org members add` uses. | |
| 137 | Reads require membership or a public repository under the org; an outsider | |
| 138 | gets "denied", not "no organization", as `org show` answers today. `org | |
| 139 | label set` on an existing org label sets the colour. `org label remove` | |
| 140 | takes the label off every issue in the org through the existing cascade. | |
| 141 | ||
| 142 | JSON: `org label list` returns `[{name, color, uses}]`; `org milestone | |
| 143 | list` 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 | |
| 155 | with `RepoByPath`, look up the actor's grant on the target with | |
| 156 | `AccessRole`, and act only if `policy.CanWrite`. An unknown path or a | |
| 157 | refused target does nothing and logs nothing above debug; the text remains | |
| 158 | an autolink only readers see. On success `actOnIssue` runs against the | |
| 159 | target: the issue closes, the system comment links the source commit by | |
| 160 | full path, the `issue.closed` event lands on the target's feed, and the | |
| 161 | once-per-(issue, sha) record applies. Bare `owner/name#N` without a | |
| 162 | keyword 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 | ||
| 209 | One MR. Migration 0052 runs on daemon start; the rebuild copies every row | |
| 210 | once and is fast at this scale. No config, no runner change, no client | |
| 211 | change. Ships in the next minor version, since it adds commands and a | |
| 212 | migration. | |