Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +37 −0
| @@ -285,6 +285,43 @@ body.drawer-open .drawer-backdrop { | |||
| 285 | 285 | display: inline; | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | + | /* Labels. */ | |
| 289 | + | .label-chip { | |
| 290 | + | display: inline-flex; | |
| 291 | + | align-items: center; | |
| 292 | + | padding: 0.1rem 0.55rem; | |
| 293 | + | border-radius: 999px; | |
| 294 | + | font-size: 0.85em; | |
| 295 | + | font-weight: 600; | |
| 296 | + | color: #fff; | |
| 297 | + | background: var(--label-color, #888); | |
| 298 | + | border: 1px solid rgba(0, 0, 0, 0.25); | |
| 299 | + | } | |
| 300 | + | .label-list .entry-row-body { | |
| 301 | + | display: flex; | |
| 302 | + | flex-direction: column; | |
| 303 | + | gap: 0.25rem; | |
| 304 | + | align-items: flex-start; | |
| 305 | + | } | |
| 306 | + | .label-add { | |
| 307 | + | display: flex; | |
| 308 | + | gap: 0.5rem; | |
| 309 | + | flex-wrap: wrap; | |
| 310 | + | align-items: stretch; | |
| 311 | + | } | |
| 312 | + | .label-add input[type="text"] { | |
| 313 | + | flex: 1; | |
| 314 | + | min-width: 10rem; | |
| 315 | + | } | |
| 316 | + | .label-add input[type="color"] { | |
| 317 | + | width: 2.6rem; | |
| 318 | + | padding: 0.15rem; | |
| 319 | + | height: 2.4rem; | |
| 320 | + | } | |
| 321 | + | .label-add button { | |
| 322 | + | flex: none; | |
| 323 | + | } | |
| 324 | + | ||
| 288 | 325 | /* Repo settings danger zone. */ | |
| 289 | 326 | .danger-zone { | |
| 290 | 327 | border-color: var(--fb-danger); | |
crates/auth/src/access.rs +3 −0
| @@ -147,6 +147,9 @@ mod tests { | |||
| 147 | 147 | description: None, | |
| 148 | 148 | visibility, | |
| 149 | 149 | default_branch: "main".to_string(), | |
| 150 | + | issues_enabled: true, | |
| 151 | + | pulls_enabled: true, | |
| 152 | + | next_iid: 1, | |
| 150 | 153 | archived_at: None, | |
| 151 | 154 | size_bytes: 0, | |
| 152 | 155 | pushed_at: None, | |
crates/model/src/entity.rs +128 −0
| @@ -226,6 +226,128 @@ pub struct ApiToken { | |||
| 226 | 226 | pub created_at: Timestamp, | |
| 227 | 227 | } | |
| 228 | 228 | ||
| 229 | + | /// A per-repo issue/PR label, optionally **scoped** (`kind: value`, e.g. | |
| 230 | + | /// `type: backend`). Scoped labels are mutually exclusive within their scope. | |
| 231 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 232 | + | pub struct Label { | |
| 233 | + | /// ULID primary key. | |
| 234 | + | pub id: String, | |
| 235 | + | /// Owning repository. | |
| 236 | + | pub repo_id: String, | |
| 237 | + | /// The label name, e.g. `bug` or `type: backend`. | |
| 238 | + | pub name: String, | |
| 239 | + | /// Hex colour (`#rrggbb`). | |
| 240 | + | pub color: String, | |
| 241 | + | /// Optional description. | |
| 242 | + | pub description: Option<String>, | |
| 243 | + | /// Creation time. | |
| 244 | + | pub created_at: Timestamp, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | impl Label { | |
| 248 | + | /// The label's scope (`kind`) when it is `kind: value`, else `None`. | |
| 249 | + | #[must_use] | |
| 250 | + | pub fn scope(&self) -> Option<&str> { | |
| 251 | + | self.name.split_once(": ").map(|(scope, _)| scope.trim()) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// Whether an issue/PR is open or closed. | |
| 256 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 257 | + | pub enum IssueState { | |
| 258 | + | /// Open. | |
| 259 | + | Open, | |
| 260 | + | /// Closed. | |
| 261 | + | Closed, | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | impl IssueState { | |
| 265 | + | /// The database token (`open` / `closed`). | |
| 266 | + | #[must_use] | |
| 267 | + | pub fn as_str(self) -> &'static str { | |
| 268 | + | match self { | |
| 269 | + | Self::Open => "open", | |
| 270 | + | Self::Closed => "closed", | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// Parse from the database token. | |
| 275 | + | #[must_use] | |
| 276 | + | pub fn from_token(s: &str) -> Self { | |
| 277 | + | if s == "closed" { | |
| 278 | + | Self::Closed | |
| 279 | + | } else { | |
| 280 | + | Self::Open | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// An issue or (when `is_pull`) a pull request. Both share the `issues` table and | |
| 286 | + | /// the per-repo number counter, so `#N` is unique across both. | |
| 287 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 288 | + | pub struct Issue { | |
| 289 | + | /// ULID primary key. | |
| 290 | + | pub id: String, | |
| 291 | + | /// Owning repository. | |
| 292 | + | pub repo_id: String, | |
| 293 | + | /// The per-repo number shown as `#N`. | |
| 294 | + | pub number: Timestamp, | |
| 295 | + | /// The author. | |
| 296 | + | pub author_id: String, | |
| 297 | + | /// Title. | |
| 298 | + | pub title: String, | |
| 299 | + | /// Markdown body. | |
| 300 | + | pub body: String, | |
| 301 | + | /// Open or closed. | |
| 302 | + | pub state: IssueState, | |
| 303 | + | /// Whether this row is a pull request. | |
| 304 | + | pub is_pull: bool, | |
| 305 | + | /// The assignee, if any. | |
| 306 | + | pub assignee_id: Option<String>, | |
| 307 | + | /// Creation time. | |
| 308 | + | pub created_at: Timestamp, | |
| 309 | + | /// Last update time. | |
| 310 | + | pub updated_at: Timestamp, | |
| 311 | + | /// When it was closed, if it is. | |
| 312 | + | pub closed_at: Option<Timestamp>, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// A comment on an issue or pull request. | |
| 316 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 317 | + | pub struct Comment { | |
| 318 | + | /// ULID primary key. | |
| 319 | + | pub id: String, | |
| 320 | + | /// The issue/PR this comment belongs to. | |
| 321 | + | pub issue_id: String, | |
| 322 | + | /// The author. | |
| 323 | + | pub author_id: String, | |
| 324 | + | /// Markdown body. | |
| 325 | + | pub body: String, | |
| 326 | + | /// Creation time. | |
| 327 | + | pub created_at: Timestamp, | |
| 328 | + | /// Last edit time. | |
| 329 | + | pub updated_at: Timestamp, | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// The pull-request side table for an `issues` row with `is_pull = 1`. | |
| 333 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 334 | + | pub struct PullRequest { | |
| 335 | + | /// The `issues` row id. | |
| 336 | + | pub issue_id: String, | |
| 337 | + | /// The base ref (merged into). | |
| 338 | + | pub base_ref: String, | |
| 339 | + | /// The head ref (source of the changes). | |
| 340 | + | pub head_ref: String, | |
| 341 | + | /// When it was merged, if it has been. | |
| 342 | + | pub merged_at: Option<Timestamp>, | |
| 343 | + | /// Who merged it. | |
| 344 | + | pub merged_by: Option<String>, | |
| 345 | + | /// The resulting merge commit sha. | |
| 346 | + | pub merge_sha: Option<String>, | |
| 347 | + | /// The strategy used (`merge` | `squash` | `rebase`). | |
| 348 | + | pub merge_strategy: Option<String>, | |
| 349 | + | } | |
| 350 | + | ||
| 229 | 351 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the | |
| 230 | 352 | /// database is the sole authority mapping `(owner, path)` to this id. | |
| 231 | 353 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| @@ -247,6 +369,12 @@ pub struct Repo { | |||
| 247 | 369 | pub visibility: Visibility, | |
| 248 | 370 | /// The branch shown by default and used for HEAD. | |
| 249 | 371 | pub default_branch: String, | |
| 372 | + | /// Whether issues are enabled for this repository. | |
| 373 | + | pub issues_enabled: bool, | |
| 374 | + | /// Whether pull requests are enabled for this repository. | |
| 375 | + | pub pulls_enabled: bool, | |
| 376 | + | /// The next issue/PR number to allocate (shared counter). | |
| 377 | + | pub next_iid: Timestamp, | |
| 250 | 378 | /// When the repo was archived (read-only), if it is. | |
| 251 | 379 | pub archived_at: Option<Timestamp>, | |
| 252 | 380 | /// Approximate on-disk size in bytes, refreshed by maintenance. | |
crates/model/src/lib.rs +4 −1
| @@ -11,5 +11,8 @@ | |||
| 11 | 11 | pub mod entity; | |
| 12 | 12 | pub mod name; | |
| 13 | 13 | ||
| 14 | - | pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User, Visibility}; | |
| 14 | + | pub use entity::{ | |
| 15 | + | ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo, | |
| 16 | + | Timestamp, User, Visibility, | |
| 17 | + | }; | |
| 15 | 18 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | |
crates/store/src/labels.rs +140 −0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Per-repository issue/PR labels (optionally scoped `kind: value`). | |
| 6 | + | ||
| 7 | + | use model::Label; | |
| 8 | + | use sqlx::any::AnyRow; | |
| 9 | + | use sqlx::{AssertSqlSafe, Row}; | |
| 10 | + | ||
| 11 | + | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |
| 12 | + | ||
| 13 | + | /// The columns of `labels` in a fixed order. | |
| 14 | + | const LABEL_COLUMNS: &str = "id, repo_id, name, color, description, created_at"; | |
| 15 | + | ||
| 16 | + | impl Store { | |
| 17 | + | /// Create a label on a repo. Names are unique per repo, case-insensitively. | |
| 18 | + | /// | |
| 19 | + | /// # Errors | |
| 20 | + | /// | |
| 21 | + | /// * [`StoreError::Conflict`] if the name is already used in the repo. | |
| 22 | + | /// * [`StoreError::Query`] for any other database failure. | |
| 23 | + | pub async fn create_label( | |
| 24 | + | &self, | |
| 25 | + | repo_id: &str, | |
| 26 | + | name: &str, | |
| 27 | + | color: &str, | |
| 28 | + | description: Option<&str>, | |
| 29 | + | ) -> Result<Label, StoreError> { | |
| 30 | + | let label = Label { | |
| 31 | + | id: new_id(), | |
| 32 | + | repo_id: repo_id.to_string(), | |
| 33 | + | name: name.to_string(), | |
| 34 | + | color: color.to_string(), | |
| 35 | + | description: description.map(str::to_string), | |
| 36 | + | created_at: now_ms(), | |
| 37 | + | }; | |
| 38 | + | let sql = "INSERT INTO labels (id, repo_id, name, name_lower, color, description, created_at) \ | |
| 39 | + | VALUES ($1, $2, $3, $4, $5, $6, $7)"; | |
| 40 | + | sqlx::query(sql) | |
| 41 | + | .bind(&label.id) | |
| 42 | + | .bind(&label.repo_id) | |
| 43 | + | .bind(&label.name) | |
| 44 | + | .bind(label.name.to_lowercase()) | |
| 45 | + | .bind(&label.color) | |
| 46 | + | .bind(&label.description) | |
| 47 | + | .bind(label.created_at) | |
| 48 | + | .execute(&self.pool) | |
| 49 | + | .await | |
| 50 | + | .map_err(|e| map_conflict(e, "label", &[("name", "name")]))?; | |
| 51 | + | Ok(label) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// List a repo's labels, alphabetical (scoped labels group naturally). | |
| 55 | + | /// | |
| 56 | + | /// # Errors | |
| 57 | + | /// | |
| 58 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 59 | + | pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>, StoreError> { | |
| 60 | + | let sql = format!( | |
| 61 | + | "SELECT {LABEL_COLUMNS} FROM labels WHERE repo_id = $1 ORDER BY name_lower ASC" | |
| 62 | + | ); | |
| 63 | + | let rows = sqlx::query(AssertSqlSafe(sql)) | |
| 64 | + | .bind(repo_id) | |
| 65 | + | .fetch_all(&self.pool) | |
| 66 | + | .await?; | |
| 67 | + | rows.iter() | |
| 68 | + | .map(map_label) | |
| 69 | + | .collect::<Result<_, _>>() | |
| 70 | + | .map_err(Into::into) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Fetch a label by id. | |
| 74 | + | /// | |
| 75 | + | /// # Errors | |
| 76 | + | /// | |
| 77 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 78 | + | pub async fn label_by_id(&self, id: &str) -> Result<Option<Label>, StoreError> { | |
| 79 | + | let sql = format!("SELECT {LABEL_COLUMNS} FROM labels WHERE id = $1"); | |
| 80 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 81 | + | .bind(id) | |
| 82 | + | .fetch_optional(&self.pool) | |
| 83 | + | .await?; | |
| 84 | + | Ok(row.as_ref().map(map_label).transpose()?) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Update a label's name, colour, and description. | |
| 88 | + | /// | |
| 89 | + | /// # Errors | |
| 90 | + | /// | |
| 91 | + | /// * [`StoreError::Conflict`] if the new name collides in the repo. | |
| 92 | + | /// * [`StoreError::Query`] for any other database failure. | |
| 93 | + | pub async fn update_label( | |
| 94 | + | &self, | |
| 95 | + | id: &str, | |
| 96 | + | name: &str, | |
| 97 | + | color: &str, | |
| 98 | + | description: Option<&str>, | |
| 99 | + | ) -> Result<bool, StoreError> { | |
| 100 | + | let sql = "UPDATE labels SET name = $1, name_lower = $2, color = $3, description = $4 \ | |
| 101 | + | WHERE id = $5"; | |
| 102 | + | let updated = sqlx::query(sql) | |
| 103 | + | .bind(name) | |
| 104 | + | .bind(name.to_lowercase()) | |
| 105 | + | .bind(color) | |
| 106 | + | .bind(description) | |
| 107 | + | .bind(id) | |
| 108 | + | .execute(&self.pool) | |
| 109 | + | .await | |
| 110 | + | .map_err(|e| map_conflict(e, "label", &[("name", "name")]))? | |
| 111 | + | .rows_affected(); | |
| 112 | + | Ok(updated > 0) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Delete a label (cascading to `issue_labels`). Returns `true` if removed. | |
| 116 | + | /// | |
| 117 | + | /// # Errors | |
| 118 | + | /// | |
| 119 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 120 | + | pub async fn delete_label(&self, id: &str) -> Result<bool, StoreError> { | |
| 121 | + | let deleted = sqlx::query("DELETE FROM labels WHERE id = $1") | |
| 122 | + | .bind(id) | |
| 123 | + | .execute(&self.pool) | |
| 124 | + | .await? | |
| 125 | + | .rows_affected(); | |
| 126 | + | Ok(deleted > 0) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Map a `labels` row to a [`Label`]. | |
| 131 | + | fn map_label(row: &AnyRow) -> Result<Label, sqlx::Error> { | |
| 132 | + | Ok(Label { | |
| 133 | + | id: row.try_get("id")?, | |
| 134 | + | repo_id: row.try_get("repo_id")?, | |
| 135 | + | name: row.try_get("name")?, | |
| 136 | + | color: row.try_get("color")?, | |
| 137 | + | description: row.try_get("description")?, | |
| 138 | + | created_at: row.try_get("created_at")?, | |
| 139 | + | }) | |
| 140 | + | } | |
crates/store/src/lib.rs +1 −0
| @@ -28,6 +28,7 @@ | |||
| 28 | 28 | mod groups; | |
| 29 | 29 | mod invites; | |
| 30 | 30 | mod keys; | |
| 31 | + | mod labels; | |
| 31 | 32 | mod repos; | |
| 32 | 33 | mod sessions; | |
| 33 | 34 | mod tokens; | |
crates/store/src/repos.rs +36 −4
| @@ -12,7 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | |
| 14 | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | |
| 15 | - | default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 15 | + | default_branch, issues_enabled, pulls_enabled, next_iid, archived_at, size_bytes, \ | |
| 16 | + | pushed_at, created_at, updated_at"; | |
| 16 | 17 | ||
| 17 | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| 18 | 19 | /// and the derived size/push bookkeeping. | |
| @@ -65,6 +66,9 @@ impl Store { | |||
| 65 | 66 | description: new.description, | |
| 66 | 67 | visibility: new.visibility, | |
| 67 | 68 | default_branch: new.default_branch, | |
| 69 | + | issues_enabled: true, | |
| 70 | + | pulls_enabled: true, | |
| 71 | + | next_iid: 1, | |
| 68 | 72 | archived_at: None, | |
| 69 | 73 | size_bytes: 0, | |
| 70 | 74 | pushed_at: None, | |
| @@ -215,6 +219,34 @@ impl Store { | |||
| 215 | 219 | Ok(updated > 0) | |
| 216 | 220 | } | |
| 217 | 221 | ||
| 222 | + | /// Enable or disable issues / pull requests for a repository. `feature` is | |
| 223 | + | /// `"issues"` or `"pulls"`. Returns `true` if the row was updated. | |
| 224 | + | /// | |
| 225 | + | /// # Errors | |
| 226 | + | /// | |
| 227 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 228 | + | pub async fn set_feature_enabled( | |
| 229 | + | &self, | |
| 230 | + | repo_id: &str, | |
| 231 | + | feature: &str, | |
| 232 | + | enabled: bool, | |
| 233 | + | ) -> Result<bool, StoreError> { | |
| 234 | + | let column = match feature { | |
| 235 | + | "issues" => "issues_enabled", | |
| 236 | + | "pulls" => "pulls_enabled", | |
| 237 | + | _ => return Ok(false), | |
| 238 | + | }; | |
| 239 | + | let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3"); | |
| 240 | + | let updated = sqlx::query(AssertSqlSafe(sql)) | |
| 241 | + | .bind(enabled) | |
| 242 | + | .bind(now_ms()) | |
| 243 | + | .bind(repo_id) | |
| 244 | + | .execute(&self.pool) | |
| 245 | + | .await? | |
| 246 | + | .rows_affected(); | |
| 247 | + | Ok(updated > 0) | |
| 248 | + | } | |
| 249 | + | ||
| 218 | 250 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; | |
| 219 | 251 | /// unarchiving clears it. Returns `true` if the row existed and was updated. | |
| 220 | 252 | /// | |
| @@ -361,9 +393,6 @@ impl Store { | |||
| 361 | 393 | } | |
| 362 | 394 | ||
| 363 | 395 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | |
| 364 | - | // A method (not an associated fn) for symmetry with the other row mappers, | |
| 365 | - | // even though the visibility mapping no longer needs `self`. | |
| 366 | - | #[allow(clippy::unused_self)] | |
| 367 | 396 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | |
| 368 | 397 | Ok(Repo { | |
| 369 | 398 | id: row.try_get("id")?, | |
| @@ -375,6 +404,9 @@ impl Store { | |||
| 375 | 404 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) | |
| 376 | 405 | .unwrap_or(Visibility::Private), | |
| 377 | 406 | default_branch: row.try_get("default_branch")?, | |
| 407 | + | issues_enabled: self.get_bool(row, "issues_enabled")?, | |
| 408 | + | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | |
| 409 | + | next_iid: row.try_get("next_iid")?, | |
| 378 | 410 | archived_at: row.try_get("archived_at")?, | |
| 379 | 411 | size_bytes: row.try_get("size_bytes")?, | |
| 380 | 412 | pushed_at: row.try_get("pushed_at")?, | |
crates/store/src/tests.rs +56 −0
| @@ -146,6 +146,62 @@ async fn update_profile_and_avatar_round_trip() { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | #[tokio::test] | |
| 149 | + | async fn labels_create_list_delete() { | |
| 150 | + | for fx in sqlite_fixtures().await { | |
| 151 | + | let owner = fx | |
| 152 | + | .store | |
| 153 | + | .create_user(sample_user("lo", "lo@example.com")) | |
| 154 | + | .await | |
| 155 | + | .unwrap(); | |
| 156 | + | let repo = fx | |
| 157 | + | .store | |
| 158 | + | .create_repo(NewRepo { | |
| 159 | + | owner_id: owner.id, | |
| 160 | + | group_id: None, | |
| 161 | + | name: "r".to_string(), | |
| 162 | + | path: "r".to_string(), | |
| 163 | + | description: None, | |
| 164 | + | visibility: model::Visibility::Private, | |
| 165 | + | default_branch: "main".to_string(), | |
| 166 | + | }) | |
| 167 | + | .await | |
| 168 | + | .unwrap(); | |
| 169 | + | // New repos default to issues/PRs enabled. | |
| 170 | + | assert!(repo.issues_enabled && repo.pulls_enabled); | |
| 171 | + | ||
| 172 | + | let label = fx | |
| 173 | + | .store | |
| 174 | + | .create_label(&repo.id, "type: backend", "#ff0000", Some("server code")) | |
| 175 | + | .await | |
| 176 | + | .unwrap(); | |
| 177 | + | assert_eq!(label.scope(), Some("type")); | |
| 178 | + | ||
| 179 | + | // Duplicate name (case-insensitive) conflicts. | |
| 180 | + | let dup = fx | |
| 181 | + | .store | |
| 182 | + | .create_label(&repo.id, "Type: Backend", "#00ff00", None) | |
| 183 | + | .await; | |
| 184 | + | assert!(matches!(dup, Err(StoreError::Conflict { .. }))); | |
| 185 | + | ||
| 186 | + | let list = fx.store.list_labels(&repo.id).await.unwrap(); | |
| 187 | + | assert_eq!(list.len(), 1); | |
| 188 | + | ||
| 189 | + | assert!(fx.store.delete_label(&label.id).await.unwrap()); | |
| 190 | + | assert!(fx.store.list_labels(&repo.id).await.unwrap().is_empty()); | |
| 191 | + | ||
| 192 | + | // Feature toggles round-trip. | |
| 193 | + | assert!( | |
| 194 | + | fx.store | |
| 195 | + | .set_feature_enabled(&repo.id, "issues", false) | |
| 196 | + | .await | |
| 197 | + | .unwrap() | |
| 198 | + | ); | |
| 199 | + | let repo = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap(); | |
| 200 | + | assert!(!repo.issues_enabled); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[tokio::test] | |
| 149 | 205 | async fn collaborators_add_update_list_remove() { | |
| 150 | 206 | for fx in sqlite_fixtures().await { | |
| 151 | 207 | let owner = fx | |
crates/web/src/lib.rs +5 −0
| @@ -171,6 +171,11 @@ pub fn build_router(state: AppState) -> Router { | |||
| 171 | 171 | "/repo-settings/{id}/collaborators/remove", | |
| 172 | 172 | post(repo::settings_collaborator_remove), | |
| 173 | 173 | ) | |
| 174 | + | .route("/repo-settings/{id}/labels", post(repo::settings_label_add)) | |
| 175 | + | .route( | |
| 176 | + | "/repo-settings/{id}/labels/delete", | |
| 177 | + | post(repo::settings_label_delete), | |
| 178 | + | ) | |
| 174 | 179 | .route("/healthz", get(serve_assets::healthz)) | |
| 175 | 180 | .route("/version", get(serve_assets::version)) | |
| 176 | 181 | .route("/theme.css", get(serve_assets::theme_default)) | |
crates/web/src/repo.rs +186 −0
| @@ -727,6 +727,7 @@ async fn repo_settings_view( | |||
| 727 | 727 | } | |
| 728 | 728 | let branches = branch_names(state, &ctx.repo.id).await; | |
| 729 | 729 | let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; | |
| 730 | + | let labels = state.store.list_labels(&ctx.repo.id).await?; | |
| 730 | 731 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 731 | 732 | ||
| 732 | 733 | let id = &ctx.repo.id; | |
| @@ -751,6 +752,15 @@ async fn repo_settings_view( | |||
| 751 | 752 | (vis_option(Visibility::Internal, "Internal — any signed-in user")) | |
| 752 | 753 | (vis_option(Visibility::Private, "Private — only you and collaborators")) | |
| 753 | 754 | } | |
| 755 | + | label { "Features" } | |
| 756 | + | label class="checkbox" { | |
| 757 | + | input type="checkbox" name="issues_enabled" value="1" | |
| 758 | + | checked[ctx.repo.issues_enabled]; " Issues" | |
| 759 | + | } | |
| 760 | + | label class="checkbox" { | |
| 761 | + | input type="checkbox" name="pulls_enabled" value="1" | |
| 762 | + | checked[ctx.repo.pulls_enabled]; " Pull requests" | |
| 763 | + | } | |
| 754 | 764 | } | |
| 755 | 765 | div class="settings-actions" { | |
| 756 | 766 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } | |
| @@ -766,6 +776,7 @@ async fn repo_settings_view( | |||
| 766 | 776 | } | |
| 767 | 777 | } | |
| 768 | 778 | (collaborators_section(id, &chrome.csrf, &collaborators)) | |
| 779 | + | (labels_section(id, &chrome.csrf, &labels)) | |
| 769 | 780 | section class="listing" { | |
| 770 | 781 | h2 { "Danger zone" } | |
| 771 | 782 | div class="card danger-zone" { | |
| @@ -827,6 +838,167 @@ fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collabora | |||
| 827 | 838 | } | |
| 828 | 839 | } | |
| 829 | 840 | ||
| 841 | + | /// A coloured label chip. | |
| 842 | + | fn label_chip(label: &model::Label) -> Markup { | |
| 843 | + | html! { | |
| 844 | + | span class="label-chip" style=(format!("--label-color: {}", css_color(&label.color))) { | |
| 845 | + | (label.name) | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | /// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits. | |
| 851 | + | fn css_color(color: &str) -> String { | |
| 852 | + | let hex: String = color | |
| 853 | + | .trim_start_matches('#') | |
| 854 | + | .chars() | |
| 855 | + | .filter(char::is_ascii_hexdigit) | |
| 856 | + | .take(8) | |
| 857 | + | .collect(); | |
| 858 | + | if hex.is_empty() { | |
| 859 | + | "#888888".to_string() | |
| 860 | + | } else { | |
| 861 | + | format!("#{hex}") | |
| 862 | + | } | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | /// The Labels section of the repo settings page. | |
| 866 | + | fn labels_section(id: &str, csrf: &str, labels: &[model::Label]) -> Markup { | |
| 867 | + | html! { | |
| 868 | + | section class="listing" { | |
| 869 | + | h2 { "Labels" } | |
| 870 | + | div class="card" { | |
| 871 | + | @if labels.is_empty() { | |
| 872 | + | p class="muted" { "No labels yet. Use a scope like " code { "type: backend" } " for grouped labels." } | |
| 873 | + | } @else { | |
| 874 | + | ul class="entry-list label-list" { | |
| 875 | + | @for l in labels { | |
| 876 | + | li class="entry-row" { | |
| 877 | + | div class="entry-row-body" { | |
| 878 | + | (label_chip(l)) | |
| 879 | + | @if let Some(desc) = &l.description { | |
| 880 | + | div class="entry-row-meta muted" { (desc) } | |
| 881 | + | } | |
| 882 | + | } | |
| 883 | + | div class="entry-row-actions" { | |
| 884 | + | form method="post" action=(format!("/repo-settings/{id}/labels/delete")) { | |
| 885 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 886 | + | input type="hidden" name="label_id" value=(l.id); | |
| 887 | + | button class="btn btn-danger" type="submit" { "Delete" } | |
| 888 | + | } | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | } | |
| 892 | + | } | |
| 893 | + | } | |
| 894 | + | form class="label-add" method="post" action=(format!("/repo-settings/{id}/labels")) { | |
| 895 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 896 | + | input type="text" name="name" placeholder="name or scope: value" aria-label="Label name" required; | |
| 897 | + | input type="color" name="color" value="#2f81f7" aria-label="Colour"; | |
| 898 | + | input type="text" name="description" placeholder="Description (optional)" aria-label="Description"; | |
| 899 | + | button class="btn btn-primary inline-btn" type="submit" { "Add label" } | |
| 900 | + | } | |
| 901 | + | } | |
| 902 | + | } | |
| 903 | + | } | |
| 904 | + | } | |
| 905 | + | ||
| 906 | + | /// The add-label form. | |
| 907 | + | #[derive(Debug, Deserialize)] | |
| 908 | + | pub struct LabelAddForm { | |
| 909 | + | /// Label name (may be `scope: value`). | |
| 910 | + | #[serde(default)] | |
| 911 | + | name: String, | |
| 912 | + | /// Hex colour. | |
| 913 | + | #[serde(default)] | |
| 914 | + | color: String, | |
| 915 | + | /// Optional description. | |
| 916 | + | #[serde(default)] | |
| 917 | + | description: String, | |
| 918 | + | /// CSRF token. | |
| 919 | + | #[serde(rename = "_csrf")] | |
| 920 | + | csrf: String, | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | /// `POST /repo-settings/{id}/labels` — create a label. | |
| 924 | + | pub async fn settings_label_add( | |
| 925 | + | State(state): State<AppState>, | |
| 926 | + | MaybeUser(viewer): MaybeUser, | |
| 927 | + | jar: CookieJar, | |
| 928 | + | headers: HeaderMap, | |
| 929 | + | Path(id): Path<String>, | |
| 930 | + | Form(form): Form<LabelAddForm>, | |
| 931 | + | ) -> Response { | |
| 932 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 933 | + | return AppError::Forbidden.into_response(); | |
| 934 | + | } | |
| 935 | + | let result = async { | |
| 936 | + | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; | |
| 937 | + | let name = form.name.trim(); | |
| 938 | + | if name.is_empty() { | |
| 939 | + | return Err(AppError::BadRequest("Label name is required.".to_string())); | |
| 940 | + | } | |
| 941 | + | let color = css_color(&form.color); | |
| 942 | + | let desc = form.description.trim(); | |
| 943 | + | state | |
| 944 | + | .store | |
| 945 | + | .create_label(&repo.id, name, &color, (!desc.is_empty()).then_some(desc)) | |
| 946 | + | .await | |
| 947 | + | .map_err(|e| match e { | |
| 948 | + | store::StoreError::Conflict { .. } => { | |
| 949 | + | AppError::BadRequest("A label with that name already exists.".to_string()) | |
| 950 | + | } | |
| 951 | + | other => AppError::from(other), | |
| 952 | + | })?; | |
| 953 | + | Ok::<String, AppError>(repo_settings_url(&state, &repo).await) | |
| 954 | + | } | |
| 955 | + | .await; | |
| 956 | + | match result { | |
| 957 | + | Ok(dest) => Redirect::to(&dest).into_response(), | |
| 958 | + | Err(err) => err.into_response(), | |
| 959 | + | } | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | /// The delete-label form. | |
| 963 | + | #[derive(Debug, Deserialize)] | |
| 964 | + | pub struct LabelDeleteForm { | |
| 965 | + | /// The label id. | |
| 966 | + | #[serde(default)] | |
| 967 | + | label_id: String, | |
| 968 | + | /// CSRF token. | |
| 969 | + | #[serde(rename = "_csrf")] | |
| 970 | + | csrf: String, | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | /// `POST /repo-settings/{id}/labels/delete` — delete a label. | |
| 974 | + | pub async fn settings_label_delete( | |
| 975 | + | State(state): State<AppState>, | |
| 976 | + | MaybeUser(viewer): MaybeUser, | |
| 977 | + | jar: CookieJar, | |
| 978 | + | headers: HeaderMap, | |
| 979 | + | Path(id): Path<String>, | |
| 980 | + | Form(form): Form<LabelDeleteForm>, | |
| 981 | + | ) -> Response { | |
| 982 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 983 | + | return AppError::Forbidden.into_response(); | |
| 984 | + | } | |
| 985 | + | let result = async { | |
| 986 | + | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; | |
| 987 | + | // Only delete a label that belongs to this repo. | |
| 988 | + | if let Some(label) = state.store.label_by_id(&form.label_id).await? | |
| 989 | + | && label.repo_id == repo.id | |
| 990 | + | { | |
| 991 | + | state.store.delete_label(&label.id).await?; | |
| 992 | + | } | |
| 993 | + | Ok::<String, AppError>(repo_settings_url(&state, &repo).await) | |
| 994 | + | } | |
| 995 | + | .await; | |
| 996 | + | match result { | |
| 997 | + | Ok(dest) => Redirect::to(&dest).into_response(), | |
| 998 | + | Err(err) => err.into_response(), | |
| 999 | + | } | |
| 1000 | + | } | |
| 1001 | + | ||
| 830 | 1002 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a | |
| 831 | 1003 | /// `404` (so a non-admin cannot even confirm the repo exists). | |
| 832 | 1004 | async fn require_admin_repo( | |
| @@ -873,6 +1045,12 @@ pub struct RepoGeneralForm { | |||
| 873 | 1045 | /// The chosen visibility token. | |
| 874 | 1046 | #[serde(default)] | |
| 875 | 1047 | visibility: String, | |
| 1048 | + | /// Enable issues (checkbox present when on). | |
| 1049 | + | #[serde(default)] | |
| 1050 | + | issues_enabled: Option<String>, | |
| 1051 | + | /// Enable pull requests. | |
| 1052 | + | #[serde(default)] | |
| 1053 | + | pulls_enabled: Option<String>, | |
| 876 | 1054 | /// CSRF token. | |
| 877 | 1055 | #[serde(rename = "_csrf")] | |
| 878 | 1056 | csrf: String, | |
| @@ -905,6 +1083,14 @@ pub async fn settings_general( | |||
| 905 | 1083 | if let Some(vis) = Visibility::from_token(&form.visibility) { | |
| 906 | 1084 | state.store.set_visibility(&repo.id, vis).await?; | |
| 907 | 1085 | } | |
| 1086 | + | state | |
| 1087 | + | .store | |
| 1088 | + | .set_feature_enabled(&repo.id, "issues", form.issues_enabled.is_some()) | |
| 1089 | + | .await?; | |
| 1090 | + | state | |
| 1091 | + | .store | |
| 1092 | + | .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some()) | |
| 1093 | + | .await?; | |
| 908 | 1094 | let owner = state | |
| 909 | 1095 | .store | |
| 910 | 1096 | .user_by_id(&repo.owner_id) | |
migrations/postgres/0005_collaboration.sql +59 −0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | -- Collaboration: per-repo toggles + shared issue/PR number counter, labels, | |
| 2 | + | -- issues (with is_pull discriminator), comments, issue↔label join, and the PR | |
| 3 | + | -- side table. Issues and PRs share the `issues` table and `repos.next_iid`. | |
| 4 | + | ALTER TABLE repos ADD COLUMN issues_enabled BOOLEAN NOT NULL DEFAULT TRUE; | |
| 5 | + | ALTER TABLE repos ADD COLUMN pulls_enabled BOOLEAN NOT NULL DEFAULT TRUE; | |
| 6 | + | ALTER TABLE repos ADD COLUMN next_iid BIGINT NOT NULL DEFAULT 1; | |
| 7 | + | ||
| 8 | + | CREATE TABLE labels ( | |
| 9 | + | id TEXT PRIMARY KEY, | |
| 10 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 11 | + | name TEXT NOT NULL, | |
| 12 | + | name_lower TEXT NOT NULL, | |
| 13 | + | color TEXT NOT NULL, | |
| 14 | + | description TEXT, | |
| 15 | + | created_at BIGINT NOT NULL | |
| 16 | + | ); | |
| 17 | + | CREATE UNIQUE INDEX idx_labels_repo_name ON labels (repo_id, name_lower); | |
| 18 | + | ||
| 19 | + | CREATE TABLE issues ( | |
| 20 | + | id TEXT PRIMARY KEY, | |
| 21 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 22 | + | number BIGINT NOT NULL, | |
| 23 | + | author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 24 | + | title TEXT NOT NULL, | |
| 25 | + | body TEXT NOT NULL, | |
| 26 | + | state TEXT NOT NULL DEFAULT 'open', | |
| 27 | + | is_pull BOOLEAN NOT NULL DEFAULT FALSE, | |
| 28 | + | assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 29 | + | created_at BIGINT NOT NULL, | |
| 30 | + | updated_at BIGINT NOT NULL, | |
| 31 | + | closed_at BIGINT | |
| 32 | + | ); | |
| 33 | + | CREATE UNIQUE INDEX idx_issues_repo_number ON issues (repo_id, number); | |
| 34 | + | CREATE INDEX idx_issues_repo_state ON issues (repo_id, is_pull, state); | |
| 35 | + | ||
| 36 | + | CREATE TABLE comments ( | |
| 37 | + | id TEXT PRIMARY KEY, | |
| 38 | + | issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 39 | + | author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 40 | + | body TEXT NOT NULL, | |
| 41 | + | created_at BIGINT NOT NULL, | |
| 42 | + | updated_at BIGINT NOT NULL | |
| 43 | + | ); | |
| 44 | + | ||
| 45 | + | CREATE TABLE issue_labels ( | |
| 46 | + | issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 47 | + | label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 48 | + | PRIMARY KEY (issue_id, label_id) | |
| 49 | + | ); | |
| 50 | + | ||
| 51 | + | CREATE TABLE pull_requests ( | |
| 52 | + | issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE, | |
| 53 | + | base_ref TEXT NOT NULL, | |
| 54 | + | head_ref TEXT NOT NULL, | |
| 55 | + | merged_at BIGINT, | |
| 56 | + | merged_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 57 | + | merge_sha TEXT, | |
| 58 | + | merge_strategy TEXT | |
| 59 | + | ); | |
migrations/sqlite/0005_collaboration.sql +60 −0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | -- Collaboration: per-repo toggles + shared issue/PR number counter, labels, | |
| 2 | + | -- issues (with is_pull discriminator), comments, issue↔label join, and the PR | |
| 3 | + | -- side table. Issues and PRs share the `issues` table and `repos.next_iid`, so | |
| 4 | + | -- `#N` is unique across both. | |
| 5 | + | ALTER TABLE repos ADD COLUMN issues_enabled INTEGER NOT NULL DEFAULT 1; | |
| 6 | + | ALTER TABLE repos ADD COLUMN pulls_enabled INTEGER NOT NULL DEFAULT 1; | |
| 7 | + | ALTER TABLE repos ADD COLUMN next_iid BIGINT NOT NULL DEFAULT 1; | |
| 8 | + | ||
| 9 | + | CREATE TABLE labels ( | |
| 10 | + | id TEXT PRIMARY KEY, | |
| 11 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 12 | + | name TEXT NOT NULL, | |
| 13 | + | name_lower TEXT NOT NULL, | |
| 14 | + | color TEXT NOT NULL, -- '#rrggbb' | |
| 15 | + | description TEXT, | |
| 16 | + | created_at BIGINT NOT NULL | |
| 17 | + | ); | |
| 18 | + | CREATE UNIQUE INDEX idx_labels_repo_name ON labels (repo_id, name_lower); | |
| 19 | + | ||
| 20 | + | CREATE TABLE issues ( | |
| 21 | + | id TEXT PRIMARY KEY, | |
| 22 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 23 | + | number BIGINT NOT NULL, | |
| 24 | + | author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 25 | + | title TEXT NOT NULL, | |
| 26 | + | body TEXT NOT NULL, | |
| 27 | + | state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed' | |
| 28 | + | is_pull INTEGER NOT NULL DEFAULT 0, | |
| 29 | + | assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 30 | + | created_at BIGINT NOT NULL, | |
| 31 | + | updated_at BIGINT NOT NULL, | |
| 32 | + | closed_at BIGINT | |
| 33 | + | ); | |
| 34 | + | CREATE UNIQUE INDEX idx_issues_repo_number ON issues (repo_id, number); | |
| 35 | + | CREATE INDEX idx_issues_repo_state ON issues (repo_id, is_pull, state); | |
| 36 | + | ||
| 37 | + | CREATE TABLE comments ( | |
| 38 | + | id TEXT PRIMARY KEY, | |
| 39 | + | issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 40 | + | author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 41 | + | body TEXT NOT NULL, | |
| 42 | + | created_at BIGINT NOT NULL, | |
| 43 | + | updated_at BIGINT NOT NULL | |
| 44 | + | ); | |
| 45 | + | ||
| 46 | + | CREATE TABLE issue_labels ( | |
| 47 | + | issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 48 | + | label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 49 | + | PRIMARY KEY (issue_id, label_id) | |
| 50 | + | ); | |
| 51 | + | ||
| 52 | + | CREATE TABLE pull_requests ( | |
| 53 | + | issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE, | |
| 54 | + | base_ref TEXT NOT NULL, | |
| 55 | + | head_ref TEXT NOT NULL, | |
| 56 | + | merged_at BIGINT, | |
| 57 | + | merged_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 58 | + | merge_sha TEXT, | |
| 59 | + | merge_strategy TEXT | |
| 60 | + | ); | |