Enable running sub-recipes from GitHub (#3207)

Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Will Pfleger
2025-07-02 20:06:38 -04:00
committed by GitHub
parent 5f7e50ef4a
commit 49d98e061c
5 changed files with 131 additions and 44 deletions

View File

@@ -522,9 +522,9 @@ enum Command {
/// Additional sub-recipe file paths
#[arg(
long = "sub-recipe",
value_name = "FILE",
help = "Path to a sub-recipe YAML file (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.",
value_name = "RECIPE",
help = "Sub-recipe name or file path (can be specified multiple times)",
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
)]
additional_sub_recipes: Vec<String>,

View File

@@ -1,8 +1,9 @@
use std::path::PathBuf;
use anyhow::Result;
use anyhow::{anyhow, Result};
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};
#[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();
if !additional_sub_recipes.is_empty() {
additional_sub_recipes.iter().for_each(|sub_recipe_path| {
let path = convert_path(sub_recipe_path);
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let additional_sub_recipe: SubRecipe = SubRecipe {
path: path.to_string_lossy().to_string(),
name,
values: None,
};
all_sub_recipes.push(additional_sub_recipe);
});
for sub_recipe_name in additional_sub_recipes {
match retrieve_recipe_file(&sub_recipe_name) {
Ok(recipe_file) => {
let name = extract_recipe_name(&sub_recipe_name);
let recipe_file_path = recipe_file.file_path;
let additional_sub_recipe = SubRecipe {
path: recipe_file_path.to_string_lossy().to_string(),
name,
values: None,
};
all_sub_recipes.push(additional_sub_recipe);
}
Err(e) => {
return Err(anyhow!(
"Could not retrieve sub-recipe '{}': {}",
sub_recipe_name,
e
));
}
}
}
}
Ok((
InputConfig {
@@ -53,13 +61,18 @@ pub fn extract_recipe_info_from_cli(
))
}
fn convert_path(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home_dir) = dirs::home_dir() {
return home_dir.join(stripped);
}
fn extract_recipe_name(recipe_identifier: &str) -> String {
// If it's a path (contains / or \), extract the file stem
if recipe_identifier.contains('/') || recipe_identifier.contains('\\') {
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)]
@@ -114,11 +127,22 @@ mod tests {
#[test]
fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() {
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 recipe_name = recipe_path.to_str().unwrap().to_string();
let additional_sub_recipes = vec![
"path/to/sub_recipe1.yaml".to_string(),
"another/sub_recipe2.yaml".to_string(),
sub_recipe1_path.to_string_lossy().to_string(),
sub_recipe2_path.to_string_lossy().to_string(),
];
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].name, "existing_sub_recipe".to_string());
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!(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!(sub_recipes[2].values.is_none());
assert!(response.is_some());

View File

@@ -9,12 +9,13 @@ use std::process::Stdio;
use tar::Archive;
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 fn retrieve_recipe_from_github(
recipe_name: &str,
recipe_repo_full_name: &str,
) -> Result<(String, PathBuf)> {
) -> Result<RecipeFile> {
println!(
"📦 Looking for recipe \"{}\" in github repo: {}",
recipe_name, recipe_repo_full_name
@@ -26,7 +27,13 @@ pub fn retrieve_recipe_from_github(
for attempt in 1..=max_attempts {
match clone_and_download_recipe(recipe_name, recipe_repo_full_name) {
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) => {
@@ -47,7 +54,7 @@ fn clean_cloned_dirs(recipe_repo_full_name: &str) -> anyhow::Result<()> {
}
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 {
let candidate_file_path = download_dir.join(format!("recipe.{}", ext));
if candidate_file_path.exists() {
@@ -59,7 +66,7 @@ fn read_recipe_file(download_dir: &Path) -> Result<String> {
.unwrap()
.display()
);
return Ok(content);
return Ok((content, candidate_file_path));
}
}

View File

@@ -2,7 +2,7 @@ use crate::recipes::print_recipe::{
missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation,
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::{
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,
params: Vec<(String, 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
.to_str()
.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> {
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
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
@@ -87,7 +95,11 @@ pub fn explain_recipe_with_parameters(
recipe_name: &str,
params: Vec<(String, String)>,
) -> 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
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;

View File

@@ -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";
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
// If recipe_name ends with yaml or json, treat it as a direct file path
pub struct RecipeFile {
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
.iter()
.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);
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| {
if let Some(recipe_repo_full_name) = configured_github_recipe_repo() {
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 {
let recipe_path = dir.join(format!("{}.{}", recipe_name, ext));
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(".")];
if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) {
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)> {
let path = recipe_path.as_ref();
fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf {
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))?;
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()))?
.to_path_buf();
Ok((content, parent_dir))
Ok(RecipeFile {
content,
parent_dir,
file_path: canonical,
})
}