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 {
285285 display: inline;
286286 }
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+
288325 /* Repo settings danger zone. */
289326 .danger-zone {
290327 border-color: var(--fb-danger);
crates/auth/src/access.rs +3 −0
@@ -147,6 +147,9 @@ mod tests {
147147 description: None,
148148 visibility,
149149 default_branch: "main".to_string(),
150+ issues_enabled: true,
151+ pulls_enabled: true,
152+ next_iid: 1,
150153 archived_at: None,
151154 size_bytes: 0,
152155 pushed_at: None,
crates/model/src/entity.rs +128 −0
@@ -226,6 +226,128 @@ pub struct ApiToken {
226226 pub created_at: Timestamp,
227227 }
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)]
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+
229351 /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the
230352 /// database is the sole authority mapping `(owner, path)` to this id.
231353 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -247,6 +369,12 @@ pub struct Repo {
247369 pub visibility: Visibility,
248370 /// The branch shown by default and used for HEAD.
249371 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,
250378 /// When the repo was archived (read-only), if it is.
251379 pub archived_at: Option<Timestamp>,
252380 /// Approximate on-disk size in bytes, refreshed by maintenance.
crates/model/src/lib.rs +4 −1
@@ -11,5 +11,8 @@
1111 pub mod entity;
1212 pub mod name;
1313
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+};
1518 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 @@
2828 mod groups;
2929 mod invites;
3030 mod keys;
31+mod labels;
3132 mod repos;
3233 mod sessions;
3334 mod tokens;
crates/store/src/repos.rs +36 −4
@@ -12,7 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
1414 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";
1617
1718 /// Fields needed to create a repository. The server fills in the id, timestamps,
1819 /// and the derived size/push bookkeeping.
@@ -65,6 +66,9 @@ impl Store {
6566 description: new.description,
6667 visibility: new.visibility,
6768 default_branch: new.default_branch,
69+ issues_enabled: true,
70+ pulls_enabled: true,
71+ next_iid: 1,
6872 archived_at: None,
6973 size_bytes: 0,
7074 pushed_at: None,
@@ -215,6 +219,34 @@ impl Store {
215219 Ok(updated > 0)
216220 }
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+
218250 /// Archive or unarchive a repository. Archiving stamps `archived_at`;
219251 /// unarchiving clears it. Returns `true` if the row existed and was updated.
220252 ///
@@ -361,9 +393,6 @@ impl Store {
361393 }
362394
363395 /// 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)]
367396 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
368397 Ok(Repo {
369398 id: row.try_get("id")?,
@@ -375,6 +404,9 @@ impl Store {
375404 visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?)
376405 .unwrap_or(Visibility::Private),
377406 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")?,
378410 archived_at: row.try_get("archived_at")?,
379411 size_bytes: row.try_get("size_bytes")?,
380412 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() {
146146 }
147147
148148 #[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]
149205 async fn collaborators_add_update_list_remove() {
150206 for fx in sqlite_fixtures().await {
151207 let owner = fx
crates/web/src/lib.rs +5 −0
@@ -171,6 +171,11 @@ pub fn build_router(state: AppState) -> Router {
171171 "/repo-settings/{id}/collaborators/remove",
172172 post(repo::settings_collaborator_remove),
173173 )
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+ )
174179 .route("/healthz", get(serve_assets::healthz))
175180 .route("/version", get(serve_assets::version))
176181 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/repo.rs +186 −0
@@ -727,6 +727,7 @@ async fn repo_settings_view(
727727 }
728728 let branches = branch_names(state, &ctx.repo.id).await;
729729 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
730+ let labels = state.store.list_labels(&ctx.repo.id).await?;
730731 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
731732
732733 let id = &ctx.repo.id;
@@ -751,6 +752,15 @@ async fn repo_settings_view(
751752 (vis_option(Visibility::Internal, "Internal — any signed-in user"))
752753 (vis_option(Visibility::Private, "Private — only you and collaborators"))
753754 }
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+ }
754764 }
755765 div class="settings-actions" {
756766 button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" }
@@ -766,6 +776,7 @@ async fn repo_settings_view(
766776 }
767777 }
768778 (collaborators_section(id, &chrome.csrf, &collaborators))
779+ (labels_section(id, &chrome.csrf, &labels))
769780 section class="listing" {
770781 h2 { "Danger zone" }
771782 div class="card danger-zone" {
@@ -827,6 +838,167 @@ fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collabora
827838 }
828839 }
829840
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+
8301002 /// Resolve a repo by id and require the viewer to have `Admin` access, else a
8311003 /// `404` (so a non-admin cannot even confirm the repo exists).
8321004 async fn require_admin_repo(
@@ -873,6 +1045,12 @@ pub struct RepoGeneralForm {
8731045 /// The chosen visibility token.
8741046 #[serde(default)]
8751047 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>,
8761054 /// CSRF token.
8771055 #[serde(rename = "_csrf")]
8781056 csrf: String,
@@ -905,6 +1083,14 @@ pub async fn settings_general(
9051083 if let Some(vis) = Visibility::from_token(&form.visibility) {
9061084 state.store.set_visibility(&repo.id, vis).await?;
9071085 }
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?;
9081094 let owner = state
9091095 .store
9101096 .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+);