// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Repositories: creation and path lookup. use model::{Repo, Visibility}; use sqlx::any::AnyRow; use sqlx::{AssertSqlSafe, Row}; use crate::{Store, StoreError, map_conflict, new_id, now_ms}; /// The columns of `repos` in a fixed order, shared by every `SELECT`. pub(crate) const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \ fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; /// Fields needed to create a repository. The server fills in the id, timestamps, /// and the derived size/push bookkeeping. #[derive(Debug, Clone)] pub struct NewRepo { /// Owning user id. pub owner_id: String, /// Containing group id, or `None` when the repo hangs directly off the owner. pub group_id: Option, /// The repo's own name; validated with [`model::validate_name`]. pub name: String, /// Materialized full path within the owner (`group/sub/repo`, or just `name`). pub path: String, /// Optional one-line description. pub description: Option, /// The repo's visibility (public / internal / private). pub visibility: Visibility, /// The repo's default branch. pub default_branch: String, } /// A repo collaborator with their username resolved, for listings. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Collaborator { /// The collaborator's user id. pub user_id: String, /// Their username. pub username: String, /// Their permission (`read` | `write` | `admin`). pub permission: String, } impl Store { /// Create a repository, returning the persisted row. /// /// # Errors /// /// * [`StoreError::Name`] if the repo name is invalid. /// * [`StoreError::Conflict`] if the owner already has a repo at that path. /// * [`StoreError::Query`] for any other database failure. pub async fn create_repo(&self, new: NewRepo) -> Result { model::validate_name(&new.name)?; let now = now_ms(); let repo = Repo { id: new_id(), owner_id: new.owner_id, group_id: new.group_id, name: new.name, path: new.path, description: new.description, visibility: new.visibility, default_branch: new.default_branch, object_format: "sha1".to_string(), issues_enabled: true, pulls_enabled: true, releases_enabled: true, is_mirror: false, fork_parent_id: None, next_iid: 1, archived_at: None, size_bytes: 0, pushed_at: None, created_at: now, updated_at: now, }; let sql = "INSERT INTO repos \ (id, owner_id, group_id, name, path, description, visibility, default_branch, \ archived_at, size_bytes, pushed_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; sqlx::query(sql) .bind(repo.id.clone()) .bind(repo.owner_id.clone()) .bind(repo.group_id.clone()) .bind(repo.name.clone()) .bind(repo.path.clone()) .bind(repo.description.clone()) .bind(repo.visibility.as_str()) .bind(repo.default_branch.clone()) .bind(repo.archived_at) .bind(repo.size_bytes) .bind(repo.pushed_at) .bind(repo.created_at) .bind(repo.updated_at) .execute(&self.pool) .await .map_err(|e| map_conflict(e, "repo", &[("path", "path")]))?; Ok(repo) } /// Set a repository's object format (`sha1` | `sha256`). Called right after /// [`Store::create_repo`] when a non-default format was chosen, before the /// on-disk repository is created, so the row matches the on-disk repo. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_object_format( &self, repo_id: &str, object_format: &str, ) -> Result<(), StoreError> { sqlx::query("UPDATE repos SET object_format = $1 WHERE id = $2") .bind(object_format) .bind(repo_id) .execute(&self.pool) .await?; Ok(()) } /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is /// the authoritative name→repo lookup; the on-disk tree is keyed by id. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn repo_by_owner_path( &self, owner_id: &str, path: &str, ) -> Result, StoreError> { let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE owner_id = $1 AND path = $2"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(owner_id) .bind(path) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?) } /// List every repository owned by `owner_id`, ordered by path. Used by /// `fabrica user del` to refuse (and enumerate) when a user still owns repos. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn repos_by_owner(&self, owner_id: &str) -> Result, StoreError> { let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE owner_id = $1 ORDER BY path ASC"); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(owner_id) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_repo(r)) .collect::>() .map_err(Into::into) } /// Fetch a repository by id, or `None`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn repo_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {REPO_COLUMNS} FROM repos WHERE id = $1"); let row = sqlx::query(AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_repo(r)).transpose()?) } /// List every repository, ordered by owner then path. Used by `repo list`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_repos(&self) -> Result, StoreError> { let sql = format!("SELECT {REPO_COLUMNS} FROM repos ORDER BY owner_id ASC, path ASC"); let rows = sqlx::query(AssertSqlSafe(sql)) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_repo(r)) .collect::>() .map_err(Into::into) } /// One page of repositories, ordered by owner then path. For the admin view. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_repos_paged(&self, limit: i64, offset: i64) -> Result, StoreError> { let sql = format!( "SELECT {REPO_COLUMNS} FROM repos ORDER BY owner_id ASC, path ASC LIMIT $1 OFFSET $2" ); let rows = sqlx::query(AssertSqlSafe(sql)) .bind(limit) .bind(offset) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_repo(r)) .collect::>() .map_err(Into::into) } /// Rename a repository: update its leaf `name` and materialized `path` /// (metadata only — the on-disk directory is keyed by id and never moves). /// /// # Errors /// /// * [`StoreError::Name`] if `new_name` is invalid. /// * [`StoreError::Conflict`] if the owner already has a repo at `new_path`. /// * [`StoreError::Query`] on any other database failure. pub async fn rename_repo( &self, repo_id: &str, new_name: &str, new_path: &str, ) -> Result { model::validate_name(new_name)?; let updated = sqlx::query("UPDATE repos SET name = $1, path = $2, updated_at = $3 WHERE id = $4") .bind(new_name) .bind(new_path) .bind(now_ms()) .bind(repo_id) .execute(&self.pool) .await .map_err(|e| map_conflict(e, "repo", &[("path", "path")]))? .rows_affected(); Ok(updated > 0) } /// Set a repository's visibility (private vs public). Returns `true` if the /// row existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_visibility( &self, repo_id: &str, visibility: Visibility, ) -> Result { let updated = sqlx::query("UPDATE repos SET visibility = $1, updated_at = $2 WHERE id = $3") .bind(visibility.as_str()) .bind(now_ms()) .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Enable or disable issues / pull requests for a repository. `feature` is /// `"issues"` or `"pulls"`. Returns `true` if the row was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_feature_enabled( &self, repo_id: &str, feature: &str, enabled: bool, ) -> Result { let column = match feature { "issues" => "issues_enabled", "pulls" => "pulls_enabled", "releases" => "releases_enabled", _ => return Ok(false), }; let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3"); let updated = sqlx::query(AssertSqlSafe(sql)) .bind(enabled) .bind(now_ms()) .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Mark a repository as a mirror (or not). Mirror repos are kept in sync from /// a remote and have issues/PRs disabled. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_mirror(&self, repo_id: &str, is_mirror: bool) -> Result { let updated = sqlx::query("UPDATE repos SET is_mirror = $1, updated_at = $2 WHERE id = $3") .bind(is_mirror) .bind(now_ms()) .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Record that `repo_id` was forked from `parent_id`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_fork_parent(&self, repo_id: &str, parent_id: &str) -> Result<(), StoreError> { sqlx::query("UPDATE repos SET fork_parent_id = $1 WHERE id = $2") .bind(parent_id) .bind(repo_id) .execute(&self.pool) .await?; Ok(()) } /// The number of repositories forked from `repo_id`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn count_forks(&self, repo_id: &str) -> Result { let n: i64 = sqlx::query("SELECT COUNT(*) AS n FROM repos WHERE fork_parent_id = $1") .bind(repo_id) .fetch_one(&self.pool) .await? .try_get("n")?; Ok(n) } /// Archive or unarchive a repository. Archiving stamps `archived_at`; /// unarchiving clears it. Returns `true` if the row existed and was updated. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_archived(&self, repo_id: &str, archived: bool) -> Result { let now = now_ms(); let archived_at = archived.then_some(now); let updated = sqlx::query("UPDATE repos SET archived_at = $1, updated_at = $2 WHERE id = $3") .bind(archived_at) .bind(now) .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Record that a repository was just pushed to: stamp `pushed_at` and refresh /// `size_bytes`. Called by `fabrica hook post-receive`. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn record_push(&self, repo_id: &str, size_bytes: i64) -> Result { let now = now_ms(); let updated = sqlx::query( "UPDATE repos SET pushed_at = $1, size_bytes = $2, updated_at = $1 WHERE id = $3", ) .bind(now) .bind(size_bytes) .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); Ok(updated > 0) } /// Delete a repository row (cascading to collaborators). The caller is /// responsible for the on-disk directory — moving it to trash, or purging it. /// Returns `true` if a row was removed. Empty auto-created groups the repo /// left behind are garbage-collected up the tree. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_repo(&self, repo_id: &str) -> Result { // Remember the group before the row disappears, to garbage-collect it. let group_id = self.repo_by_id(repo_id).await?.and_then(|r| r.group_id); let deleted = sqlx::query("DELETE FROM repos WHERE id = $1") .bind(repo_id) .execute(&self.pool) .await? .rows_affected(); if deleted > 0 { self.gc_empty_groups(group_id).await?; } Ok(deleted > 0) } /// The viewer's collaborator permission on a repo (`read`|`write`|`admin`), or /// `None` if they are not an explicit collaborator. Feeds [`auth::access`]. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn collaborator_permission( &self, repo_id: &str, user_id: &str, ) -> Result, StoreError> { let row = sqlx::query( "SELECT permission FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2", ) .bind(repo_id) .bind(user_id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| r.try_get("permission")).transpose()?) } /// List a repo's explicit collaborators, alphabetical by username. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn list_collaborators(&self, repo_id: &str) -> Result, StoreError> { let sql = "SELECT c.user_id, u.username, c.permission \ FROM repo_collaborators c JOIN users u ON u.id = c.user_id \ WHERE c.repo_id = $1 ORDER BY u.username_lower ASC"; let rows = sqlx::query(sql).bind(repo_id).fetch_all(&self.pool).await?; rows.iter() .map(|r| { Ok(Collaborator { user_id: r.try_get("user_id")?, username: r.try_get("username")?, permission: r.try_get("permission")?, }) }) .collect::>() .map_err(Into::into) } /// Add or update a collaborator's permission on a repo (`read`|`write`| /// `admin`). The caller validates the permission token. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn set_collaborator( &self, repo_id: &str, user_id: &str, permission: &str, ) -> Result<(), StoreError> { // Portable upsert: both dialects support ON CONFLICT DO UPDATE. let sql = "INSERT INTO repo_collaborators (repo_id, user_id, permission, created_at) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (repo_id, user_id) DO UPDATE SET permission = $3"; sqlx::query(sql) .bind(repo_id) .bind(user_id) .bind(permission) .bind(now_ms()) .execute(&self.pool) .await?; Ok(()) } /// Remove a collaborator from a repo. Returns `true` if a row was removed. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn remove_collaborator( &self, repo_id: &str, user_id: &str, ) -> Result { let deleted = sqlx::query("DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2") .bind(repo_id) .bind(user_id) .execute(&self.pool) .await? .rows_affected(); Ok(deleted > 0) } /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. pub(crate) fn map_repo(&self, row: &AnyRow) -> Result { Ok(Repo { id: row.try_get("id")?, owner_id: row.try_get("owner_id")?, group_id: row.try_get("group_id")?, name: row.try_get("name")?, path: row.try_get("path")?, description: row.try_get("description")?, visibility: Visibility::from_token(&row.try_get::("visibility")?) .unwrap_or(Visibility::Private), default_branch: row.try_get("default_branch")?, object_format: row.try_get("object_format")?, issues_enabled: self.get_bool(row, "issues_enabled")?, pulls_enabled: self.get_bool(row, "pulls_enabled")?, releases_enabled: self.get_bool(row, "releases_enabled")?, is_mirror: self.get_bool(row, "is_mirror")?, fork_parent_id: row.try_get("fork_parent_id")?, next_iid: row.try_get("next_iid")?, archived_at: row.try_get("archived_at")?, size_bytes: row.try_get("size_bytes")?, pushed_at: row.try_get("pushed_at")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) } }