//! The repository store: the rows behind a repository, and the lookups the
//! webui and the SSH server share.
//!
//! Split from [`super`], which holds the rules that do not need a database.
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, PaginatorTrait,
QueryFilter, QueryOrder,
};
use uuid::Uuid;
use crate::entities::repositories::{self, MergePolicy, Visibility};
use crate::entities::users;
/// Looks up a user by name, the way a repository is addressed.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn find_user(db: &DatabaseConnection, name: &str) -> Result<Option<users::Model>, DbErr> {
users::Entity::find()
.filter(users::Column::Name.eq(name))
.filter(users::Column::DeletedAt.is_null())
.one(db)
.await
}
/// Looks up one of `owner_id`'s repositories by slug, ignoring any that have
/// been deleted.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn find_repository(
db: &DatabaseConnection,
owner_id: i64,
slug: &str,
) -> Result<Option<repositories::Model>, DbErr> {
repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(owner_id))
.filter(repositories::Column::Slug.eq(slug))
.filter(repositories::Column::DeletedAt.is_null())
.one(db)
.await
}
/// Any row for `owner_id` and `slug`, whether or not it has been deleted.
///
/// Creating a repository checks this rather than [`find_repository`]: the
/// unique constraint ignores `deleted_at`, so a soft-deleted repository still
/// holds its name. Refusing here says that plainly instead of letting the
/// insert fail on a constraint.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn find_any_repository(
db: &DatabaseConnection,
owner_id: i64,
slug: &str,
) -> Result<Option<repositories::Model>, DbErr> {
repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(owner_id))
.filter(repositories::Column::Slug.eq(slug))
.one(db)
.await
}
/// Resolves an owner name and slug to the user and the repository together,
/// which is what every address on the web and over SSH turns into.
///
/// A missing owner and a missing repository are both `None`: a client that
/// guesses which of the two it was wrong has learned nothing it should not.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn find_repository_by_path(
db: &DatabaseConnection,
owner: &str,
slug: &str,
) -> Result<Option<(users::Model, repositories::Model)>, DbErr> {
let Some(user) = find_user(db, owner).await? else {
return Ok(None);
};
find_repository(db, user.id, slug)
.await
.map(|repository| repository.map(|repository| (user, repository)))
}
/// The repositories a user owns, most recently created first.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn list_repositories(
db: &DatabaseConnection,
owner_id: i64,
) -> Result<Vec<repositories::Model>, DbErr> {
repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(owner_id))
.filter(repositories::Column::DeletedAt.is_null())
.order_by_desc(repositories::Column::CreatedAt)
.all(db)
.await
}
/// Records a new repository for `owner_id`.
///
/// The row only - creating the bare repository on disk is [`super::init_bare`],
/// which the caller does first so that a row is never left pointing at a
/// directory that was never made.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn create_repository(
db: &DatabaseConnection,
owner_id: i64,
slug: &str,
description: Option<&str>,
visibility: Visibility,
) -> Result<repositories::Model, DbErr> {
repositories::ActiveModel {
uuid: Set(Uuid::new_v4()),
owner_id: Set(owner_id),
slug: Set(slug.to_string()),
description: Set(description.map(str::to_string)),
visibility: Set(visibility),
default_branch: Set(super::DEFAULT_BRANCH.to_string()),
next_ticket_number: Set(1),
next_patch_number: Set(1),
default_merge_policy: Set(repositories::MergePolicy::PreferFastForward),
..Default::default()
}
.insert(db)
.await
}
/// How many repositories a user owns, for the checks that want to refuse a
/// name without building anything.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn count_repositories(db: &DatabaseConnection, owner_id: i64) -> Result<u64, DbErr> {
repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(owner_id))
.filter(repositories::Column::DeletedAt.is_null())
.count(db)
.await
}
/// Updates the visibility of a repository.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn update_visibility(
db: &DatabaseConnection,
repository_id: i64,
visibility: Visibility,
) -> Result<(), DbErr> {
let Some(repository) = repositories::Entity::find_by_id(repository_id)
.one(db)
.await?
else {
return Ok(());
};
let mut active: repositories::ActiveModel = repository.into();
active.visibility = Set(visibility);
active.update(db).await?;
Ok(())
}
/// Updates the default branch of a repository.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn update_default_branch(
db: &DatabaseConnection,
repository_id: i64,
default_branch: &str,
) -> Result<(), DbErr> {
let Some(repository) = repositories::Entity::find_by_id(repository_id)
.one(db)
.await?
else {
return Ok(());
};
let mut active: repositories::ActiveModel = repository.into();
active.default_branch = Set(default_branch.to_string());
active.update(db).await?;
Ok(())
}
/// Updates the default merge policy of a repository.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn update_default_merge_policy(
db: &DatabaseConnection,
repository_id: i64,
default_merge_policy: MergePolicy,
) -> Result<(), DbErr> {
let Some(repository) = repositories::Entity::find_by_id(repository_id)
.one(db)
.await?
else {
return Ok(());
};
let mut active: repositories::ActiveModel = repository.into();
active.default_merge_policy = Set(default_merge_policy);
active.update(db).await?;
Ok(())
}