🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The patch proposal store: database operations for creating, updating, and
//! querying patch proposals.

use sea_orm::{
    ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, DbErr, EntityTrait,
    IntoActiveModel, QueryFilter, Set,
};

use crate::entities::patch_proposals::{self, MergePolicy, Status};
use crate::entities::repositories;
use chrono::Utc;
use uuid::Uuid;

/// 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
}

/// Closes a patch proposal by setting its status to Closed.
pub async fn close(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.status = Set(Status::Closed);
    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
}