//! The patch proposal store: database operations for creating, updating, and
//! querying patch proposals.
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait,
IntoActiveModel, QueryFilter, Set,
};
use uuid::Uuid;
use crate::entities::patch_proposals::{self, MergePolicy, Status};
use crate::entities::repositories;
/// Creates a new draft patch proposal for the given repository.
///
/// The patch proposal is assigned the next available number for the repository,
/// and its source_ref is set to `patches/{number}`. The target_branch defaults
/// to the repository's default_branch.
///
/// If `change_id` is provided, it's recorded so that future pushes of the same
/// logical change (as identified by jj) update this patch instead of creating
/// a new one.
///
/// Returns the created patch proposal model.
#[allow(clippy::too_many_arguments)]
pub async fn create_draft(
db: &DatabaseConnection,
repository_id: i64,
creator_id: i64,
summary: &str,
description: Option<&str>,
target_branch: Option<&str>,
merge_policy: MergePolicy,
change_id: Option<&str>,
) -> Result<patch_proposals::Model, DbErr> {
// Get the repository to find the next patch number and default branch
let repository = repositories::Entity::find_by_id(repository_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"repository {repository_id} not found"
)))?;
let patch_number = repository.next_patch_number;
// Clone the default branch before we consume the repository
let default_branch = repository.default_branch.clone();
let target = target_branch.unwrap_or(&default_branch);
let source_ref = format!("patches/{patch_number}");
// Increment the next patch number
let mut repo_active: repositories::ActiveModel = repository.into();
repo_active.next_patch_number = Set(patch_number + 1);
repo_active.update(db).await?;
// Create the patch proposal
let now = Utc::now().fixed_offset();
let pp = patch_proposals::ActiveModel {
uuid: Set(Uuid::new_v4()),
repository_id: Set(repository_id),
creator_id: Set(Some(creator_id)),
remote_actor_id: Set(None),
remote_offer_id: Set(None),
resolve_activity_url: Set(None),
number: Set(patch_number),
summary: Set(summary.to_string()),
description: Set(description.map(|s| s.to_string())),
source_ref: Set(source_ref),
target_branch: Set(target.to_string()),
status: Set(Status::Draft),
merge_policy: Set(merge_policy),
change_id: Set(change_id.map(|s| s.to_string())),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
pp.insert(db).await
}
/// Creates a patch proposal from a remote Offer (inbound federation).
///
/// The `remote_offer_id` is the Offer activity's id, used for idempotency.
/// The patch starts in the given status (typically Open for MRs).
#[allow(clippy::too_many_arguments)]
pub async fn create_draft_from_remote(
db: &DatabaseConnection,
repository_id: i64,
remote_actor_id: i64,
summary: &str,
description: Option<&str>,
target_branch: &str,
merge_policy: MergePolicy,
status: Status,
remote_offer_id: &str,
) -> Result<patch_proposals::Model, DbErr> {
// Get the repository to find the next patch number
let repository = repositories::Entity::find_by_id(repository_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"repository {repository_id} not found"
)))?;
let patch_number = repository.next_patch_number;
let source_ref = format!("patches/{patch_number}");
// Increment the next patch number
let mut repo_active: repositories::ActiveModel = repository.into();
repo_active.next_patch_number = Set(patch_number + 1);
repo_active.update(db).await?;
// Create the patch proposal
let now = Utc::now().fixed_offset();
let pp = patch_proposals::ActiveModel {
uuid: Set(Uuid::new_v4()),
repository_id: Set(repository_id),
creator_id: Set(None),
remote_actor_id: Set(Some(remote_actor_id)),
remote_offer_id: Set(Some(remote_offer_id.to_string())),
resolve_activity_url: Set(None),
number: Set(patch_number),
summary: Set(summary.to_string()),
description: Set(description.map(|s| s.to_string())),
source_ref: Set(source_ref),
target_branch: Set(target_branch.to_string()),
status: Set(status),
merge_policy: Set(merge_policy),
change_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
pp.insert(db).await
}
/// Finds a patch proposal by repository ID and patch number.
pub async fn find_by_number(
db: &DatabaseConnection,
repository_id: i64,
number: i64,
) -> Result<Option<patch_proposals::Model>, DbErr> {
patch_proposals::Entity::find()
.filter(patch_proposals::Column::RepositoryId.eq(repository_id))
.filter(patch_proposals::Column::Number.eq(number))
.one(db)
.await
}
/// Finds a patch proposal by repository ID and source ref (e.g., "patches/1").
pub async fn find_by_source_ref(
db: &DatabaseConnection,
repository_id: i64,
source_ref: &str,
) -> Result<Option<patch_proposals::Model>, DbErr> {
patch_proposals::Entity::find()
.filter(patch_proposals::Column::RepositoryId.eq(repository_id))
.filter(patch_proposals::Column::SourceRef.eq(source_ref))
.one(db)
.await
}
/// Finds an unclosed patch proposal by its jj change-id.
///
/// Returns the open patch proposal in this repository that carries the given
/// change-id, if any. Closed and merged patches are excluded — a new PP gets
/// its own identity after the old one is done with.
pub async fn find_open_by_change_id(
db: &DatabaseConnection,
repository_id: i64,
change_id: &str,
) -> Result<Option<patch_proposals::Model>, DbErr> {
patch_proposals::Entity::find()
.filter(patch_proposals::Column::RepositoryId.eq(repository_id))
.filter(patch_proposals::Column::ChangeId.eq(change_id))
.filter(
Condition::any()
.add(patch_proposals::Column::Status.eq(Status::Draft))
.add(patch_proposals::Column::Status.eq(Status::Open)),
)
.one(db)
.await
}
/// Records or adopts a change-id on an existing patch proposal.
///
/// Used when a plain-git PP gets its first jj push, or when a change-id
/// needs to be updated.
pub async fn set_change_id(
db: &DatabaseConnection,
patch_id: i64,
change_id: Option<&str>,
) -> Result<patch_proposals::Model, DbErr> {
let mut pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
pp.change_id = Set(change_id.map(|s| s.to_string()));
pp.update(db).await
}
/// Updates an existing patch proposal.
///
/// Only the summary, description, target_branch, status, and merge_policy
/// fields can be updated.
pub async fn update(
db: &DatabaseConnection,
patch_id: i64,
summary: Option<&str>,
description: Option<&str>,
target_branch: Option<&str>,
status: Option<Status>,
merge_policy: Option<MergePolicy>,
) -> Result<patch_proposals::Model, DbErr> {
let mut pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
if let Some(s) = summary {
pp.summary = Set(s.to_string());
}
if let Some(d) = description {
pp.description = Set(Some(d.to_string()));
}
if let Some(t) = target_branch {
pp.target_branch = Set(t.to_string());
}
if let Some(s) = status {
pp.status = Set(s);
}
if let Some(m) = merge_policy {
pp.merge_policy = Set(m);
}
pp.update(db).await
}
/// Updates a patch proposal with the given fields, including the
/// resolve_activity_url.
#[allow(clippy::too_many_arguments)]
pub async fn update_with_resolve_url(
db: &DatabaseConnection,
patch_id: i64,
summary: Option<&str>,
description: Option<&str>,
target_branch: Option<&str>,
status: Option<Status>,
merge_policy: Option<MergePolicy>,
resolve_activity_url: Option<String>,
) -> Result<patch_proposals::Model, DbErr> {
let mut pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
if let Some(s) = summary {
pp.summary = Set(s.to_string());
}
if let Some(d) = description {
pp.description = Set(Some(d.to_string()));
}
if let Some(t) = target_branch {
pp.target_branch = Set(t.to_string());
}
if let Some(s) = status {
pp.status = Set(s);
}
if let Some(m) = merge_policy {
pp.merge_policy = Set(m);
}
pp.resolve_activity_url = Set(resolve_activity_url);
pp.update(db).await
}
/// Updates a patch proposal with snapshot OIDs for diff preservation.
#[allow(clippy::too_many_arguments)]
pub async fn update_with_merge_snapshot(
db: &DatabaseConnection,
patch_id: i64,
summary: Option<&str>,
description: Option<&str>,
target_branch: Option<&str>,
status: Option<Status>,
merge_policy: Option<MergePolicy>,
snapshot_base_oid: Option<&str>,
snapshot_head_oid: Option<&str>,
resolve_activity_url: Option<String>,
) -> Result<patch_proposals::Model, DbErr> {
let mut pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
if let Some(s) = summary {
pp.summary = Set(s.to_string());
}
if let Some(d) = description {
pp.description = Set(Some(d.to_string()));
}
if let Some(t) = target_branch {
pp.target_branch = Set(t.to_string());
}
if let Some(s) = status {
pp.status = Set(s);
}
if let Some(m) = merge_policy {
pp.merge_policy = Set(m);
}
if let Some(base) = snapshot_base_oid {
pp.snapshot_base_oid = Set(Some(base.to_string()));
}
if let Some(head) = snapshot_head_oid {
pp.snapshot_head_oid = Set(Some(head.to_string()));
}
pp.resolve_activity_url = Set(resolve_activity_url);
pp.update(db).await
}
/// Closes a patch proposal by setting its status to Closed.
/// If snapshot OIDs are provided, they are stored to preserve the diff state at
/// close time.
pub async fn close(
db: &DatabaseConnection,
patch_id: i64,
snapshot_base_oid: Option<&str>,
snapshot_head_oid: Option<&str>,
) -> Result<(), DbErr> {
let pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
let mut pp = pp;
pp.status = Set(Status::Closed);
if let Some(base) = snapshot_base_oid {
pp.snapshot_base_oid = Set(Some(base.to_string()));
}
if let Some(head) = snapshot_head_oid {
pp.snapshot_head_oid = Set(Some(head.to_string()));
}
pp.update(db).await?;
Ok(())
}
/// Clears the snapshot OIDs on a patch proposal (used when reopening).
pub async fn clear_snapshot_oids(db: &DatabaseConnection, patch_id: i64) -> Result<(), DbErr> {
let pp = patch_proposals::Entity::find_by_id(patch_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"patch proposal {patch_id} not found"
)))?
.into_active_model();
let mut pp = pp;
pp.snapshot_base_oid = Set(None);
pp.snapshot_head_oid = Set(None);
pp.update(db).await?;
Ok(())
}
/// Lists patch proposals for a repository, optionally filtered by status.
///
/// Returns patches in reverse order by number (newest first).
pub async fn list(
db: &DatabaseConnection,
repository_id: i64,
status_filter: Option<Status>,
) -> Result<Vec<patch_proposals::Model>, DbErr> {
use sea_orm::QueryOrder;
let mut query = patch_proposals::Entity::find()
.filter(patch_proposals::Column::RepositoryId.eq(repository_id));
if let Some(status) = status_filter {
query = query.filter(patch_proposals::Column::Status.eq(status));
}
query
.order_by_desc(patch_proposals::Column::Number)
.all(db)
.await
}