Enable running sub-recipes from GitHub (#3207)
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
@@ -522,9 +522,9 @@ enum Command {
|
|||||||
/// Additional sub-recipe file paths
|
/// Additional sub-recipe file paths
|
||||||
#[arg(
|
#[arg(
|
||||||
long = "sub-recipe",
|
long = "sub-recipe",
|
||||||
value_name = "FILE",
|
value_name = "RECIPE",
|
||||||
help = "Path to a sub-recipe YAML file (can be specified multiple times)",
|
help = "Sub-recipe name or file path (can be specified multiple times)",
|
||||||
long_help = "Specify paths to sub-recipe YAML files that contain additional recipe configuration or instructions to be used alongside the main recipe. Can be specified multiple times to include multiple sub-recipes.",
|
long_help = "Specify sub-recipes to include alongside the main recipe. Can be:\n - Recipe names from GitHub (if GOOSE_RECIPE_GITHUB_REPO is configured)\n - Local file paths to YAML files\nCan be specified multiple times to include multiple sub-recipes.",
|
||||||
action = clap::ArgAction::Append
|
action = clap::ArgAction::Append
|
||||||
)]
|
)]
|
||||||
additional_sub_recipes: Vec<String>,
|
additional_sub_recipes: Vec<String>,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use goose::recipe::{Response, SubRecipe};
|
use goose::recipe::{Response, SubRecipe};
|
||||||
|
|
||||||
|
use crate::recipes::search_recipe::retrieve_recipe_file;
|
||||||
use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings};
|
use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings};
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -22,20 +23,27 @@ pub fn extract_recipe_info_from_cli(
|
|||||||
});
|
});
|
||||||
let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default();
|
let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default();
|
||||||
if !additional_sub_recipes.is_empty() {
|
if !additional_sub_recipes.is_empty() {
|
||||||
additional_sub_recipes.iter().for_each(|sub_recipe_path| {
|
for sub_recipe_name in additional_sub_recipes {
|
||||||
let path = convert_path(sub_recipe_path);
|
match retrieve_recipe_file(&sub_recipe_name) {
|
||||||
let name = path
|
Ok(recipe_file) => {
|
||||||
.file_stem()
|
let name = extract_recipe_name(&sub_recipe_name);
|
||||||
.and_then(|s| s.to_str())
|
let recipe_file_path = recipe_file.file_path;
|
||||||
.unwrap_or("unknown")
|
let additional_sub_recipe = SubRecipe {
|
||||||
.to_string();
|
path: recipe_file_path.to_string_lossy().to_string(),
|
||||||
let additional_sub_recipe: SubRecipe = SubRecipe {
|
name,
|
||||||
path: path.to_string_lossy().to_string(),
|
values: None,
|
||||||
name,
|
};
|
||||||
values: None,
|
all_sub_recipes.push(additional_sub_recipe);
|
||||||
};
|
}
|
||||||
all_sub_recipes.push(additional_sub_recipe);
|
Err(e) => {
|
||||||
});
|
return Err(anyhow!(
|
||||||
|
"Could not retrieve sub-recipe '{}': {}",
|
||||||
|
sub_recipe_name,
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok((
|
Ok((
|
||||||
InputConfig {
|
InputConfig {
|
||||||
@@ -53,13 +61,18 @@ pub fn extract_recipe_info_from_cli(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_path(path: &str) -> PathBuf {
|
fn extract_recipe_name(recipe_identifier: &str) -> String {
|
||||||
if let Some(stripped) = path.strip_prefix("~/") {
|
// If it's a path (contains / or \), extract the file stem
|
||||||
if let Some(home_dir) = dirs::home_dir() {
|
if recipe_identifier.contains('/') || recipe_identifier.contains('\\') {
|
||||||
return home_dir.join(stripped);
|
PathBuf::from(recipe_identifier)
|
||||||
}
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
// If it's just a name (like "weekly-updates"), use it directly
|
||||||
|
recipe_identifier.to_string()
|
||||||
}
|
}
|
||||||
PathBuf::from(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -114,11 +127,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() {
|
fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() {
|
||||||
let (_temp_dir, recipe_path) = create_recipe();
|
let (_temp_dir, recipe_path) = create_recipe();
|
||||||
|
|
||||||
|
// Create actual sub-recipe files in the temp directory
|
||||||
|
std::fs::create_dir_all(_temp_dir.path().join("path/to")).unwrap();
|
||||||
|
std::fs::create_dir_all(_temp_dir.path().join("another")).unwrap();
|
||||||
|
|
||||||
|
let sub_recipe1_path = _temp_dir.path().join("path/to/sub_recipe1.yaml");
|
||||||
|
let sub_recipe2_path = _temp_dir.path().join("another/sub_recipe2.yaml");
|
||||||
|
|
||||||
|
std::fs::write(&sub_recipe1_path, "title: Sub Recipe 1").unwrap();
|
||||||
|
std::fs::write(&sub_recipe2_path, "title: Sub Recipe 2").unwrap();
|
||||||
|
|
||||||
let params = vec![("name".to_string(), "my_value".to_string())];
|
let params = vec![("name".to_string(), "my_value".to_string())];
|
||||||
let recipe_name = recipe_path.to_str().unwrap().to_string();
|
let recipe_name = recipe_path.to_str().unwrap().to_string();
|
||||||
let additional_sub_recipes = vec![
|
let additional_sub_recipes = vec![
|
||||||
"path/to/sub_recipe1.yaml".to_string(),
|
sub_recipe1_path.to_string_lossy().to_string(),
|
||||||
"another/sub_recipe2.yaml".to_string(),
|
sub_recipe2_path.to_string_lossy().to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
let (input_config, settings, sub_recipes, response) =
|
let (input_config, settings, sub_recipes, response) =
|
||||||
@@ -143,10 +167,16 @@ mod tests {
|
|||||||
assert_eq!(sub_recipes[0].path, "existing_sub_recipe.yaml".to_string());
|
assert_eq!(sub_recipes[0].path, "existing_sub_recipe.yaml".to_string());
|
||||||
assert_eq!(sub_recipes[0].name, "existing_sub_recipe".to_string());
|
assert_eq!(sub_recipes[0].name, "existing_sub_recipe".to_string());
|
||||||
assert!(sub_recipes[0].values.is_none());
|
assert!(sub_recipes[0].values.is_none());
|
||||||
assert_eq!(sub_recipes[1].path, "path/to/sub_recipe1.yaml".to_string());
|
assert_eq!(
|
||||||
|
sub_recipes[1].path,
|
||||||
|
sub_recipe1_path.to_string_lossy().to_string()
|
||||||
|
);
|
||||||
assert_eq!(sub_recipes[1].name, "sub_recipe1".to_string());
|
assert_eq!(sub_recipes[1].name, "sub_recipe1".to_string());
|
||||||
assert!(sub_recipes[1].values.is_none());
|
assert!(sub_recipes[1].values.is_none());
|
||||||
assert_eq!(sub_recipes[2].path, "another/sub_recipe2.yaml".to_string());
|
assert_eq!(
|
||||||
|
sub_recipes[2].path,
|
||||||
|
sub_recipe2_path.to_string_lossy().to_string()
|
||||||
|
);
|
||||||
assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string());
|
assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string());
|
||||||
assert!(sub_recipes[2].values.is_none());
|
assert!(sub_recipes[2].values.is_none());
|
||||||
assert!(response.is_some());
|
assert!(response.is_some());
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ use std::process::Stdio;
|
|||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
|
|
||||||
use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS;
|
use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS;
|
||||||
|
use crate::recipes::search_recipe::RecipeFile;
|
||||||
|
|
||||||
pub const GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "GOOSE_RECIPE_GITHUB_REPO";
|
pub const GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "GOOSE_RECIPE_GITHUB_REPO";
|
||||||
pub fn retrieve_recipe_from_github(
|
pub fn retrieve_recipe_from_github(
|
||||||
recipe_name: &str,
|
recipe_name: &str,
|
||||||
recipe_repo_full_name: &str,
|
recipe_repo_full_name: &str,
|
||||||
) -> Result<(String, PathBuf)> {
|
) -> Result<RecipeFile> {
|
||||||
println!(
|
println!(
|
||||||
"📦 Looking for recipe \"{}\" in github repo: {}",
|
"📦 Looking for recipe \"{}\" in github repo: {}",
|
||||||
recipe_name, recipe_repo_full_name
|
recipe_name, recipe_repo_full_name
|
||||||
@@ -26,7 +27,13 @@ pub fn retrieve_recipe_from_github(
|
|||||||
for attempt in 1..=max_attempts {
|
for attempt in 1..=max_attempts {
|
||||||
match clone_and_download_recipe(recipe_name, recipe_repo_full_name) {
|
match clone_and_download_recipe(recipe_name, recipe_repo_full_name) {
|
||||||
Ok(download_dir) => match read_recipe_file(&download_dir) {
|
Ok(download_dir) => match read_recipe_file(&download_dir) {
|
||||||
Ok(content) => return Ok((content, download_dir)),
|
Ok((content, recipe_file_local_path)) => {
|
||||||
|
return Ok(RecipeFile {
|
||||||
|
content,
|
||||||
|
parent_dir: download_dir.clone(),
|
||||||
|
file_path: recipe_file_local_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -47,7 +54,7 @@ fn clean_cloned_dirs(recipe_repo_full_name: &str) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn read_recipe_file(download_dir: &Path) -> Result<String> {
|
fn read_recipe_file(download_dir: &Path) -> Result<(String, PathBuf)> {
|
||||||
for ext in RECIPE_FILE_EXTENSIONS {
|
for ext in RECIPE_FILE_EXTENSIONS {
|
||||||
let candidate_file_path = download_dir.join(format!("recipe.{}", ext));
|
let candidate_file_path = download_dir.join(format!("recipe.{}", ext));
|
||||||
if candidate_file_path.exists() {
|
if candidate_file_path.exists() {
|
||||||
@@ -59,7 +66,7 @@ fn read_recipe_file(download_dir: &Path) -> Result<String> {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.display()
|
.display()
|
||||||
);
|
);
|
||||||
return Ok(content);
|
return Ok((content, candidate_file_path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::recipes::print_recipe::{
|
|||||||
missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation,
|
missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation,
|
||||||
print_required_parameters_for_template,
|
print_required_parameters_for_template,
|
||||||
};
|
};
|
||||||
use crate::recipes::search_recipe::retrieve_recipe_file;
|
use crate::recipes::search_recipe::{retrieve_recipe_file, RecipeFile};
|
||||||
use crate::recipes::template_recipe::{
|
use crate::recipes::template_recipe::{
|
||||||
parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview,
|
parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview,
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,11 @@ pub fn load_recipe_content_as_template(
|
|||||||
recipe_name: &str,
|
recipe_name: &str,
|
||||||
params: Vec<(String, String)>,
|
params: Vec<(String, String)>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
let RecipeFile {
|
||||||
|
content: recipe_file_content,
|
||||||
|
parent_dir: recipe_parent_dir,
|
||||||
|
..
|
||||||
|
} = retrieve_recipe_file(recipe_name)?;
|
||||||
let recipe_dir_str = recipe_parent_dir
|
let recipe_dir_str = recipe_parent_dir
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||||
@@ -70,7 +74,11 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
|
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
|
||||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
let RecipeFile {
|
||||||
|
content: recipe_file_content,
|
||||||
|
parent_dir: recipe_parent_dir,
|
||||||
|
..
|
||||||
|
} = retrieve_recipe_file(recipe_name)?;
|
||||||
let recipe_dir_str = recipe_parent_dir
|
let recipe_dir_str = recipe_parent_dir
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||||
@@ -87,7 +95,11 @@ pub fn explain_recipe_with_parameters(
|
|||||||
recipe_name: &str,
|
recipe_name: &str,
|
||||||
params: Vec<(String, String)>,
|
params: Vec<(String, String)>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
let RecipeFile {
|
||||||
|
content: recipe_file_content,
|
||||||
|
parent_dir: recipe_parent_dir,
|
||||||
|
..
|
||||||
|
} = retrieve_recipe_file(recipe_name)?;
|
||||||
let recipe_dir_str = recipe_parent_dir
|
let recipe_dir_str = recipe_parent_dir
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO
|
|||||||
|
|
||||||
const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH";
|
const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH";
|
||||||
|
|
||||||
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
pub struct RecipeFile {
|
||||||
// If recipe_name ends with yaml or json, treat it as a direct file path
|
pub content: String,
|
||||||
|
pub parent_dir: PathBuf,
|
||||||
|
pub file_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<RecipeFile> {
|
||||||
if RECIPE_FILE_EXTENSIONS
|
if RECIPE_FILE_EXTENSIONS
|
||||||
.iter()
|
.iter()
|
||||||
.any(|ext| recipe_name.ends_with(&format!(".{}", ext)))
|
.any(|ext| recipe_name.ends_with(&format!(".{}", ext)))
|
||||||
@@ -18,6 +23,12 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
|||||||
let path = PathBuf::from(recipe_name);
|
let path = PathBuf::from(recipe_name);
|
||||||
return read_recipe_file(path);
|
return read_recipe_file(path);
|
||||||
}
|
}
|
||||||
|
if is_file_path(recipe_name) || is_file_name(recipe_name) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Recipe file {} is not a json or yaml file",
|
||||||
|
recipe_name
|
||||||
|
));
|
||||||
|
}
|
||||||
retrieve_recipe_from_local_path(recipe_name).or_else(|e| {
|
retrieve_recipe_from_local_path(recipe_name).or_else(|e| {
|
||||||
if let Some(recipe_repo_full_name) = configured_github_recipe_repo() {
|
if let Some(recipe_repo_full_name) = configured_github_recipe_repo() {
|
||||||
retrieve_recipe_from_github(recipe_name, &recipe_repo_full_name)
|
retrieve_recipe_from_github(recipe_name, &recipe_repo_full_name)
|
||||||
@@ -27,7 +38,18 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)> {
|
fn is_file_path(recipe_name: &str) -> bool {
|
||||||
|
recipe_name.contains('/')
|
||||||
|
|| recipe_name.contains('\\')
|
||||||
|
|| recipe_name.starts_with('~')
|
||||||
|
|| recipe_name.starts_with('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_file_name(recipe_name: &str) -> bool {
|
||||||
|
Path::new(recipe_name).extension().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<RecipeFile> {
|
||||||
for ext in RECIPE_FILE_EXTENSIONS {
|
for ext in RECIPE_FILE_EXTENSIONS {
|
||||||
let recipe_path = dir.join(format!("{}.{}", recipe_name, ext));
|
let recipe_path = dir.join(format!("{}.{}", recipe_name, ext));
|
||||||
if let Ok(result) = read_recipe_file(recipe_path) {
|
if let Ok(result) = read_recipe_file(recipe_path) {
|
||||||
@@ -42,7 +64,7 @@ fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result<(String, PathBuf)> {
|
fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result<RecipeFile> {
|
||||||
let mut search_dirs = vec![PathBuf::from(".")];
|
let mut search_dirs = vec![PathBuf::from(".")];
|
||||||
if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) {
|
if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) {
|
||||||
let path_separator = if cfg!(windows) { ';' } else { ':' };
|
let path_separator = if cfg!(windows) { ';' } else { ':' };
|
||||||
@@ -78,10 +100,22 @@ fn configured_github_recipe_repo() -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<(String, PathBuf)> {
|
fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf {
|
||||||
let path = recipe_path.as_ref();
|
if let Some(path_str) = path.to_str() {
|
||||||
|
if let Some(stripped) = path_str.strip_prefix("~/") {
|
||||||
|
if let Some(home_dir) = dirs::home_dir() {
|
||||||
|
return home_dir.join(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(path)
|
fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<RecipeFile> {
|
||||||
|
let raw_path = recipe_path.as_ref();
|
||||||
|
let path = convert_path_with_tilde_expansion(raw_path);
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
.map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?;
|
.map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
let canonical = path.canonicalize().map_err(|e| {
|
let canonical = path.canonicalize().map_err(|e| {
|
||||||
@@ -97,5 +131,9 @@ fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<(String, PathBuf)>
|
|||||||
.ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))?
|
.ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))?
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
Ok((content, parent_dir))
|
Ok(RecipeFile {
|
||||||
|
content,
|
||||||
|
parent_dir,
|
||||||
|
file_path: canonical,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user