jj-vine 0.4.0

Stacked pull requests for jj (jujutsu). Supports GitLab and bookmark-based flow.
Documentation
use bon::Builder;
use owo_colors::OwoColorize;
use tracing::error;

use crate::{
    error::{Error, Result},
    forge::{Forge, ForgeCreateMergeRequestOptions},
    submit::execute::{
        ActionInfo,
        ActionResultData,
        ExecuteAction,
        ExecuteActionContext,
        MRUpdate,
        MRUpdateType,
    },
};

/// Create a new merge request
#[derive(Debug, Clone, PartialEq, Eq, Builder)]
pub struct CreateMRAction {
    /// The bookmark of the merge request
    pub bookmark: String,

    /// The target branch of the merge request
    pub target_branch: String,

    /// The title of the merge request
    pub title: String,

    /// The description of the merge request
    pub description: String,

    pub dependencies: Option<Vec<String>>,
}

impl ActionInfo for CreateMRAction {
    fn id(&self) -> String {
        format!("create_mr:{}", self.bookmark)
    }

    fn group_text(&self) -> String {
        "Creating MRs".to_string()
    }

    fn text(&self) -> String {
        format!("Creating MR for {}", self.bookmark.magenta())
    }

    fn substep_text(&self) -> String {
        self.bookmark.clone().magenta().to_string()
    }

    fn plan_text(&self) -> String {
        match self.description.lines().count() {
            0 => format!(
                "Create MR for {} -> {} named \"{}\" with no description",
                self.bookmark.magenta(),
                self.target_branch.magenta(),
                self.title.bold()
            ),
            lines => format!(
                "Create MR for {} -> {} named \"{}\" and a description {} lines long",
                self.bookmark.magenta(),
                self.target_branch.magenta(),
                self.title.bold(),
                lines
            ),
        }
    }

    fn dependencies(&self) -> Vec<String> {
        self.dependencies.as_ref().cloned().unwrap_or_default()
    }
}

impl ExecuteAction for CreateMRAction {
    async fn execute(&self, ctx: ExecuteActionContext<'_>) -> Result<ActionResultData> {
        if ctx.execute.dry_run {
            let msg = format!(
                "Would {} {} -> {} \"{}\"",
                "create".green(),
                self.bookmark.magenta(),
                self.target_branch.magenta(),
                self.title
            );
            ctx.execute.output.log_message(&msg);

            return Ok(ActionResultData::DryRun);
        }

        let desc = if self.description.is_empty() {
            None
        } else {
            Some(self.description.as_str())
        };

        let assignees = if ctx.execute.config.assign_to_self {
            match ctx.execute.forge.current_user().await {
                Ok(user) => Some(vec![user]),
                Err(e) => {
                    let warning =
                        format!("Warning: Failed to get current user for assignment: {}", e);
                    ctx.execute
                        .output
                        .log_message(&warning.yellow().to_string());
                    None
                }
            }
        } else {
            None
        };

        let mut reviewers = Vec::new();
        for username in &ctx.execute.config.default_reviewers {
            match ctx.execute.forge.user_by_username(username).await {
                Ok(Some(user)) => {
                    reviewers.push(user);
                }
                Ok(None) => {
                    let warning = format!("Warning: Reviewer '{}' not found", username);
                    ctx.execute
                        .output
                        .log_message(&warning.yellow().to_string());
                }
                Err(e) => {
                    let warning =
                        format!("Warning: Failed to look up reviewer '{}': {}", username, e);
                    ctx.execute
                        .output
                        .log_message(&warning.yellow().to_string());
                }
            }
        }

        match ctx
            .execute
            .forge
            .create_merge_request(
                ForgeCreateMergeRequestOptions::builder()
                    .source_branch(self.bookmark.clone())
                    .target_branch(self.target_branch.clone())
                    .title(self.title.clone())
                    .remove_source_branch(ctx.execute.config.delete_source_branch)
                    .squash(ctx.execute.config.squash_commits)
                    .description(desc.map(|s| s.to_string()))
                    .assignees(assignees.unwrap_or_default())
                    .reviewers(reviewers)
                    .open_as_draft(ctx.execute.config.open_as_draft)
                    .build(),
            )
            .await
        {
            Ok(mr) => {
                ctx.execute.output.log_completed(&format!(
                    "Created MR {}: {}",
                    format!("!{}", mr.iid()).cyan(),
                    &mr.url().dimmed()
                ));
                Ok(ActionResultData::MRCreated(MRUpdate {
                    mr,
                    bookmark: self.bookmark.clone(),
                    update_type: MRUpdateType::Created,
                }))
            }
            Err(e) => {
                let error_msg = format!("Failed to create MR for {}: {}", self.bookmark, e);
                ctx.execute.output.log_message(&error_msg);
                error!("{}", error_msg);
                Err(Error::new(error_msg))
            }
        }
    }
}