Skip to main content

silver_platter/
batch.rs

1//! Batch management.
2
3use crate::candidates::Candidate;
4use crate::codemod::script_runner;
5use crate::proposal::DescriptionFormat;
6use crate::publish::{Error as PublishError, PublishResult};
7use crate::recipe::Recipe;
8use crate::vcs::{open_branch, BranchOpenError};
9use crate::workspace::Workspace;
10use crate::Mode;
11use breezyshim::branch::{Branch, GenericBranch};
12use breezyshim::error::Error as BrzError;
13use breezyshim::tree::MutableTree;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use url::Url;
19
20/// Current version of the batch format.
21pub const CURRENT_VERSION: u8 = 1;
22
23#[derive(Debug, serde::Serialize, serde::Deserialize)]
24/// Batch entry
25pub struct Entry {
26    #[serde(skip)]
27    local_path: PathBuf,
28
29    /// Subpath within the local path to work on.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub subpath: Option<PathBuf>,
32
33    /// URL of the target branch.
34    #[serde(rename = "url")]
35    pub target_branch_url: Option<Url>,
36
37    /// Description of the work to be done.
38    pub description: String,
39
40    #[serde(
41        rename = "commit-message",
42        default,
43        skip_serializing_if = "Option::is_none"
44    )]
45    /// Commit message for the work.
46    pub commit_message: Option<String>,
47
48    #[serde(
49        rename = "auto-merge",
50        default,
51        skip_serializing_if = "Option::is_none"
52    )]
53    /// Whether to automatically merge the proposal.
54    pub auto_merge: Option<bool>,
55
56    /// Mode for the work.
57    pub mode: Mode,
58
59    /// Title of the work.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub title: Option<String>,
62
63    /// Owner of the work.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub owner: Option<String>,
66
67    /// Labels for the work.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub labels: Option<Vec<String>>,
70
71    /// Context for the work.
72    #[serde(default, skip_serializing_if = "serde_yaml::Value::is_null")]
73    pub context: serde_yaml::Value,
74
75    #[serde(
76        rename = "proposal-url",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    /// URL of the proposal for this work.
81    pub proposal_url: Option<Url>,
82}
83
84#[derive(Debug, serde::Deserialize, serde::Serialize)]
85/// Batch
86pub struct Batch {
87    /// Format version
88    #[serde(default)]
89    pub version: u8,
90
91    /// Recipe
92    #[serde(deserialize_with = "deserialize_recipe")]
93    pub recipe: Recipe,
94
95    /// Batch name
96    pub name: String,
97
98    /// Work to be done in this batch.
99    pub work: HashMap<String, Entry>,
100
101    #[serde(skip)]
102    /// Basepath for the batch
103    pub basepath: PathBuf,
104}
105
106fn deserialize_recipe<'de, D>(deserializer: D) -> Result<Recipe, D::Error>
107where
108    D: serde::Deserializer<'de>,
109{
110    // Recipe can either be a PathBuf or a Recipe
111    #[derive(serde::Deserialize)]
112    #[serde(untagged)]
113    enum RecipeOrPathBuf {
114        Recipe(Recipe),
115        PathBuf(PathBuf),
116    }
117
118    let value = RecipeOrPathBuf::deserialize(deserializer)?;
119
120    match value {
121        RecipeOrPathBuf::Recipe(recipe) => Ok(recipe),
122        RecipeOrPathBuf::PathBuf(path) => {
123            let file = std::fs::File::open(&path).map_err(serde::de::Error::custom)?;
124            let recipe: Recipe = serde_yaml::from_reader(file).map_err(serde::de::Error::custom)?;
125            Ok(recipe)
126        }
127    }
128}
129
130#[derive(Debug)]
131/// Batch error
132pub enum Error {
133    /// Error running a script
134    Script(crate::codemod::Error),
135
136    /// Error opening a branch
137    Vcs(crate::vcs::BranchOpenError),
138
139    /// I/O error
140    Io(std::io::Error),
141
142    /// Error parsing YAML
143    Yaml(serde_yaml::Error),
144
145    /// Error with Tera
146    Tera(tera::Error),
147
148    /// Error with workspace
149    Workspace(crate::workspace::Error),
150}
151
152impl From<crate::workspace::Error> for Error {
153    fn from(e: crate::workspace::Error) -> Self {
154        Error::Workspace(e)
155    }
156}
157
158impl From<crate::codemod::Error> for Error {
159    fn from(e: crate::codemod::Error) -> Self {
160        Error::Script(e)
161    }
162}
163
164impl From<crate::vcs::BranchOpenError> for Error {
165    fn from(e: crate::vcs::BranchOpenError) -> Self {
166        Error::Vcs(e)
167    }
168}
169
170impl From<std::io::Error> for Error {
171    fn from(e: std::io::Error) -> Self {
172        Error::Io(e)
173    }
174}
175
176impl From<tera::Error> for Error {
177    fn from(e: tera::Error) -> Self {
178        Error::Tera(e)
179    }
180}
181
182impl From<serde_yaml::Error> for Error {
183    fn from(e: serde_yaml::Error) -> Self {
184        Error::Yaml(e)
185    }
186}
187
188impl std::fmt::Display for Error {
189    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
190        match self {
191            Error::Vcs(e) => write!(f, "VCS error: {}", e),
192            Error::Script(e) => write!(f, "Script error: {}", e),
193            Error::Io(e) => write!(f, "I/O error: {}", e),
194            Error::Yaml(e) => write!(f, "YAML error: {}", e),
195            Error::Tera(e) => write!(f, "Tera error: {}", e),
196            Error::Workspace(e) => write!(f, "Workspace error: {}", e),
197        }
198    }
199}
200
201impl Entry {
202    /// Create a new batch entry from a recipe.
203    pub fn from_recipe(
204        recipe: &Recipe,
205        basepath: &Path,
206        url: &Url,
207        subpath: &Path,
208        default_mode: Option<Mode>,
209        extra_env: Option<HashMap<String, String>>,
210    ) -> Result<Self, Error> {
211        if !basepath.exists() {
212            std::fs::create_dir_all(basepath)?;
213        }
214        let basepath = basepath.canonicalize().unwrap();
215        let main_branch = match open_branch(url, None, None, None) {
216            Ok(branch) => branch,
217            Err(e) => return Err(Error::Vcs(e)),
218        };
219
220        let ws = Workspace::builder()
221            .main_branch(main_branch)
222            .path(basepath.to_path_buf())
223            .build()?;
224
225        log::info!(
226            "Making changes to {}",
227            ws.main_branch().unwrap().get_user_url()
228        );
229
230        let result = match script_runner(
231            ws.local_tree(),
232            recipe
233                .command
234                .as_ref()
235                .unwrap()
236                .argv()
237                .iter()
238                .map(|s| s.as_str())
239                .collect::<Vec<_>>()
240                .as_slice(),
241            subpath,
242            recipe.commit_pending,
243            None,
244            None,
245            extra_env,
246            std::process::Stdio::inherit(),
247        ) {
248            Ok(result) => result,
249            Err(e) => return Err(Error::Script(e)),
250        };
251
252        let tera_context: tera::Context = tera::Context::from_value(
253            result
254                .context
255                .clone()
256                .unwrap_or_else(|| serde_json::json!({})),
257        )
258        .unwrap();
259
260        let target_branch_url = match result.target_branch_url {
261            Some(url) => Some(url),
262            None => Some(url.clone()),
263        };
264        let description = if let Some(description) = result.description {
265            description
266        } else if let Some(ref mr) = recipe.merge_request {
267            mr.render_description(DescriptionFormat::Markdown, &tera_context)?
268                .unwrap()
269        } else {
270            panic!("No description provided");
271        };
272        let commit_message = if let Some(commit_message) = result.commit_message {
273            Some(commit_message)
274        } else if let Some(ref mr) = recipe.merge_request {
275            mr.render_commit_message(&tera_context)?
276        } else {
277            None
278        };
279        let title = if let Some(title) = result.title {
280            Some(title)
281        } else if let Some(ref mr) = recipe.merge_request {
282            mr.render_title(&tera_context)?
283        } else {
284            None
285        };
286        let mode = recipe.mode.or(default_mode).unwrap_or_default();
287        let labels = recipe.labels.clone();
288        let context = result.context;
289
290        let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge);
291
292        let owner = None;
293
294        Ok(Entry {
295            local_path: basepath.to_path_buf(),
296            subpath: Some(subpath.to_owned()),
297            target_branch_url,
298            description,
299            commit_message,
300            mode,
301            owner,
302            title,
303            labels,
304            auto_merge,
305            proposal_url: None,
306            context: serde_yaml::from_str(
307                context
308                    .unwrap_or(serde_json::Value::Null)
309                    .to_string()
310                    .as_str(),
311            )
312            .unwrap(),
313        })
314    }
315
316    /// Return the status of this entry
317    pub fn status(&self) -> Status {
318        if let Some(proposal_url) = self.proposal_url.as_ref() {
319            let proposal = breezyshim::forge::get_proposal_by_url(proposal_url).unwrap();
320            if proposal.is_merged().unwrap() {
321                Status::Merged(proposal_url.clone())
322            } else if proposal.is_closed().unwrap() {
323                Status::Closed(proposal_url.clone())
324            } else {
325                Status::Open(proposal_url.clone())
326            }
327        } else {
328            Status::NotPublished()
329        }
330    }
331
332    /// Get the local working tree for this entry.
333    pub fn working_tree(&self) -> Result<Box<dyn breezyshim::tree::WorkingTree>, BrzError> {
334        breezyshim::workingtree::open(&self.local_path)
335            .map(|wt| Box::new(wt) as Box<dyn breezyshim::tree::WorkingTree>)
336    }
337
338    /// Get the target branch for this entry.
339    pub fn target_branch(&self) -> Result<GenericBranch, BranchOpenError> {
340        open_branch(self.target_branch_url.as_ref().unwrap(), None, None, None)
341    }
342
343    /// Get the local branch for this entry.
344    pub fn local_branch(&self) -> Result<GenericBranch, BranchOpenError> {
345        let url = match url::Url::from_directory_path(&self.local_path) {
346            Ok(url) => url,
347            Err(_) => {
348                return Err(BranchOpenError::Other(format!(
349                    "Invalid URL: {}",
350                    self.local_path.display()
351                )));
352            }
353        };
354        open_branch(&url, None, None, None)
355    }
356
357    /// Refresh the changes for this entry.
358    pub fn refresh(
359        &mut self,
360        recipe: &Recipe,
361        extra_env: Option<HashMap<String, String>>,
362    ) -> Result<(), Error> {
363        let url = self.target_branch_url.as_ref().unwrap();
364        let main_branch = match open_branch(url, None, None, None) {
365            Ok(branch) => branch,
366            Err(e) => return Err(Error::Vcs(e)),
367        };
368
369        let ws = Workspace::builder()
370            .main_branch(main_branch)
371            .path(self.local_path.clone())
372            .build()?;
373
374        log::info!(
375            "Making changes to {}",
376            ws.main_branch().unwrap().get_user_url()
377        );
378
379        assert_eq!(
380            ws.main_branch().unwrap().last_revision(),
381            ws.local_tree().last_revision().unwrap()
382        );
383
384        let result = match script_runner(
385            ws.local_tree(),
386            recipe
387                .command
388                .as_ref()
389                .unwrap()
390                .argv()
391                .iter()
392                .map(|s| s.as_str())
393                .collect::<Vec<_>>()
394                .as_slice(),
395            self.subpath.as_deref().unwrap_or_else(|| Path::new("")),
396            recipe.commit_pending,
397            None,
398            None,
399            extra_env,
400            std::process::Stdio::inherit(),
401        ) {
402            Ok(result) => result,
403            Err(e) => return Err(Error::Script(e)),
404        };
405
406        let tera_context: tera::Context = tera::Context::from_value(
407            result
408                .context
409                .clone()
410                .unwrap_or_else(|| serde_json::json!({})),
411        )
412        .unwrap();
413
414        let target_branch_url = match result.target_branch_url {
415            Some(url) => Some(url),
416            None => Some(url.clone()),
417        };
418        let description = if let Some(description) = result.description {
419            description
420        } else if let Some(ref mr) = recipe.merge_request {
421            mr.render_description(DescriptionFormat::Markdown, &tera_context)?
422                .unwrap()
423        } else {
424            panic!("No description provided");
425        };
426        let commit_message = if let Some(commit_message) = result.commit_message {
427            Some(commit_message)
428        } else if let Some(ref mr) = recipe.merge_request {
429            mr.render_commit_message(&tera_context)?
430        } else {
431            None
432        };
433        let title = if let Some(title) = result.title {
434            Some(title)
435        } else if let Some(ref mr) = recipe.merge_request {
436            mr.render_title(&tera_context)?
437        } else {
438            None
439        };
440        let mode = recipe.mode.unwrap_or_default();
441        let labels = recipe.labels.clone();
442        let context = result.context;
443
444        let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge);
445
446        let owner = None;
447
448        self.target_branch_url = target_branch_url;
449        self.description = description;
450        self.commit_message = commit_message;
451        self.mode = mode;
452        self.owner = owner;
453        self.title = title;
454        self.labels = labels;
455        self.auto_merge = auto_merge;
456        self.context = serde_yaml::from_str(
457            context
458                .unwrap_or(serde_json::Value::Null)
459                .to_string()
460                .as_str(),
461        )
462        .unwrap();
463
464        Ok(())
465    }
466
467    /// Publish this entry
468    pub fn publish(
469        &mut self,
470        batch_name: &str,
471        refresh: bool,
472        overwrite: Option<bool>,
473    ) -> Result<PublishResult, PublishError> {
474        let target_branch_url = match self.target_branch_url.as_ref() {
475            Some(url) => url,
476            None => {
477                return Err(PublishError::NoTargetBranch);
478            }
479        };
480
481        let result = publish_one(
482            target_branch_url,
483            self.working_tree().unwrap().as_ref(),
484            batch_name,
485            self.mode,
486            self.proposal_url.as_ref(),
487            self.labels.clone(),
488            self.owner.as_deref(),
489            refresh,
490            self.commit_message.as_deref(),
491            self.title.as_deref(),
492            Some(self.description.as_str()),
493            overwrite.or_else(|| {
494                if self.proposal_url.is_some() {
495                    Some(true)
496                } else {
497                    None
498                }
499            }),
500            self.auto_merge,
501        )?;
502
503        if let Some(ref proposal) = result.proposal {
504            self.proposal_url = Some(proposal.url().unwrap());
505        }
506        Ok(result)
507    }
508}
509
510/// Status of a batch entry.
511pub enum Status {
512    /// Merged - URL of the merge proposal.
513    Merged(Url),
514
515    /// Closed - URL of the merge proposal.
516    Closed(Url),
517
518    /// Open - URL of the merge proposal.
519    Open(Url),
520
521    /// Not published yet.
522    NotPublished(),
523}
524
525impl std::fmt::Display for Status {
526    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
527        match self {
528            Status::Merged(url) => write!(f, "Merged: {}", url),
529            Status::Closed(url) => write!(f, "{} was closed without being merged", url),
530            Status::Open(url) => write!(f, "{} is still open", url),
531            Status::NotPublished() => write!(f, "Not published yet"),
532        }
533    }
534}
535
536impl Batch {
537    /// Create a batch from a recipe and a set of candidates.
538    pub fn from_recipe<'a>(
539        recipe: &Recipe,
540        candidates: impl Iterator<Item = &'a Candidate>,
541        directory: &Path,
542        extra_env: Option<HashMap<String, String>>,
543    ) -> Result<Batch, Error> {
544        // The directory should either be empty or not exist
545        if directory.exists() {
546            if !directory.is_dir() {
547                return Err(Error::Io(std::io::Error::new(
548                    std::io::ErrorKind::AlreadyExists,
549                    "Not a directory",
550                )));
551            }
552            if let Ok(entries) = std::fs::read_dir(directory) {
553                if entries.count() > 0 {
554                    return Err(Error::Io(std::io::Error::new(
555                        std::io::ErrorKind::AlreadyExists,
556                        "Directory not empty",
557                    )));
558                }
559            }
560        } else {
561            std::fs::create_dir_all(directory)?;
562        }
563
564        // make sure directory is an absolute path
565        let directory = directory.canonicalize().unwrap();
566
567        let mut batch = match load_batch_metadata(&directory) {
568            Ok(Some(batch)) => batch,
569            Ok(None) => Batch {
570                version: CURRENT_VERSION,
571                recipe: recipe.clone(),
572                name: recipe.name.clone().unwrap(),
573                work: HashMap::new(),
574                basepath: directory.to_path_buf(),
575            },
576            Err(e) => return Err(e),
577        };
578
579        for candidate in candidates {
580            let basename = match candidate.shortname() {
581                Ok(name) => name,
582                Err(e) => {
583                    log::warn!("Skipping candidate {}: {:?}", candidate.url, e);
584                    continue;
585                }
586            };
587
588            let mut name = basename.to_string();
589
590            // TODO(jelmer): Search by URL rather than by name?
591            if let Some(entry) = batch.work.get(name.as_str()) {
592                if entry.target_branch_url.as_ref() == Some(&candidate.url) {
593                    log::info!("Skipping {} ({}) (already in batch)", name, candidate.url);
594                    continue;
595                }
596            }
597
598            let mut work_path = directory.join(&name);
599            let mut i = 0;
600            while std::fs::metadata(&work_path).is_ok() {
601                i += 1;
602                name = format!("{}.{}", basename, i);
603                work_path = directory.join(&name);
604            }
605
606            match Entry::from_recipe(
607                recipe,
608                work_path.as_ref(),
609                &candidate.url,
610                candidate
611                    .subpath
612                    .as_deref()
613                    .unwrap_or_else(|| Path::new("")),
614                candidate.default_mode,
615                extra_env.clone(),
616            ) {
617                Ok(entry) => {
618                    batch.work.insert(name, entry);
619                    save_batch_metadata(&directory, &batch)?;
620                }
621                Err(e) => {
622                    log::error!("Failed to generate batch entry for {}: {}", name, e);
623                    // Recursively remove work_path
624                    std::fs::remove_dir_all(work_path)?;
625                    continue;
626                }
627            }
628        }
629        save_batch_metadata(&directory, &batch)?;
630        Ok(batch)
631    }
632
633    /// Get reference to a batch entry.
634    pub fn get(&self, name: &str) -> Option<&Entry> {
635        self.work.get(name)
636    }
637
638    /// Get a mutable reference to a batch entry.
639    pub fn get_mut(&mut self, name: &str) -> Option<&mut Entry> {
640        self.work.get_mut(name)
641    }
642
643    /// Returen the status of all work in the batch.
644    pub fn status(&self) -> HashMap<&str, Status> {
645        let mut status = HashMap::new();
646        for (name, entry) in self.work.iter() {
647            status.insert(name.as_str(), entry.status());
648        }
649        status
650    }
651
652    /// Remove work from the batch.
653    pub fn remove(&mut self, name: &str) -> Result<(), Error> {
654        self.work.remove(name);
655        let path = self.basepath.join(name);
656        match std::fs::remove_dir_all(&path) {
657            Ok(()) => Ok(()),
658            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
659                log::warn!("{} ({}): already removed - {}", name, path.display(), e);
660                Ok(())
661            }
662            Err(e) => Err(Error::Io(e)),
663        }
664    }
665}
666
667/// Drop a batch entry from the given directory.
668pub fn drop_batch_entry(directory: &Path, name: &str) -> Result<(), Error> {
669    let mut batch = match load_batch_metadata(directory)? {
670        Some(batch) => batch,
671        None => return Ok(()),
672    };
673    batch.work.remove(name);
674    match std::fs::remove_dir_all(directory.join(name)) {
675        Ok(()) => {}
676        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
677            log::warn!(
678                "{} ({}): already removed - {}",
679                name,
680                directory.join(name).display(),
681                e
682            );
683        }
684        Err(e) => {
685            return Err(Error::Io(e));
686        }
687    }
688    save_batch_metadata(directory, &batch)?;
689    Ok(())
690}
691
692/// Save batch metadata to the metadata file in the given directory.
693pub fn save_batch_metadata(directory: &Path, batch: &Batch) -> Result<(), Error> {
694    let mut file = std::fs::File::create(directory.join("batch.yaml"))?;
695    serde_yaml::to_writer(&mut file, &batch)?;
696    file.flush()?;
697    Ok(())
698}
699
700/// Load a batch metadata from the metadata file in the given directory.
701pub fn load_batch_metadata(directory: &Path) -> Result<Option<Batch>, Error> {
702    assert!(directory.is_absolute());
703    let file = match std::fs::File::open(directory.join("batch.yaml")) {
704        Ok(f) => f,
705        Err(e) => {
706            if e.kind() == std::io::ErrorKind::NotFound {
707                return Ok(None);
708            }
709            return Err(Error::Io(e));
710        }
711    };
712
713    let mut batch: Batch = serde_yaml::from_reader(file)?;
714
715    batch.basepath = directory.to_path_buf();
716
717    // Set local path for entries
718    for (key, entry) in batch.work.iter_mut() {
719        entry.local_path = directory.join(key);
720    }
721
722    Ok(Some(batch))
723}
724
725/// Publish a single batch entry.
726fn publish_one(
727    url: &url::Url,
728    local_tree: &dyn breezyshim::tree::WorkingTree,
729    batch_name: &str,
730    mode: Mode,
731    existing_proposal_url: Option<&url::Url>,
732    labels: Option<Vec<String>>,
733    derived_owner: Option<&str>,
734    refresh: bool,
735    commit_message: Option<&str>,
736    title: Option<&str>,
737    description: Option<&str>,
738    mut overwrite: Option<bool>,
739    auto_merge: Option<bool>,
740) -> Result<PublishResult, PublishError> {
741    let main_branch = match crate::vcs::open_branch(url, None, None, None) {
742        Ok(b) => b,
743        Err(e) => {
744            log::error!("{}: {}", url, e);
745            return Err(e.into());
746        }
747    };
748
749    let (forge, existing_proposal, mut resume_branch) =
750        match breezyshim::forge::get_forge(&main_branch) {
751            Ok(f) => {
752                let (existing_proposal, resume_branch) = if let Some(existing_proposal_url) =
753                    existing_proposal_url
754                {
755                    let existing_proposal = f.get_proposal_by_url(existing_proposal_url).unwrap();
756                    let resume_branch_url =
757                        existing_proposal.get_source_branch_url().unwrap().unwrap();
758                    let (resume_branch_url, params) =
759                        breezyshim::urlutils::split_segment_parameters(&resume_branch_url);
760                    let resume_branch_name = params.get("branch");
761                    let resume_branch = match crate::vcs::open_branch(
762                        &resume_branch_url,
763                        None,
764                        None,
765                        resume_branch_name.map(|x| x.as_str()),
766                    ) {
767                        Ok(b) => b,
768                        Err(e) => {
769                            log::error!("{} {:?}: {}", resume_branch_url, resume_branch_name, e);
770                            return Err(e.into());
771                        }
772                    };
773                    (Some(existing_proposal), Some(resume_branch))
774                } else {
775                    (None, None)
776                };
777                (Some(f), existing_proposal, resume_branch)
778            }
779            Err(BrzError::UnsupportedForge(e)) => {
780                if mode != Mode::Push {
781                    return Err(BrzError::UnsupportedForge(e).into());
782                }
783
784                // We can't figure out what branch to resume from when there's no forge
785                // that can tell us.
786                log::warn!(
787                    "Unsupported forge ({}), will attempt to push to {}",
788                    e,
789                    crate::vcs::full_branch_url(&main_branch),
790                );
791                (None, None, None)
792            }
793            Err(e) => {
794                log::error!("{}: {}", url, e);
795                return Err(e.into());
796            }
797        };
798    if refresh {
799        if resume_branch.is_some() {
800            overwrite = Some(true);
801        }
802        resume_branch = None;
803    }
804    if let Some(ref existing_proposal) = existing_proposal {
805        log::info!("Updating {}", existing_proposal.url().unwrap());
806    }
807
808    let local_branch = local_tree.branch();
809
810    crate::publish::enable_tag_pushing(&local_branch).unwrap();
811
812    let publish_result = match crate::publish::publish_changes(
813        &local_branch,
814        &main_branch,
815        resume_branch.as_ref(),
816        mode,
817        batch_name,
818        |_df, _ep| description.unwrap().to_string(),
819        Some(|_ep: Option<&crate::proposal::MergeProposal>| commit_message.map(|s| s.to_string())),
820        Some(|_ep: Option<&crate::proposal::MergeProposal>| title.map(|s| s.to_string())),
821        forge.as_ref(),
822        Some(true),
823        None,
824        overwrite,
825        existing_proposal,
826        labels,
827        None,
828        derived_owner,
829        None,
830        None,
831        auto_merge,
832        None,
833    ) {
834        Ok(r) => r,
835        Err(e) => match e {
836            PublishError::UnsupportedForge(ref url) => {
837                log::error!("No known supported forge for {}. Run 'svp login'?", url);
838                return Err(e);
839            }
840            PublishError::InsufficientChangesForNewProposal => {
841                log::info!("Insufficient changes for a new merge proposal");
842                return Err(e);
843            }
844            PublishError::DivergedBranches() => {
845                if resume_branch.is_none() {
846                    return Err(PublishError::UnrelatedBranchExists);
847                }
848                log::warn!("Branch exists that has diverged");
849                return Err(e);
850            }
851            PublishError::ForgeLoginRequired => {
852                log::error!(
853                    "Credentials for hosting site at {} missing. Run 'svp login'?",
854                    url
855                );
856                return Err(e);
857            }
858            _ => {
859                log::error!("Failed to publish: {}", e);
860                return Err(e);
861            }
862        },
863    };
864
865    if let Some(ref proposal) = publish_result.proposal {
866        if publish_result.is_new == Some(true) {
867            log::info!("Merge proposal created.");
868        } else {
869            log::info!("Merge proposal updated.")
870        }
871        log::info!("URL: {}", proposal.url().unwrap());
872        log::info!(
873            "Description: {}",
874            proposal.get_description().unwrap().unwrap()
875        );
876    }
877    Ok(publish_result)
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use crate::Mode;
884    use breezyshim::testing::TestEnv;
885    use serial_test::serial;
886    use std::collections::HashMap;
887
888    #[test]
889    #[serial]
890    fn test_entry_from_recipe() {
891        let _test_env = TestEnv::new();
892        let td = tempfile::tempdir().unwrap();
893        let remote = tempfile::tempdir().unwrap();
894        breezyshim::controldir::create_branch_convenience_as_generic(
895            &url::Url::from_directory_path(remote.path()).unwrap(),
896            None,
897            &breezyshim::controldir::ControlDirFormat::default(),
898        )
899        .unwrap();
900        let recipe = crate::recipe::RecipeBuilder::new();
901        let recipe = recipe
902            .shell("echo hello > hello.txt; echo hello".to_owned())
903            .build();
904        let entry = super::Entry::from_recipe(
905            &recipe,
906            td.path(),
907            &url::Url::from_directory_path(remote.path()).unwrap(),
908            std::path::Path::new(""),
909            None,
910            None,
911        )
912        .unwrap();
913        assert_eq!(entry.description, "hello\n");
914    }
915
916    #[test]
917    #[serial]
918    fn test_batch_from_recipe() {
919        let _test_env = TestEnv::new();
920        let td = tempfile::tempdir().unwrap();
921        let remote = tempfile::tempdir().unwrap();
922        breezyshim::controldir::create_branch_convenience_as_generic(
923            &url::Url::from_directory_path(remote.path()).unwrap(),
924            None,
925            &breezyshim::controldir::ControlDirFormat::default(),
926        )
927        .unwrap();
928        let recipe = crate::recipe::RecipeBuilder::new();
929        let recipe = recipe
930            .name("hello".to_owned())
931            .shell("echo hello > hello.txt; echo hello".to_owned())
932            .build();
933        let candidate = crate::candidates::Candidate {
934            url: url::Url::from_directory_path(remote.path()).unwrap(),
935            subpath: None,
936            paths: None,
937            default_mode: None,
938            branch: None,
939            name: Some("foo".to_owned()),
940        };
941        let batch =
942            super::Batch::from_recipe(&recipe, std::iter::once(&candidate), td.path(), None)
943                .unwrap();
944        assert_eq!(batch.work.len(), 1);
945        let entry = batch.work.get("foo").unwrap();
946        assert_eq!(entry.description, "hello\n");
947    }
948
949    #[test]
950    #[serial]
951    fn test_drop_batch_entry() {
952        let _test_env = TestEnv::new();
953        let td = tempfile::tempdir().unwrap();
954        let remote = tempfile::tempdir().unwrap();
955        breezyshim::controldir::create_branch_convenience_as_generic(
956            &url::Url::from_directory_path(remote.path()).unwrap(),
957            None,
958            &breezyshim::controldir::ControlDirFormat::default(),
959        )
960        .unwrap();
961        let recipe = crate::recipe::RecipeBuilder::new();
962        let recipe = recipe
963            .name("hello".to_owned())
964            .shell("echo hello > hello.txt; echo hello".to_owned())
965            .build();
966        let candidate = crate::candidates::Candidate {
967            url: url::Url::from_directory_path(remote.path()).unwrap(),
968            subpath: None,
969            paths: None,
970            default_mode: None,
971            branch: None,
972            name: Some("foo".to_owned()),
973        };
974
975        // Create batch with one entry
976        let batch =
977            super::Batch::from_recipe(&recipe, std::iter::once(&candidate), td.path(), None)
978                .unwrap();
979        assert_eq!(batch.work.len(), 1);
980        assert!(batch.work.contains_key("foo"));
981
982        // Verify the entry directory exists
983        assert!(td.path().join("foo").exists());
984        assert!(td.path().join("batch.yaml").exists());
985
986        // Drop the entry
987        super::drop_batch_entry(td.path(), "foo").unwrap();
988
989        // Verify the entry was removed
990        let loaded_batch = super::load_batch_metadata(td.path()).unwrap().unwrap();
991        assert_eq!(loaded_batch.work.len(), 0);
992        assert!(!loaded_batch.work.contains_key("foo"));
993        assert!(!td.path().join("foo").exists());
994
995        // Test dropping non-existent entry (should not error)
996        super::drop_batch_entry(td.path(), "non-existent").unwrap();
997    }
998
999    #[test]
1000    fn test_load_save_batch_metadata() {
1001        let td = tempfile::tempdir().unwrap();
1002        let absolute_path = td.path().canonicalize().unwrap();
1003
1004        // Create a basic recipe
1005        let recipe = crate::recipe::RecipeBuilder::new()
1006            .name("test-batch".to_owned())
1007            .shell("echo test".to_owned())
1008            .build();
1009
1010        // Create a batch
1011        let mut batch = Batch {
1012            version: CURRENT_VERSION,
1013            recipe: recipe.clone(),
1014            name: "test-batch".to_string(),
1015            work: HashMap::new(),
1016            basepath: absolute_path.clone(),
1017        };
1018
1019        // Add a sample entry
1020        let entry = Entry {
1021            local_path: absolute_path.join("test-entry"),
1022            subpath: Some(std::path::PathBuf::from("subdir")),
1023            target_branch_url: Some(url::Url::parse("https://example.com/repo").unwrap()),
1024            description: "Test description".to_string(),
1025            commit_message: Some("Test commit message".to_string()),
1026            auto_merge: Some(true),
1027            mode: Mode::Propose,
1028            title: Some("Test title".to_string()),
1029            owner: None,
1030            labels: Some(vec!["test-label".to_string()]),
1031            proposal_url: None,
1032            context: serde_yaml::Value::Null,
1033        };
1034
1035        batch.work.insert("test-entry".to_string(), entry);
1036
1037        // Save batch metadata
1038        super::save_batch_metadata(&absolute_path, &batch).unwrap();
1039
1040        // Verify batch.yaml exists
1041        assert!(absolute_path.join("batch.yaml").exists());
1042
1043        // Load batch metadata
1044        let loaded_batch = super::load_batch_metadata(&absolute_path).unwrap().unwrap();
1045
1046        // Verify loaded batch matches original
1047        assert_eq!(loaded_batch.version, batch.version);
1048        assert_eq!(loaded_batch.name, batch.name);
1049        assert_eq!(loaded_batch.basepath, batch.basepath);
1050        assert_eq!(loaded_batch.work.len(), 1);
1051
1052        let loaded_entry = loaded_batch.work.get("test-entry").unwrap();
1053        assert_eq!(loaded_entry.local_path, absolute_path.join("test-entry"));
1054        assert_eq!(
1055            loaded_entry.subpath,
1056            Some(std::path::PathBuf::from("subdir"))
1057        );
1058        assert_eq!(
1059            loaded_entry.target_branch_url,
1060            Some(url::Url::parse("https://example.com/repo").unwrap())
1061        );
1062        assert_eq!(loaded_entry.description, "Test description");
1063        assert_eq!(
1064            loaded_entry.commit_message,
1065            Some("Test commit message".to_string())
1066        );
1067        assert_eq!(loaded_entry.auto_merge, Some(true));
1068        assert_eq!(loaded_entry.mode, Mode::Propose);
1069        assert_eq!(loaded_entry.title, Some("Test title".to_string()));
1070        assert_eq!(loaded_entry.labels, Some(vec!["test-label".to_string()]));
1071
1072        // Test loading non-existent batch
1073        let non_existent_dir = absolute_path.join("non-existent");
1074        std::fs::create_dir(&non_existent_dir).unwrap();
1075        let result = super::load_batch_metadata(&non_existent_dir).unwrap();
1076        assert!(result.is_none());
1077    }
1078}