Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +37 −0
| @@ -285,6 +285,43 @@ body.drawer-open .drawer-backdrop { | |||
| 285 | display: inline; | 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 | /* Repo settings danger zone. */ | 325 | /* Repo settings danger zone. */ |
| 289 | .danger-zone { | 326 | .danger-zone { |
| 290 | border-color: var(--fb-danger); | 327 | border-color: var(--fb-danger); |
crates/auth/src/access.rs +3 −0
| @@ -147,6 +147,9 @@ mod tests { | |||
| 147 | description: None, | 147 | description: None, |
| 148 | visibility, | 148 | visibility, |
| 149 | default_branch: "main".to_string(), | 149 | default_branch: "main".to_string(), |
| 150 | issues_enabled: true, | ||
| 151 | pulls_enabled: true, | ||
| 152 | next_iid: 1, | ||
| 150 | archived_at: None, | 153 | archived_at: None, |
| 151 | size_bytes: 0, | 154 | size_bytes: 0, |
| 152 | pushed_at: None, | 155 | pushed_at: None, |
crates/model/src/entity.rs +128 −0
| @@ -226,6 +226,128 @@ pub struct ApiToken { | |||
| 226 | pub created_at: Timestamp, | 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 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the | 351 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the |
| 230 | /// database is the sole authority mapping `(owner, path)` to this id. | 352 | /// database is the sole authority mapping `(owner, path)` to this id. |
| 231 | #[derive(Debug, Clone, PartialEq, Eq)] | 353 | #[derive(Debug, Clone, PartialEq, Eq)] |
| @@ -247,6 +369,12 @@ pub struct Repo { | |||
| 247 | pub visibility: Visibility, | 369 | pub visibility: Visibility, |
| 248 | /// The branch shown by default and used for HEAD. | 370 | /// The branch shown by default and used for HEAD. |
| 249 | pub default_branch: String, | 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 | /// When the repo was archived (read-only), if it is. | 378 | /// When the repo was archived (read-only), if it is. |
| 251 | pub archived_at: Option<Timestamp>, | 379 | pub archived_at: Option<Timestamp>, |
| 252 | /// Approximate on-disk size in bytes, refreshed by maintenance. | 380 | /// Approximate on-disk size in bytes, refreshed by maintenance. |
crates/model/src/lib.rs +4 −1
| @@ -11,5 +11,8 @@ | |||
| 11 | pub mod entity; | 11 | pub mod entity; |
| 12 | pub mod name; | 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 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | 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 | mod groups; | 28 | mod groups; |
| 29 | mod invites; | 29 | mod invites; |
| 30 | mod keys; | 30 | mod keys; |
| 31 | mod labels; | ||
| 31 | mod repos; | 32 | mod repos; |
| 32 | mod sessions; | 33 | mod sessions; |
| 33 | mod tokens; | 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 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. |
| 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | 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 | /// Fields needed to create a repository. The server fills in the id, timestamps, | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, |
| 18 | /// and the derived size/push bookkeeping. | 19 | /// and the derived size/push bookkeeping. |
| @@ -65,6 +66,9 @@ impl Store { | |||
| 65 | description: new.description, | 66 | description: new.description, |
| 66 | visibility: new.visibility, | 67 | visibility: new.visibility, |
| 67 | default_branch: new.default_branch, | 68 | default_branch: new.default_branch, |
| 69 | issues_enabled: true, | ||
| 70 | pulls_enabled: true, | ||
| 71 | next_iid: 1, | ||
| 68 | archived_at: None, | 72 | archived_at: None, |
| 69 | size_bytes: 0, | 73 | size_bytes: 0, |
| 70 | pushed_at: None, | 74 | pushed_at: None, |
| @@ -215,6 +219,34 @@ impl Store { | |||
| 215 | Ok(updated > 0) | 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 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; | 250 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; |
| 219 | /// unarchiving clears it. Returns `true` if the row existed and was updated. | 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 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | 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 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | 396 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { |
| 368 | Ok(Repo { | 397 | Ok(Repo { |
| 369 | id: row.try_get("id")?, | 398 | id: row.try_get("id")?, |
| @@ -375,6 +404,9 @@ impl Store { | |||
| 375 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) | 404 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) |
| 376 | .unwrap_or(Visibility::Private), | 405 | .unwrap_or(Visibility::Private), |
| 377 | default_branch: row.try_get("default_branch")?, | 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 | archived_at: row.try_get("archived_at")?, | 410 | archived_at: row.try_get("archived_at")?, |
| 379 | size_bytes: row.try_get("size_bytes")?, | 411 | size_bytes: row.try_get("size_bytes")?, |
| 380 | pushed_at: row.try_get("pushed_at")?, | 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 | #[tokio::test] | 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 | async fn collaborators_add_update_list_remove() { | 205 | async fn collaborators_add_update_list_remove() { |
| 150 | for fx in sqlite_fixtures().await { | 206 | for fx in sqlite_fixtures().await { |
| 151 | let owner = fx | 207 | let owner = fx |
crates/web/src/lib.rs +5 −0
| @@ -171,6 +171,11 @@ pub fn build_router(state: AppState) -> Router { | |||
| 171 | "/repo-settings/{id}/collaborators/remove", | 171 | "/repo-settings/{id}/collaborators/remove", |
| 172 | post(repo::settings_collaborator_remove), | 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 | .route("/healthz", get(serve_assets::healthz)) | 179 | .route("/healthz", get(serve_assets::healthz)) |
| 175 | .route("/version", get(serve_assets::version)) | 180 | .route("/version", get(serve_assets::version)) |
| 176 | .route("/theme.css", get(serve_assets::theme_default)) | 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 | let branches = branch_names(state, &ctx.repo.id).await; | 728 | let branches = branch_names(state, &ctx.repo.id).await; |
| 729 | let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; | 729 | let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; |
| 730 | let labels = state.store.list_labels(&ctx.repo.id).await?; | ||
| 730 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | 731 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); |
| 731 | 732 | ||
| 732 | let id = &ctx.repo.id; | 733 | let id = &ctx.repo.id; |
| @@ -751,6 +752,15 @@ async fn repo_settings_view( | |||
| 751 | (vis_option(Visibility::Internal, "Internal — any signed-in user")) | 752 | (vis_option(Visibility::Internal, "Internal — any signed-in user")) |
| 752 | (vis_option(Visibility::Private, "Private — only you and collaborators")) | 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 | div class="settings-actions" { | 765 | div class="settings-actions" { |
| 756 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } | 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 | (collaborators_section(id, &chrome.csrf, &collaborators)) | 778 | (collaborators_section(id, &chrome.csrf, &collaborators)) |
| 779 | (labels_section(id, &chrome.csrf, &labels)) | ||
| 769 | section class="listing" { | 780 | section class="listing" { |
| 770 | h2 { "Danger zone" } | 781 | h2 { "Danger zone" } |
| 771 | div class="card danger-zone" { | 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 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a | 1002 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a |
| 831 | /// `404` (so a non-admin cannot even confirm the repo exists). | 1003 | /// `404` (so a non-admin cannot even confirm the repo exists). |
| 832 | async fn require_admin_repo( | 1004 | async fn require_admin_repo( |
| @@ -873,6 +1045,12 @@ pub struct RepoGeneralForm { | |||
| 873 | /// The chosen visibility token. | 1045 | /// The chosen visibility token. |
| 874 | #[serde(default)] | 1046 | #[serde(default)] |
| 875 | visibility: String, | 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 | /// CSRF token. | 1054 | /// CSRF token. |
| 877 | #[serde(rename = "_csrf")] | 1055 | #[serde(rename = "_csrf")] |
| 878 | csrf: String, | 1056 | csrf: String, |
| @@ -905,6 +1083,14 @@ pub async fn settings_general( | |||
| 905 | if let Some(vis) = Visibility::from_token(&form.visibility) { | 1083 | if let Some(vis) = Visibility::from_token(&form.visibility) { |
| 906 | state.store.set_visibility(&repo.id, vis).await?; | 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 | let owner = state | 1094 | let owner = state |
| 909 | .store | 1095 | .store |
| 910 | .user_by_id(&repo.owner_id) | 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 | ); | ||