fabrica

hanna/fabrica

feat: scoped labels and per-repo issue/PR toggles

dd10faf · hanna committed on 2026-07-25

Add the collaboration schema (migration 0005: per-repo issues_enabled/
pulls_enabled/next_iid, labels, issues, comments, issue_labels,
pull_requests), the Label/Issue/Comment/PullRequest/IssueState model types,
and store label CRUD + feature toggles. Repo Settings gains a Labels section
(coloured chips, scoped kind: value names, add/delete) and Issues/Pull
requests enable checkboxes in General.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
12 files changed · +715 −5UnifiedSplit
assets/base.css +37 −0
@@ -285,6 +285,43 @@ body.drawer-open .drawer-backdrop {
285 display: inline;285 display: inline;
286}286}
287287
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}
228228
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)]
232pub 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
247impl 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)]
257pub enum IssueState {
258 /// Open.
259 Open,
260 /// Closed.
261 Closed,
262}
263
264impl 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)]
288pub 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)]
317pub 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)]
334pub 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`; the351/// 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 @@
11pub mod entity;11pub mod entity;
12pub mod name;12pub mod name;
1313
14pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User, Visibility};14pub use entity::{
15 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,
16 Timestamp, User, Visibility,
17};
15pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};18pub 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
7use model::Label;
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
12
13/// The columns of `labels` in a fixed order.
14const LABEL_COLUMNS: &str = "id, repo_id, name, color, description, created_at";
15
16impl 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`].
131fn 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 @@
28mod groups;28mod groups;
29mod invites;29mod invites;
30mod keys;30mod keys;
31mod labels;
31mod repos;32mod repos;
32mod sessions;33mod sessions;
33mod tokens;34mod tokens;
crates/store/src/repos.rs +36 −4
@@ -12,7 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
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`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \14const 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";
1617
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 }
217221
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 }
362394
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}
147147
148#[tokio::test]148#[tokio::test]
149async 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]
149async fn collaborators_add_update_list_remove() {205async fn collaborators_add_update_list_remove() {
150 for fx in sqlite_fixtures().await {206 for fx in sqlite_fixtures().await {
151 let owner = fx207 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());
731732
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}
829840
841/// A coloured label chip.
842fn 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.
851fn 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.
866fn 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)]
908pub 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.
924pub 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)]
964pub 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.
974pub 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 a1002/// 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).
832async fn require_admin_repo(1004async 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 = state1094 let owner = state
909 .store1095 .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`.
4ALTER TABLE repos ADD COLUMN issues_enabled BOOLEAN NOT NULL DEFAULT TRUE;
5ALTER TABLE repos ADD COLUMN pulls_enabled BOOLEAN NOT NULL DEFAULT TRUE;
6ALTER TABLE repos ADD COLUMN next_iid BIGINT NOT NULL DEFAULT 1;
7
8CREATE 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);
17CREATE UNIQUE INDEX idx_labels_repo_name ON labels (repo_id, name_lower);
18
19CREATE 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);
33CREATE UNIQUE INDEX idx_issues_repo_number ON issues (repo_id, number);
34CREATE INDEX idx_issues_repo_state ON issues (repo_id, is_pull, state);
35
36CREATE 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
45CREATE 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
51CREATE 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.
5ALTER TABLE repos ADD COLUMN issues_enabled INTEGER NOT NULL DEFAULT 1;
6ALTER TABLE repos ADD COLUMN pulls_enabled INTEGER NOT NULL DEFAULT 1;
7ALTER TABLE repos ADD COLUMN next_iid BIGINT NOT NULL DEFAULT 1;
8
9CREATE 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);
18CREATE UNIQUE INDEX idx_labels_repo_name ON labels (repo_id, name_lower);
19
20CREATE 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);
34CREATE UNIQUE INDEX idx_issues_repo_number ON issues (repo_id, number);
35CREATE INDEX idx_issues_repo_state ON issues (repo_id, is_pull, state);
36
37CREATE 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
46CREATE 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
52CREATE 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);