use breezyshim::prelude::Repository;
use breezyshim::workingtree;
use breezyshim::workspace::{check_clean_tree, reset_tree};
use breezyshim::{Branch, WorkingTree};
use clap::{Args, Parser, Subcommand};
use log::{error, info};
use silver_platter::candidates::Candidates;
use silver_platter::debian::codemod::{script_runner, CommandResult};
use silver_platter::proposal::{MergeProposal, MergeProposalStatus};
use silver_platter::publish::Error as PublishError;
use silver_platter::CodemodResult;
use silver_platter::Mode;
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long)]
debug: bool,
}
#[derive(Subcommand)]
enum Commands {
Forges {},
Login {
url: url::Url,
},
Proposals {
#[arg(short, long, default_value = "open")]
status: Option<MergeProposalStatus>,
},
Run(RunArgs),
Apply {
command: Option<String>,
#[arg(long)]
diff: bool,
#[arg(long)]
commit_pending: Option<silver_platter::CommitPending>,
#[arg(long)]
build_verify: bool,
#[arg(long, default_value(silver_platter::debian::DEFAULT_BUILDER))]
builder: String,
#[arg(long)]
build_target_dir: Option<std::path::PathBuf>,
#[arg(long)]
install: bool,
#[arg(long)]
dump_context: bool,
#[arg(long)]
recipe: Option<std::path::PathBuf>,
#[arg(long)]
no_update_changelog: bool,
#[arg(long)]
update_changelog: bool,
},
#[clap(subcommand)]
Batch(BatchArgs),
UploadPending {
#[arg(long)]
acceptable_keys: Option<Vec<String>>,
#[arg(long)]
gpg_verification: bool,
#[arg(long)]
min_commit_age: Option<i64>,
#[arg(long)]
diff: bool,
#[arg(long, default_value_t = format!("{} --source --source-only-changes --debbuildopt=-v$(LAST_VERSION)", silver_platter::debian::DEFAULT_BUILDER))]
builder: String,
#[arg(long, conflicts_with = "packages")]
maintainer: Option<Vec<String>>,
#[arg(long)]
vcswatch: bool,
#[arg(long)]
exclude: Option<Vec<String>>,
#[arg(long)]
autopkgtest_only: bool,
#[arg(long)]
allowed_committer: Option<Vec<String>>,
#[arg(long)]
shuffle: bool,
#[arg(long)]
verify_command: Option<String>,
#[arg(long, env = "APT_REPOSITORY")]
apt_repository: Option<String>,
#[arg(long, env = "APT_REPOSITORY_KEY")]
apt_repository_key: Option<std::path::PathBuf>,
packages: Vec<String>,
},
}
#[derive(Args)]
struct RunArgs {
url: Option<url::Url>,
#[arg(long)]
command: Option<String>,
#[arg(long)]
derived_owner: Option<String>,
#[arg(long)]
refresh: bool,
#[arg(long)]
label: Option<Vec<String>>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
diff: bool,
#[arg(long)]
push: Option<silver_platter::Mode>,
#[arg(long, default_value = "auto")]
commit_pending: Option<silver_platter::CommitPending>,
#[arg(long)]
verify_command: Option<String>,
#[arg(long)]
recipe: Option<std::path::PathBuf>,
#[arg(long)]
candidates: Option<std::path::PathBuf>,
#[arg(long)]
mode: Option<silver_platter::Mode>,
#[arg(long)]
no_update_changelog: bool,
#[arg(long)]
update_changelog: bool,
#[arg(long)]
build_verify: bool,
#[arg(long, default_value(silver_platter::debian::DEFAULT_BUILDER))]
builder: String,
#[arg(long)]
build_target_dir: Option<std::path::PathBuf>,
#[arg(long)]
install: bool,
}
#[derive(Subcommand)]
enum BatchArgs {
Generate {
#[arg(long)]
recipe: Option<std::path::PathBuf>,
#[arg(long)]
candidates: Option<std::path::PathBuf>,
directory: Option<std::path::PathBuf>,
},
Publish {
directory: std::path::PathBuf,
name: Option<String>,
#[arg(long)]
overwrite: bool,
},
Status {
directory: std::path::PathBuf,
codebase: Option<String>,
},
Diff {
directory: std::path::PathBuf,
codebase: String,
},
Refresh {
directory: std::path::PathBuf,
codebase: Option<String>,
},
}
fn run(args: &RunArgs) -> i32 {
let mut extra_env = HashMap::new();
if let Some(recipe) = &args.recipe {
extra_env.insert(
"RECIPEDIR".to_string(),
recipe.parent().unwrap().to_str().unwrap().to_string(),
);
}
let recipe = args
.recipe
.as_ref()
.map(|recipe| silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap());
let mut urls = vec![];
if let Some(url) = args.url.as_ref() {
urls.push(url.clone());
}
if let Some(candidates) = args.candidates.as_ref() {
let candidates = Candidates::from_path(candidates.as_path()).unwrap();
urls.extend(candidates.iter().map(|c| c.url.clone()));
}
let update_changelog = if args.update_changelog {
Some(true)
} else if args.no_update_changelog {
Some(false)
} else {
None
};
let commit_pending = if let Some(commit_pending) = args.commit_pending {
commit_pending
} else if let Some(recipe) = &recipe {
recipe.commit_pending
} else {
silver_platter::CommitPending::Auto
};
let command = if let Some(command) = args.command.as_ref() {
shlex::split(command.as_str()).unwrap()
} else if let Some(recipe) = &recipe {
recipe.command.as_ref().unwrap().argv()
} else {
error!("No command specified");
return 1;
};
let branch = if let Some(branch) = args.branch.as_ref() {
branch.clone()
} else if let Some(recipe) = recipe.as_ref() {
recipe.name.clone().unwrap()
} else {
silver_platter::derived_branch_name(command.first().unwrap()).to_string()
};
let mode = if let Some(mode) = args.mode {
mode
} else if let Some(recipe) = &recipe {
recipe.mode.unwrap()
} else {
silver_platter::Mode::Propose
};
let mut refresh = args.refresh;
if let Some(ref recipe) = recipe {
if recipe.resume.is_some() {
refresh = true;
}
}
let recipe_ref = recipe.as_ref();
let allow_create_proposal = |result: &CommandResult| -> bool {
if let Some(value) = result.value.as_ref() {
if let Some(recipe) = recipe_ref {
if let Some(merge_request) = recipe.merge_request.as_ref() {
if let Some(propose_threshold) = merge_request.propose_threshold {
return *value >= propose_threshold;
}
}
}
}
true
};
let recipe_ref = recipe.as_ref();
let get_commit_message = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| {
if let Some(recipe) = recipe_ref {
if let Some(merge_request) = recipe.merge_request.as_ref() {
return merge_request
.render_commit_message(&result.tera_context())
.unwrap();
}
}
if let Some(existing_proposal) = existing_proposal.as_ref() {
return existing_proposal.get_commit_message().unwrap();
}
None
};
let recipe_ref = recipe.as_ref();
let get_title = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| {
if let Some(recipe) = recipe_ref {
if let Some(merge_request) = recipe.merge_request.as_ref() {
return merge_request.render_title(&result.tera_context()).unwrap();
}
}
if let Some(existing_proposal) = existing_proposal {
return existing_proposal.get_title().unwrap();
}
None
};
let get_description = |result: &CommandResult,
description_format,
_existing_proposal: Option<&MergeProposal>|
-> String {
if let Some(recipe) = recipe.as_ref() {
if let Some(merge_request) = recipe.merge_request.as_ref() {
let description = merge_request
.render_description(description_format, &result.tera_context())
.unwrap();
if let Some(description) = description {
return description;
}
}
}
result.description.clone()
};
let mut retcode = 0;
let labels_ref = args
.label
.as_ref()
.map(|labels| labels.iter().map(|s| s.as_str()).collect::<Vec<_>>());
for url in urls {
let result = silver_platter::debian::run::apply_and_publish(
&url,
branch.as_str(),
command
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.as_slice(),
mode,
commit_pending,
labels_ref.as_deref(),
args.diff,
args.derived_owner.as_deref(),
refresh,
Some(allow_create_proposal),
Some(get_commit_message),
Some(get_title),
get_description,
update_changelog,
args.build_verify,
args.build_target_dir.clone(),
Some(args.builder.clone()),
args.install,
Some(extra_env.clone()),
);
retcode = std::cmp::max(retcode, result)
}
retcode
}
pub fn publish_entry(
batch: &mut silver_platter::batch::Batch,
name: &str,
refresh: bool,
overwrite: Option<bool>,
) -> bool {
let batch_name = batch.name.clone();
let entry = batch.get_mut(name).unwrap();
let publish_result = match entry.publish(&batch_name, refresh, overwrite) {
Ok(publish_result) => publish_result,
Err(PublishError::EmptyMergeProposal) => {
info!("No changes left");
batch.remove(name).unwrap();
return true;
}
Err(PublishError::UnrelatedBranchExists) => {
return false;
}
Err(e) => {
error!("Failed to publish {}: {}", name, e);
return false;
}
};
match publish_result.mode {
Mode::Push => {
batch.remove(name).unwrap();
}
Mode::Propose => {
entry.proposal_url = Some(publish_result.proposal.unwrap().url().unwrap());
}
Mode::PushDerived => {
batch.remove(name).unwrap();
}
_ => {
unreachable!();
}
}
true
}
pub fn batch_publish(
directory: &Path,
codebase: Option<&str>,
refresh: bool,
overwrite: Option<bool>,
) -> i32 {
let mut batch = match silver_platter::batch::load_batch_metadata(directory) {
Ok(Some(batch)) => batch,
Ok(None) => {
info!("No batch.yaml found in {}", directory.display());
return 1;
}
Err(e) => {
error!("Failed to load batch.yaml: {}", e);
return 1;
}
};
let mut errors = 0;
if let Some(codebase) = codebase {
if publish_entry(&mut batch, codebase, refresh, overwrite) {
silver_platter::batch::save_batch_metadata(directory, &batch).unwrap();
} else {
error!("Failed to publish {}", codebase);
errors = 1;
}
} else {
let names = batch.work.keys().cloned().collect::<Vec<_>>();
for name in names {
if !publish_entry(&mut batch, name.as_str(), refresh, overwrite) {
errors += 1;
}
}
silver_platter::batch::save_batch_metadata(directory, &batch).unwrap();
}
if batch.work.is_empty() {
info!(
"No work left in batch.yaml; you can now remove {}",
directory.display()
);
}
if errors > 0 {
1
} else {
0
}
}
fn is_launchpad_url(url: &url::Url) -> bool {
#[cfg(feature = "launchpad")]
{
launchpadlib::uris::is_launchpad_url(url)
}
#[cfg(not(feature = "launchpad"))]
{
url.host_str() == Some("launchpad.net")
|| url
.host_str()
.is_some_and(|h| h.ends_with(".launchpad.net"))
}
}
fn login(url: &url::Url) -> i32 {
let forge = if url.host_str() == Some("github.com") {
"github"
} else if is_launchpad_url(url) {
"launchpad"
} else {
"gitlab"
};
match forge {
"gitlab" => {
breezyshim::gitlab::login(url).unwrap();
}
"github" => {
breezyshim::github::login().unwrap();
}
"launchpad" => {
breezyshim::launchpad::login(url);
}
_ => {
panic!("Unknown forge {}", forge);
}
}
0
}
pub fn batch_refresh(directory: &Path, codebase: Option<&str>) -> i32 {
let directory = directory.canonicalize().unwrap();
let mut batch = match silver_platter::batch::load_batch_metadata(&directory) {
Ok(Some(batch)) => batch,
Ok(None) => {
info!("No batch.yaml found in {}", directory.display());
return 1;
}
Err(e) => {
error!(
"Failed to load batch metadata from {}: {}",
directory.display(),
e
);
return 1;
}
};
let mut errors = 0;
if let Some(codebase) = codebase {
let entry = batch.work.get_mut(codebase).unwrap();
if entry.refresh(&batch.recipe, None).is_err() {
errors += 1;
}
} else {
let names = batch.work.keys().cloned().collect::<Vec<_>>();
for name in names {
let entry = batch.work.get_mut(name.as_str()).unwrap();
if entry.refresh(&batch.recipe, None).is_err() {
errors += 1;
}
}
}
match silver_platter::batch::save_batch_metadata(&directory, &batch) {
Ok(_) => {}
Err(e) => {
error!(
"Failed to save batch metadata to {}: {}",
directory.display(),
e
);
return 1;
}
}
if batch.work.is_empty() {
info!(
"No work left in batch.yaml; you can now remove {}",
directory.display()
);
}
if errors > 0 {
1
} else {
0
}
}
fn main() -> Result<(), i32> {
let cli = Cli::parse();
env_logger::builder()
.format(|buf, record| writeln!(buf, "{}", record.args()))
.filter(
None,
if cli.debug {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
},
)
.init();
breezyshim::init();
breezyshim::plugin::load_plugins();
match &cli.command {
Commands::Forges {} => {
for instance in breezyshim::forge::iter_forge_instances() {
println!("{} ({})", instance.base_url(), instance.forge_kind());
}
Ok(())
}
Commands::Login { url } => match login(url) {
0 => Ok(()),
e => Err(e),
},
Commands::Proposals { status } => {
let statuses = status.as_ref().map(|status| vec![*status]);
for (_forge, proposal) in silver_platter::proposal::iter_all_mps(statuses) {
println!("{}", proposal.url().unwrap());
}
Ok(())
}
Commands::Run(args) => match run(args) {
0 => Ok(()),
e => Err(e),
},
Commands::Apply {
command,
diff,
commit_pending,
install,
mut build_verify,
ref build_target_dir,
builder,
dump_context,
no_update_changelog,
update_changelog,
recipe,
} => {
if *install {
build_verify = true;
}
let recipe = recipe
.as_ref()
.map(|recipe| silver_platter::recipe::Recipe::from_path(recipe).unwrap());
let commit_pending = if let Some(commit_pending) = commit_pending {
*commit_pending
} else if let Some(recipe) = &recipe {
recipe.commit_pending
} else {
silver_platter::CommitPending::Auto
};
let command = if let Some(command) = command.as_ref() {
shlex::split(command.as_str()).unwrap()
} else if let Some(recipe) = &recipe {
recipe.command.as_ref().unwrap().argv()
} else {
error!("No command specified");
return Err(1);
};
let (local_tree, subpath) = workingtree::open_containing(Path::new(".")).unwrap();
check_clean_tree(
&local_tree,
&local_tree.basis_tree().unwrap(),
subpath.as_path(),
)
.unwrap();
let update_changelog = if *update_changelog {
Some(true)
} else if *no_update_changelog {
Some(false)
} else {
None
};
let result = match script_runner(
&local_tree,
command
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.as_slice(),
subpath.as_path(),
commit_pending,
None,
None,
None,
std::process::Stdio::inherit(),
update_changelog,
) {
Ok(result) => result,
Err(err) => {
error!("Failed: {}", err);
reset_tree(&local_tree, None, Some(subpath.as_path())).unwrap();
return Err(1);
}
};
let mut td = None;
let mut build_target_dir = build_target_dir.clone();
if build_verify {
if build_target_dir.is_none() {
td = Some(tempfile::tempdir().unwrap());
build_target_dir = td.as_ref().map(|td| td.path().to_owned());
}
silver_platter::debian::build(
&local_tree,
&subpath,
&local_tree.branch(),
Some(builder),
build_target_dir.as_deref(),
)
.unwrap();
}
info!("Succeeded: {} ", result.description);
if *diff {
let old_tree = local_tree.revision_tree(&result.old_revision).unwrap();
let new_tree = local_tree.revision_tree(&result.new_revision).unwrap();
breezyshim::diff::show_diff_trees(
old_tree.as_ref(),
new_tree.as_ref(),
Box::new(std::io::stdout()),
Some("old/"),
Some("new/"),
)
.unwrap();
}
if *install {
silver_platter::debian::install_built_package(
&local_tree,
subpath.as_path(),
build_target_dir.as_ref().unwrap(),
)
.unwrap();
}
if let Some(td) = td.take() {
td.close().unwrap();
}
if *dump_context {
let context = result.context.unwrap();
println!("{}", serde_json::to_string_pretty(&context).unwrap());
}
Ok(())
}
Commands::UploadPending {
acceptable_keys,
gpg_verification,
min_commit_age,
diff,
maintainer,
builder,
autopkgtest_only,
vcswatch,
shuffle,
exclude,
verify_command,
allowed_committer,
apt_repository,
apt_repository_key,
packages,
} => silver_platter::debian::uploader::main(
packages.clone(),
acceptable_keys.clone(),
*gpg_verification,
*min_commit_age,
*diff,
builder.clone(),
maintainer.clone(),
*vcswatch,
exclude.clone(),
*autopkgtest_only,
allowed_committer.clone(),
cli.debug,
*shuffle,
verify_command.clone(),
apt_repository.clone(),
apt_repository_key.clone(),
),
Commands::Batch(args) => match args {
BatchArgs::Generate {
recipe,
candidates,
directory,
} => {
let recipe = if let Some(recipe) = recipe {
silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap()
} else {
panic!("No recipe specified");
};
let candidates = if let Some(candidate_list) = candidates {
Candidates::from_path(candidate_list.as_path()).unwrap()
} else {
Candidates::new()
};
let directory = if let Some(directory) = directory.as_ref() {
directory.clone()
} else {
info!("Using output directory: {}", recipe.name.as_ref().unwrap());
std::path::PathBuf::from(recipe.name.clone().unwrap())
};
silver_platter::batch::Batch::from_recipe(
&recipe,
candidates.iter(),
directory.as_path(),
None,
)
.unwrap();
info!("Now, review the patches under {}, edit {}/batch.yaml as appropriate and then run \"svp batch publish {}\"", directory.display(), directory.display(), directory.display());
Ok(())
}
BatchArgs::Publish {
directory,
name,
overwrite,
} => {
let overwrite = if *overwrite { Some(true) } else { None };
let ret = batch_publish(directory.as_path(), name.as_deref(), false, overwrite);
info!(
"To see the status of open merge requests, run: \"svp batch status {}\"",
directory.display()
);
match ret {
0 => Ok(()),
e => Err(e),
}
}
BatchArgs::Status {
directory,
codebase,
} => {
let batch = match silver_platter::batch::load_batch_metadata(directory) {
Ok(Some(batch)) => batch,
Ok(None) => {
info!("No batch.yaml found in {}", directory.display());
return Err(1);
}
Err(e) => {
error!("Failed to load batch.yaml: {}", e);
return Err(1);
}
};
if let Some(codebase) = codebase {
let entry = batch.work.get(codebase).unwrap();
info!("{}: {}", codebase, entry.status());
} else {
for (name, entry) in batch.work.iter() {
info!("{}: {}", name, entry.status());
}
}
Ok(())
}
BatchArgs::Diff {
directory,
codebase,
} => {
let batch = match silver_platter::batch::load_batch_metadata(directory) {
Ok(Some(batch)) => batch,
Ok(None) => {
info!("No batch.yaml found in {}", directory.display());
return Err(1);
}
Err(e) => {
error!("Failed to load batch.yaml: {}", e);
return Err(1);
}
};
let entry = batch.work.get(codebase.as_str()).unwrap();
let main_branch = match entry.target_branch() {
Ok(branch) => branch,
Err(e) => {
error!("Failed to open branch: {}", e);
return Err(1);
}
};
let local_branch = match entry.local_branch() {
Ok(branch) => branch,
Err(e) => {
error!("Failed to open branch: {}", e);
return Err(1);
}
};
let repository = local_branch.repository();
let main_revision = main_branch.last_revision();
repository
.fetch(&main_branch.repository(), Some(&main_revision))
.unwrap();
let main_tree = repository.revision_tree(&main_revision).unwrap();
breezyshim::diff::show_diff_trees(
&main_tree,
&local_branch.basis_tree().unwrap(),
Box::new(std::io::stdout()),
Some("old/"),
Some("new/"),
)
.unwrap();
Err(1)
}
BatchArgs::Refresh {
directory,
codebase,
} => {
let ret = batch_refresh(directory.as_path(), codebase.as_deref());
match ret {
0 => Ok(()),
e => Err(e),
}
}
},
}
}